Merge pull request #96 from NeuralNomadsAI/dev
Release v0.9.1 - Thinking variant, Robust process cleanup
This commit is contained in:
4
.github/workflows/release-ui.yml
vendored
4
.github/workflows/release-ui.yml
vendored
@@ -12,8 +12,8 @@ env:
|
||||
|
||||
jobs:
|
||||
release-ui:
|
||||
# Automated via reusable call (main releases); manual runs allowed on dev.
|
||||
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' }}
|
||||
# Automated via reusable call (main releases); manual runs allowed on dev/main.
|
||||
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
7
.opencode/commands/release-notes.md
Normal file
7
.opencode/commands/release-notes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Creates release notes
|
||||
agent: build
|
||||
---
|
||||
|
||||
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
|
||||
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
@@ -7384,7 +7384,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"@neuralnomads/codenomad": "file:../server"
|
||||
@@ -7418,7 +7418,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
@@ -7455,14 +7455,14 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.8.1",
|
||||
"minServerVersion": "0.9.1",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -177,8 +177,11 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||
)
|
||||
child.kill("SIGKILL")
|
||||
}, 4000)
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
@@ -376,4 +379,3 @@ export class CliProcessManager extends EventEmitter {
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -15,6 +15,7 @@ const PreferencesSchema = z.object({
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
|
||||
@@ -286,21 +286,33 @@ async function main() {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
logger.info("Received shutdown signal, closing server")
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
logger.info("Received shutdown signal, stopping workspaces and server")
|
||||
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
const shutdownWorkspaces = (async () => {
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
})()
|
||||
|
||||
const shutdownHttp = (async () => {
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
})()
|
||||
|
||||
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
|
||||
|
||||
// no-op: remote UI manifest replaces GitHub release monitor
|
||||
|
||||
|
||||
@@ -187,16 +187,27 @@ export class WorkspaceManager {
|
||||
|
||||
async shutdown() {
|
||||
this.options.logger.info("Shutting down all workspaces")
|
||||
|
||||
const stopTasks: Array<Promise<void>> = []
|
||||
|
||||
for (const [id, workspace] of this.workspaces) {
|
||||
if (workspace.pid) {
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
await this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
})
|
||||
} else {
|
||||
if (!workspace.pid) {
|
||||
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||
continue
|
||||
}
|
||||
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
stopTasks.push(
|
||||
this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (stopTasks.length > 0) {
|
||||
await Promise.allSettled(stopTasks)
|
||||
}
|
||||
|
||||
this.workspaces.clear()
|
||||
this.opencodeAuth.clear()
|
||||
this.options.logger.info("All workspaces cleared")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
|
||||
@@ -34,6 +34,8 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
let mut display = url.to_string();
|
||||
@@ -276,6 +278,7 @@ impl CliProcessManager {
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
@@ -290,7 +293,12 @@ impl CliProcessManager {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if start.elapsed() > Duration::from_secs(4) {
|
||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||
log_line(&format!(
|
||||
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||
CLI_STOP_GRACE_SECS,
|
||||
child.id()
|
||||
));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
|
||||
@@ -163,7 +163,8 @@ fn main() {
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
tauri::RunEvent::ExitRequested { api, .. } => {
|
||||
api.prevent_exit();
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
@@ -173,18 +174,18 @@ fn main() {
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} => {
|
||||
if app_handle.webview_windows().len() <= 1 {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
// Ensure we have time to stop the CLI process before the app exits.
|
||||
api.prevent_close();
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
@@ -94,6 +95,7 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void initGithubStars()
|
||||
updateInstanceTabBarHeight()
|
||||
const handleResize = () => updateInstanceTabBarHeight()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Agent } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import Kbd from "./kbd"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -99,15 +100,20 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
data-agent-selector
|
||||
class="selector-trigger"
|
||||
>
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label">
|
||||
<span class="selector-trigger-primary">
|
||||
Agent: {state.selectedOption()?.name ?? "None"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Select.Value>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
Agent: {state.selectedOption()?.name ?? "None"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Select.Value>
|
||||
</div>
|
||||
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
<Select.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Select.Icon>
|
||||
|
||||
38
packages/ui/src/components/brand-icons.tsx
Normal file
38
packages/ui/src/components/brand-icons.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
type BrandIconProps = {
|
||||
class?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export const GitHubMarkIcon: Component<BrandIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 98 96"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={props.title ? undefined : "true"}
|
||||
role={props.title ? "img" : "presentation"}
|
||||
class={props.class}
|
||||
>
|
||||
{props.title ? <title>{props.title}</title> : null}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const DiscordSymbolIcon: Component<BrandIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 64 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={props.title ? undefined : "true"}
|
||||
role={props.title ? "img" : "presentation"}
|
||||
class={props.class}
|
||||
>
|
||||
{props.title ? <title>{props.title}</title> : null}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -188,6 +191,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
if (isLoading()) return
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
@@ -242,170 +250,228 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
style="background-color: var(--surface-secondary)"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary inline-flex items-center justify-center"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<VersionPill />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
|
||||
<Show
|
||||
|
||||
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Recent Folders</h2>
|
||||
<p class="panel-subtitle">
|
||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button
|
||||
onClick={() => props.onAdvancedSettingsOpen?.()}
|
||||
class="panel-section-header w-full justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label="CodeNomad GitHub"
|
||||
title="CodeNomad GitHub"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<GitHubMarkIcon class="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
aria-label="CodeNomad GitHub Stars"
|
||||
title="CodeNomad GitHub Stars"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" />
|
||||
<Show when={githubStars() !== null}>
|
||||
<span class="text-xs font-medium">{formatCompactCount(githubStars()!)}</span>
|
||||
</Show>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label="CodeNomad Discord"
|
||||
title="CodeNomad Discord"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink(
|
||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-3 text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Remove</span>
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||
{/* Right column: recent folders */}
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Recent Folders</h2>
|
||||
<p class="panel-subtitle">
|
||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>Browse</span>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Left column: version + browse + advanced settings */}
|
||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-body flex items-center justify-center">
|
||||
<VersionPill />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Remove</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>Browse</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,15 +48,16 @@ import { clearSessionRenderCache } from "../message-block"
|
||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
|
||||
import SessionList from "../session-list"
|
||||
import KeyboardHint from "../keyboard-hint"
|
||||
import Kbd from "../kbd"
|
||||
import InstanceWelcomeView from "../instance-welcome-view"
|
||||
import InfoView from "../info-view"
|
||||
import InstanceServiceStatus from "../instance-service-status"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import ThinkingSelector from "../thinking-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import PermissionNotificationBanner from "../permission-notification-banner"
|
||||
import PermissionApprovalModal from "../permission-approval-modal"
|
||||
import Kbd from "../kbd"
|
||||
import { TodoListView } from "../tool-call/renderers/todo"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
@@ -432,6 +433,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const focusVariantSelectorControl = () => {
|
||||
const input = leftDrawerContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
|
||||
if (!input) return false
|
||||
input.focus()
|
||||
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||
return true
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingSidebarAction()
|
||||
if (!pending) return
|
||||
@@ -444,7 +453,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
setPendingSidebarAction(null)
|
||||
return
|
||||
}
|
||||
const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl()
|
||||
const handled =
|
||||
action === "focus-agent-selector"
|
||||
? focusAgentSelectorControl()
|
||||
: action === "focus-model-selector"
|
||||
? focusModelSelectorControl()
|
||||
: focusVariantSelectorControl()
|
||||
if (handled) {
|
||||
setPendingSidebarAction(null)
|
||||
}
|
||||
@@ -901,21 +915,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<div class="sidebar-selector-hints" aria-hidden="true">
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
|
||||
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import Kbd from "./kbd"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -105,7 +106,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
ref={triggerRef}
|
||||
class="selector-trigger"
|
||||
>
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
Model: {currentModelValue()?.name ?? "None"}
|
||||
</span>
|
||||
@@ -115,6 +116,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
<Combobox.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Combobox.Icon>
|
||||
|
||||
107
packages/ui/src/components/thinking-selector.tsx
Normal file
107
packages/ui/src/components/thinking-selector.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Combobox } from "@kobalte/core/combobox"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
interface ThinkingSelectorProps {
|
||||
instanceId: string
|
||||
currentModel: { providerId: string; modelId: string }
|
||||
}
|
||||
|
||||
type ThinkingOption = {
|
||||
key: string
|
||||
label: string
|
||||
value: string | undefined
|
||||
}
|
||||
|
||||
export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceProviders().length === 0) {
|
||||
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
|
||||
}
|
||||
})
|
||||
|
||||
const variantKeys = createMemo(() => {
|
||||
const { providerId, modelId } = props.currentModel
|
||||
const provider = instanceProviders().find((p) => p.id === providerId)
|
||||
const model = provider?.models.find((m) => m.id === modelId)
|
||||
return model?.variantKeys ?? []
|
||||
})
|
||||
|
||||
const options = createMemo<ThinkingOption[]>(() => {
|
||||
const keys = variantKeys()
|
||||
return [{ key: "__default__", label: "Default", value: undefined }, ...keys.map((k) => ({ key: k, label: k, value: k }))]
|
||||
})
|
||||
|
||||
const currentValue = createMemo(() => {
|
||||
const selected = getModelThinkingSelection(props.currentModel)
|
||||
const keys = variantKeys()
|
||||
if (selected && keys.includes(selected)) {
|
||||
return options().find((opt) => opt.value === selected)
|
||||
}
|
||||
return options()[0]
|
||||
})
|
||||
|
||||
const handleChange = (value: ThinkingOption | null) => {
|
||||
if (!value) return
|
||||
setModelThinkingSelection(props.currentModel, value.value)
|
||||
}
|
||||
|
||||
const triggerPrimary = createMemo(() => {
|
||||
const selected = currentValue()?.value
|
||||
return selected ? `Thinking: ${selected}` : "Thinking: Default"
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="sidebar-selector">
|
||||
<Combobox<ThinkingOption>
|
||||
value={currentValue()}
|
||||
onChange={handleChange}
|
||||
options={options()}
|
||||
optionValue="key"
|
||||
optionLabel="label"
|
||||
placeholder="Thinking: Default"
|
||||
itemComponent={(itemProps) => (
|
||||
<Combobox.Item item={itemProps.item} class="selector-option">
|
||||
<div class="selector-option-content">
|
||||
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Combobox.ItemLabel>
|
||||
</div>
|
||||
<Combobox.ItemIndicator class="selector-option-indicator">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
)}
|
||||
>
|
||||
<Combobox.Control class="relative w-full" data-thinking-selector-control>
|
||||
<Combobox.Input class="sr-only" data-thinking-selector />
|
||||
<Combobox.Trigger class="selector-trigger">
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">{triggerPrimary()}</span>
|
||||
</div>
|
||||
<span class="selector-trigger-hint" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+t" />
|
||||
</span>
|
||||
<Combobox.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
</Combobox.Control>
|
||||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="selector-popover">
|
||||
<Combobox.Listbox class="selector-listbox" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,3 +10,20 @@ export function formatTokenTotal(value: number): string {
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatCompactCount(value: number): string {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 10_000) {
|
||||
return `${Math.round(value / 1_000)}K`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const label = `${(value / 1_000).toFixed(1)}K`
|
||||
return label.replace(/\.0K$/, "K")
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
|
||||
},
|
||||
() => {
|
||||
const instance = options.getActiveInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
|
||||
},
|
||||
)
|
||||
|
||||
registerEscapeShortcut(
|
||||
|
||||
@@ -374,6 +374,20 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-variant-selector",
|
||||
label: "Select Model Variant",
|
||||
description: "Choose a thinking effort for the current model",
|
||||
category: "Agent & Model",
|
||||
keywords: ["variant", "thinking", "reasoning", "effort"],
|
||||
shortcut: { key: "T", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-agent-selector",
|
||||
label: "Open Agent Selector",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
|
||||
export type SessionSidebarRequestAction =
|
||||
| "focus-agent-selector"
|
||||
| "focus-model-selector"
|
||||
| "focus-variant-selector"
|
||||
| "show-session-list"
|
||||
|
||||
export interface SessionSidebarRequestDetail {
|
||||
instanceId: string
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
|
||||
export function registerAgentShortcuts(focusModelSelector: () => void, openAgentSelector: () => void) {
|
||||
export function registerAgentShortcuts(
|
||||
focusModelSelector: () => void,
|
||||
openAgentSelector: () => void,
|
||||
focusVariantSelector: () => void,
|
||||
) {
|
||||
const isMac = () => navigator.platform.toLowerCase().includes("mac")
|
||||
|
||||
keyboardRegistry.register({
|
||||
@@ -20,4 +24,13 @@ export function registerAgentShortcuts(focusModelSelector: () => void, openAgent
|
||||
description: "open agent",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "focus-variant",
|
||||
key: "T",
|
||||
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
|
||||
handler: focusVariantSelector,
|
||||
description: "focus thinking",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
|
||||
54
packages/ui/src/stores/github-stars.ts
Normal file
54
packages/ui/src/stores/github-stars.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const STORAGE_KEY = "codenomad:github:stars"
|
||||
const REPO_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad"
|
||||
|
||||
function readStoredStars(): number | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value) || value < 0) return null
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
function storeStars(value: number): void {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(STORAGE_KEY, String(value))
|
||||
}
|
||||
|
||||
const [githubStars, setGithubStars] = createSignal<number | null>(readStoredStars())
|
||||
|
||||
let initialized = false
|
||||
|
||||
export async function initGithubStars(): Promise<void> {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
try {
|
||||
const response = await fetch(REPO_API_URL, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { stargazers_count?: unknown }
|
||||
const next = typeof data.stargazers_count === "number" ? data.stargazers_count : null
|
||||
if (next === null || !Number.isFinite(next) || next < 0) {
|
||||
return
|
||||
}
|
||||
const normalized = Math.floor(next)
|
||||
setGithubStars(normalized)
|
||||
storeStars(normalized)
|
||||
} catch (error) {
|
||||
log.warn("Failed to fetch GitHub stars", error)
|
||||
}
|
||||
}
|
||||
|
||||
export { githubStars }
|
||||
@@ -39,6 +39,7 @@ export interface Preferences {
|
||||
lastUsedBinary?: string
|
||||
environmentVariables: Record<string, string>
|
||||
modelRecents: ModelPreference[]
|
||||
modelThinkingSelections: Record<string, string>
|
||||
diffViewMode: DiffViewMode
|
||||
toolOutputExpansion: ExpansionPreference
|
||||
diagnosticsExpansion: ExpansionPreference
|
||||
@@ -71,6 +72,7 @@ const defaultPreferences: Preferences = {
|
||||
showTimelineTools: true,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
modelThinkingSelections: {},
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
@@ -102,6 +104,11 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents
|
||||
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
|
||||
|
||||
const modelThinkingSelections = {
|
||||
...defaultPreferences.modelThinkingSelections,
|
||||
...(sanitized.modelThinkingSelections ?? {}),
|
||||
}
|
||||
|
||||
return {
|
||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||
@@ -109,6 +116,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
environmentVariables,
|
||||
modelRecents,
|
||||
modelThinkingSelections,
|
||||
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
|
||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||
@@ -118,6 +126,35 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
}
|
||||
}
|
||||
|
||||
function getModelKey(model: { providerId: string; modelId: string }): string {
|
||||
return `${model.providerId}/${model.modelId}`
|
||||
}
|
||||
|
||||
function getModelThinkingSelection(model: { providerId: string; modelId: string }): string | undefined {
|
||||
if (!model.providerId || !model.modelId) return undefined
|
||||
return preferences().modelThinkingSelections?.[getModelKey(model)]
|
||||
}
|
||||
|
||||
function setModelThinkingSelection(model: { providerId: string; modelId: string }, value: string | undefined): void {
|
||||
if (!model.providerId || !model.modelId) return
|
||||
const key = getModelKey(model)
|
||||
const current = preferences().modelThinkingSelections?.[key]
|
||||
if (current === value) return
|
||||
|
||||
updateConfig((draft) => {
|
||||
const selections = { ...(draft.preferences.modelThinkingSelections ?? {}) }
|
||||
if (!value) {
|
||||
delete selections[key]
|
||||
} else {
|
||||
selections[key] = value
|
||||
}
|
||||
draft.preferences = normalizePreferences({
|
||||
...draft.preferences,
|
||||
modelThinkingSelections: selections,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
|
||||
|
||||
const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
|
||||
@@ -527,6 +564,8 @@ export {
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
addRecentModelPreference,
|
||||
getModelThinkingSelection,
|
||||
setModelThinkingSelection,
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
setDiffViewMode,
|
||||
@@ -540,4 +579,3 @@ export {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
|
||||
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
|
||||
import { providers, sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
@@ -11,6 +11,22 @@ import { requestData } from "../lib/opencode-api"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] {
|
||||
if (!model.providerId || !model.modelId) return []
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const provider = instanceProviders.find((p) => p.id === model.providerId)
|
||||
const match = provider?.models.find((m) => m.id === model.modelId)
|
||||
return match?.variantKeys ?? []
|
||||
}
|
||||
|
||||
function getThinkingVariantToSend(instanceId: string, model: { providerId: string; modelId: string }): string | undefined {
|
||||
const selected = getModelThinkingSelection(model)
|
||||
if (!selected) return undefined
|
||||
const keys = getVariantKeysForModel(instanceId, model)
|
||||
if (keys.length === 0) return undefined
|
||||
return keys.includes(selected) ? selected : undefined
|
||||
}
|
||||
|
||||
const ID_LENGTH = 26
|
||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
@@ -170,6 +186,12 @@ async function sendMessage(
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
}),
|
||||
...(session.model.providerId &&
|
||||
session.model.modelId &&
|
||||
(() => {
|
||||
const variant = getThinkingVariantToSend(instanceId, session.model)
|
||||
return variant ? { variant } : {}
|
||||
})()),
|
||||
}
|
||||
|
||||
log.info("sendMessage", {
|
||||
@@ -215,6 +237,7 @@ async function executeCustomCommand(
|
||||
messageID: string
|
||||
agent?: string
|
||||
model?: string
|
||||
variant?: string
|
||||
} = {
|
||||
command: commandName,
|
||||
arguments: args,
|
||||
@@ -227,6 +250,8 @@ async function executeCustomCommand(
|
||||
|
||||
if (session.model.providerId && session.model.modelId) {
|
||||
body.model = `${session.model.providerId}/${session.model.modelId}`
|
||||
const variant = getThinkingVariantToSend(instanceId, session.model)
|
||||
if (variant) body.variant = variant
|
||||
}
|
||||
|
||||
await requestData(
|
||||
|
||||
@@ -483,6 +483,7 @@ async function fetchProviders(instanceId: string): Promise<void> {
|
||||
providerId: provider.id,
|
||||
limit: model.limit,
|
||||
cost: model.cost,
|
||||
variantKeys: Object.keys(model.variants ?? {}),
|
||||
})),
|
||||
}))
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
@apply items-start;
|
||||
}
|
||||
|
||||
.selector-trigger-label--top {
|
||||
@apply self-start;
|
||||
}
|
||||
|
||||
.selector-trigger-primary {
|
||||
@apply text-sm font-medium truncate;
|
||||
color: var(--text-primary);
|
||||
@@ -46,6 +50,15 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-trigger-hint {
|
||||
@apply flex-shrink-0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-trigger-hint--top {
|
||||
@apply self-start mt-0.5;
|
||||
}
|
||||
|
||||
.selector-popover {
|
||||
@apply rounded-md shadow-lg overflow-hidden min-w-[300px];
|
||||
background-color: var(--surface-base);
|
||||
|
||||
@@ -115,29 +115,6 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
@apply flex flex-col gap-1 w-full;
|
||||
}
|
||||
|
||||
.sidebar-selector-hint {
|
||||
@apply flex justify-center text-xs w-full;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-selector-hints {
|
||||
@apply flex items-center gap-2 w-full;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar-selector-hint--left,
|
||||
.sidebar-selector-hint--right {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.sidebar-selector-hint--left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.sidebar-selector-hint--right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.session-header-hints {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface Model {
|
||||
id: string
|
||||
name: string
|
||||
providerId: string
|
||||
variantKeys?: string[]
|
||||
limit?: {
|
||||
context?: number
|
||||
output?: number
|
||||
|
||||
Reference in New Issue
Block a user