From 612ec6af1b9fd4c19ca70378b7be4ae1430e8020 Mon Sep 17 00:00:00 2001 From: Sean Burkes Date: Wed, 11 Feb 2026 21:22:41 -0700 Subject: [PATCH 01/34] Fix markdown code block text visibility in light mode --- packages/ui/src/styles/markdown.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/src/styles/markdown.css b/packages/ui/src/styles/markdown.css index dbeea6dd..93d99a13 100644 --- a/packages/ui/src/styles/markdown.css +++ b/packages/ui/src/styles/markdown.css @@ -113,12 +113,18 @@ font-size: var(--font-size-sm); line-height: var(--line-height-normal); background-color: var(--surface-code); + color: var(--text-primary); border: 1px solid var(--border-base); border-radius: 8px; padding: 0.75rem; margin: 1rem 0; } + .markdown-body pre code, + .markdown-code-block pre code { + color: var(--text-primary); + } + .markdown-body blockquote { border-left: 3px solid var(--border-base); color: var(--text-secondary); From 67a530a83bc6e6f51e812ee75cdb4348f1f05681 Mon Sep 17 00:00:00 2001 From: Sean Burkes Date: Wed, 11 Feb 2026 21:54:45 -0700 Subject: [PATCH 02/34] Fix rendering for light mode table and diagnostic sections; add guards for shiki --- packages/ui/src/styles/markdown.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/styles/markdown.css b/packages/ui/src/styles/markdown.css index 93d99a13..95bc9287 100644 --- a/packages/ui/src/styles/markdown.css +++ b/packages/ui/src/styles/markdown.css @@ -1,4 +1,4 @@ -@import "github-markdown-css/github-markdown-dark.css"; +@import "github-markdown-css/github-markdown-light.css" layer(github-markdown-base); @layer components { .markdown-body { @@ -108,7 +108,7 @@ background: transparent; } - .markdown-body pre { + .markdown-body pre:not(.shiki) { font-family: var(--font-family-mono); font-size: var(--font-size-sm); line-height: var(--line-height-normal); @@ -120,8 +120,8 @@ margin: 1rem 0; } - .markdown-body pre code, - .markdown-code-block pre code { + .markdown-body pre:not(.shiki) code, + .markdown-code-block pre:not(.shiki) code { color: var(--text-primary); } @@ -174,12 +174,18 @@ border: 1px solid var(--border-base); padding: 0.5rem 0.75rem; text-align: left; + color: var(--text-primary); + background-color: transparent; } .markdown-body th { background-color: var(--surface-secondary); } + .markdown-body tbody tr:nth-child(2n) { + background-color: var(--surface-muted); + } + .markdown-code-block { position: relative; margin: 10px 0; From ab3f228d85342a1bd5583d1afa50e3ef92df07e2 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 12 Feb 2026 14:57:40 +0000 Subject: [PATCH 03/34] fix(ui): handle Windows paths in tab titles --- packages/ui/src/components/instance-tab.tsx | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/instance-tab.tsx b/packages/ui/src/components/instance-tab.tsx index 3fd8f6dd..f345f637 100644 --- a/packages/ui/src/components/instance-tab.tsx +++ b/packages/ui/src/components/instance-tab.tsx @@ -11,20 +11,11 @@ interface InstanceTabProps { onClose: () => void } -function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string { - const name = path.split("/").pop() || path - - const duplicates = instances.filter((i) => { - const iName = i.folder.split("/").pop() || i.folder - return iName === name - }) - - if (duplicates.length > 1) { - const index = duplicates.findIndex((i) => i.id === currentInstance.id) - return `~/${name} (${index + 1})` - } - - return `~/${name}` +function getPathBasename(path: string): string { + // Instance folders can be POSIX-like (/Users/...) on macOS/Linux or Windows-like (C:\Users\...). + // Normalize by trimming trailing separators and then splitting on both '/' and '\\'. + const normalized = path.replace(/[\\/]+$/, "") + return normalized.split(/[\\/]/).pop() || path } const InstanceTab: Component = (props) => { @@ -58,7 +49,7 @@ const InstanceTab: Component = (props) => { > - {props.instance.folder.split("/").pop() || props.instance.folder} + {getPathBasename(props.instance.folder)} Date: Thu, 12 Feb 2026 15:37:58 +0000 Subject: [PATCH 04/34] feat(ui): add new session icon in sidebar header --- .../components/instance/shell/SessionSidebar.tsx | 16 +++++++++++++++- packages/ui/src/lib/i18n/messages/en/session.ts | 2 ++ packages/ui/src/lib/i18n/messages/es/session.ts | 2 ++ packages/ui/src/lib/i18n/messages/fr/session.ts | 2 ++ packages/ui/src/lib/i18n/messages/ja/session.ts | 2 ++ packages/ui/src/lib/i18n/messages/ru/session.ts | 2 ++ .../ui/src/lib/i18n/messages/zh-Hans/session.ts | 2 ++ 7 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx index affce5dd..1308956f 100644 --- a/packages/ui/src/components/instance/shell/SessionSidebar.tsx +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -4,7 +4,7 @@ import type { Session } from "../../../types/session" import type { KeyboardShortcut } from "../../../lib/keyboard-registry" import type { DrawerViewState } from "./types" -import { Search } from "lucide-solid" +import { Search, SquarePlus } from "lucide-solid" import IconButton from "@suid/material/IconButton" import MenuOpenIcon from "@suid/icons-material/MenuOpen" import PushPinIcon from "@suid/icons-material/PushPin" @@ -56,6 +56,20 @@ const SessionSidebar: Component = (props) => ( {props.t("instanceShell.leftPanel.sessionsTitle")}
+ { + const result = props.onNewSession() + if (result instanceof Promise) { + void result.catch((error) => log.error("Failed to create session:", error)) + } + }} + > + + Date: Thu, 12 Feb 2026 15:47:50 +0000 Subject: [PATCH 05/34] fix(ui): use PlusSquare icon export --- packages/ui/src/components/instance/shell/SessionSidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx index 1308956f..ac5395ba 100644 --- a/packages/ui/src/components/instance/shell/SessionSidebar.tsx +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -4,7 +4,7 @@ import type { Session } from "../../../types/session" import type { KeyboardShortcut } from "../../../lib/keyboard-registry" import type { DrawerViewState } from "./types" -import { Search, SquarePlus } from "lucide-solid" +import { PlusSquare, Search } from "lucide-solid" import IconButton from "@suid/material/IconButton" import MenuOpenIcon from "@suid/icons-material/MenuOpen" import PushPinIcon from "@suid/icons-material/PushPin" @@ -68,7 +68,7 @@ const SessionSidebar: Component = (props) => ( } }} > - + Date: Thu, 12 Feb 2026 16:07:54 +0000 Subject: [PATCH 06/34] fix(ui): align sidebar header icon sizes --- packages/ui/src/components/instance/shell/SessionSidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx index ac5395ba..6f3fd70a 100644 --- a/packages/ui/src/components/instance/shell/SessionSidebar.tsx +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -68,7 +68,7 @@ const SessionSidebar: Component = (props) => ( } }} > - + = (props) => ( }, }} > - + Date: Thu, 12 Feb 2026 15:21:07 -0700 Subject: [PATCH 07/34] fix: light/dark mode consistency with alternating table row colors --- packages/ui/src/styles/markdown.css | 18 ++++++------------ packages/ui/src/styles/tokens.css | 6 ++++++ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/styles/markdown.css b/packages/ui/src/styles/markdown.css index 95bc9287..142e8e68 100644 --- a/packages/ui/src/styles/markdown.css +++ b/packages/ui/src/styles/markdown.css @@ -157,16 +157,6 @@ width: 100%; margin: 1rem 0; background-color: transparent; - display: block; - padding-right: 0.75rem; - } - - .markdown-body thead, - .markdown-body tbody, - .markdown-body tfoot { - width: 100%; - display: table; - table-layout: fixed; } .markdown-body th, @@ -182,8 +172,12 @@ background-color: var(--surface-secondary); } - .markdown-body tbody tr:nth-child(2n) { - background-color: var(--surface-muted); + .markdown-body tbody > tr:nth-child(odd) > td { + background-color: var(--markdown-table-row-odd); + } + + .markdown-body tbody > tr:nth-child(even) > td { + background-color: var(--markdown-table-row-even); } .markdown-code-block { diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 41468de7..c8a92642 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -6,6 +6,8 @@ --surface-muted: #f8fafc; --surface-code: #f1f5f9; --surface-hover: #e0e0e0; + --markdown-table-row-odd: transparent; + --markdown-table-row-even: #f1f5f9; /* Border tokens */ --border-base: #e0e0e0; @@ -180,6 +182,8 @@ --surface-muted: #212529; --surface-code: #1a1a1a; --surface-hover: #3a3a3a; + --markdown-table-row-odd: #0f1114; + --markdown-table-row-even: #181c22; /* Border tokens */ --border-base: #3a3a3a; @@ -347,6 +351,8 @@ --surface-muted: #212529; --surface-code: #1a1a1a; --surface-hover: #3a3a3a; + --markdown-table-row-odd: #0f1114; + --markdown-table-row-even: #181c22; /* Border tokens */ --border-base: #3a3a3a; From d3484ec3af51ac27ff03dbdbb88199c4b47fa5b9 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 12 Feb 2026 19:03:53 +0000 Subject: [PATCH 08/34] feat(config): migrate to YAML config and state.yaml --- package-lock.json | 19 +- .../electron/main/process-manager.ts | 48 +++- packages/electron-app/package.json | 3 +- packages/server/package.json | 1 + packages/server/src/config/location.ts | 78 +++++++ packages/server/src/config/schema.ts | 46 +++- packages/server/src/config/store.ts | 206 ++++++++++++++++-- packages/server/src/index.ts | 26 ++- packages/server/src/server/routes/config.ts | 24 +- packages/tauri-app/Cargo.lock | 20 ++ packages/tauri-app/src-tauri/Cargo.toml | 1 + .../tauri-app/src-tauri/src/cli_manager.rs | 135 ++++++++++-- 12 files changed, 535 insertions(+), 72 deletions(-) create mode 100644 packages/server/src/config/location.ts diff --git a/package-lock.json b/package-lock.json index b0157bb3..fdfe54b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11879,6 +11879,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "dev": true, @@ -11974,7 +11989,8 @@ "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", - "@neuralnomads/codenomad": "file:../server" + "@neuralnomads/codenomad": "file:../server", + "yaml": "^2.4.2" }, "devDependencies": { "7zip-bin": "^5.2.0", @@ -12017,6 +12033,7 @@ "node-forge": "^1.3.3", "pino": "^9.4.0", "undici": "^6.19.8", + "yaml": "^2.4.2", "yauzl": "^2.10.0", "zod": "^3.23.8" }, diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 45cf7f26..fdbf822b 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -5,6 +5,7 @@ import { EventEmitter } from "events" import { existsSync, readFileSync } from "fs" import os from "os" import path from "path" +import { parse as parseYaml } from "yaml" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" const nodeRequire = createRequire(import.meta.url) @@ -39,6 +40,36 @@ interface CliEntryResolution { const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" +function isYamlPath(filePath: string): boolean { + const lower = filePath.toLowerCase() + return lower.endsWith(".yaml") || lower.endsWith(".yml") +} + +function isJsonPath(filePath: string): boolean { + return filePath.toLowerCase().endsWith(".json") +} + +function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } { + const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH + const resolved = resolveConfigPath(target) + + if (isYamlPath(resolved)) { + const baseDir = path.dirname(resolved) + return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") } + } + + if (isJsonPath(resolved)) { + const baseDir = path.dirname(resolved) + return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved } + } + + // Treat as directory. + return { + configYamlPath: path.join(resolved, "config.yaml"), + legacyJsonPath: path.join(resolved, "config.json"), + } +} + function resolveConfigPath(configPath?: string): string { const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH if (target.startsWith("~/")) { @@ -53,10 +84,19 @@ function resolveHostForMode(mode: ListeningMode): string { function readListeningModeFromConfig(): ListeningMode { try { - const configPath = resolveConfigPath(process.env.CLI_CONFIG) - if (!existsSync(configPath)) return "local" - const content = readFileSync(configPath, "utf-8") - const parsed = JSON.parse(content) + const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG) + + let parsed: any = null + if (existsSync(configYamlPath)) { + const content = readFileSync(configYamlPath, "utf-8") + parsed = parseYaml(content) + } else if (existsSync(legacyJsonPath)) { + const content = readFileSync(legacyJsonPath, "utf-8") + parsed = JSON.parse(content) + } else { + return "local" + } + const mode = parsed?.preferences?.listeningMode if (mode === "local" || mode === "all") { return mode diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 37945ffe..85be725b 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -36,7 +36,8 @@ }, "dependencies": { "@neuralnomads/codenomad": "file:../server", - "@codenomad/ui": "file:../ui" + "@codenomad/ui": "file:../ui", + "yaml": "^2.4.2" }, "devDependencies": { "7zip-bin": "^5.2.0", diff --git a/packages/server/package.json b/packages/server/package.json index 5aad36da..4b2f7982 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -34,6 +34,7 @@ "node-forge": "^1.3.3", "pino": "^9.4.0", "undici": "^6.19.8", + "yaml": "^2.4.2", "yauzl": "^2.10.0", "zod": "^3.23.8" }, diff --git a/packages/server/src/config/location.ts b/packages/server/src/config/location.ts new file mode 100644 index 00000000..b8d150f2 --- /dev/null +++ b/packages/server/src/config/location.ts @@ -0,0 +1,78 @@ +import os from "os" +import path from "path" + +export interface ConfigLocation { + /** Resolved absolute base directory containing all persisted server data. */ + baseDir: string + /** Canonical YAML config file path (may be custom when input points to a YAML file). */ + configYamlPath: string + /** Canonical YAML state file path (always in baseDir). */ + stateYamlPath: string + /** Legacy JSON config file path used for migration (always in baseDir, or explicit JSON input). */ + legacyJsonPath: string + /** Directory for per-instance persisted data (chat history etc.). */ + instancesDir: string +} + +function resolvePath(inputPath: string): string { + if (inputPath.startsWith("~/")) { + return path.join(os.homedir(), inputPath.slice(2)) + } + return path.resolve(inputPath) +} + +function isYamlPath(filePath: string): boolean { + const lower = filePath.toLowerCase() + return lower.endsWith(".yaml") || lower.endsWith(".yml") +} + +function isJsonPath(filePath: string): boolean { + return filePath.toLowerCase().endsWith(".json") +} + +/** + * Resolve CodeNomad's config location into a stable base directory + derived file paths. + * + * Supported inputs: + * - Directory: "~/.config/codenomad" + * - YAML file: "~/.config/codenomad/config.yaml" (or any *.yml/*.yaml) + * - Legacy JSON file: "~/.config/codenomad/config.json" + */ +export function resolveConfigLocation(raw: string): ConfigLocation { + const trimmed = (raw ?? "").trim() + const fallback = "~/.config/codenomad/config.json" + const input = trimmed.length > 0 ? trimmed : fallback + + const resolvedInput = resolvePath(input) + + if (isYamlPath(resolvedInput)) { + const baseDir = path.dirname(resolvedInput) + return { + baseDir, + configYamlPath: resolvedInput, + stateYamlPath: path.join(baseDir, "state.yaml"), + legacyJsonPath: path.join(baseDir, "config.json"), + instancesDir: path.join(baseDir, "instances"), + } + } + + if (isJsonPath(resolvedInput)) { + const baseDir = path.dirname(resolvedInput) + return { + baseDir, + configYamlPath: path.join(baseDir, "config.yaml"), + stateYamlPath: path.join(baseDir, "state.yaml"), + legacyJsonPath: resolvedInput, + instancesDir: path.join(baseDir, "instances"), + } + } + + const baseDir = resolvedInput + return { + baseDir, + configYamlPath: path.join(baseDir, "config.yaml"), + stateYamlPath: path.join(baseDir, "state.yaml"), + legacyJsonPath: path.join(baseDir, "config.json"), + instancesDir: path.join(baseDir, "instances"), + } +} diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index 829d491c..c4781113 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -8,7 +8,8 @@ const ModelPreferenceSchema = z.object({ const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema) const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema) -const PreferencesSchema = z.object({ +const PreferencesSchema = z + .object({ showThinkingBlocks: z.boolean().default(false), thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), showTimelineTools: z.boolean().default(true), @@ -31,7 +32,9 @@ const PreferencesSchema = z.object({ osNotificationsAllowWhenVisible: z.boolean().default(false), notifyOnNeedsInput: z.boolean().default(true), notifyOnIdle: z.boolean().default(true), -}) + }) + // Preserve unknown preference keys so newer configs survive older binaries. + .passthrough() const RecentFolderSchema = z.object({ path: z.string(), @@ -45,14 +48,35 @@ const OpenCodeBinarySchema = z.object({ label: z.string().optional(), }) -const ConfigFileSchema = z.object({ - preferences: PreferencesSchema.default({}), - recentFolders: z.array(RecentFolderSchema).default([]), - opencodeBinaries: z.array(OpenCodeBinarySchema).default([]), - theme: z.enum(["light", "dark", "system"]).optional(), -}) +const ConfigFileSchema = z + .object({ + preferences: PreferencesSchema.default({}), + recentFolders: z.array(RecentFolderSchema).default([]), + opencodeBinaries: z.array(OpenCodeBinarySchema).default([]), + theme: z.enum(["light", "dark", "system"]).optional(), + }) + // Preserve unknown top-level keys so optional future features survive downgrades. + .passthrough() + +// On-disk config.yaml only stores stable configuration (not volatile state like recent folders). +const ConfigYamlSchema = z + .object({ + preferences: PreferencesSchema.default({}), + opencodeBinaries: z.array(OpenCodeBinarySchema).default([]), + theme: z.enum(["light", "dark", "system"]).optional(), + }) + .passthrough() + +// On-disk state.yaml stores server-scoped mutable state (per-server, not per-client). +const StateFileSchema = z + .object({ + recentFolders: z.array(RecentFolderSchema).default([]), + }) + .passthrough() const DEFAULT_CONFIG = ConfigFileSchema.parse({}) +const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({}) +const DEFAULT_STATE = StateFileSchema.parse({}) export { ModelPreferenceSchema, @@ -62,7 +86,11 @@ export { RecentFolderSchema, OpenCodeBinarySchema, ConfigFileSchema, + ConfigYamlSchema, + StateFileSchema, DEFAULT_CONFIG, + DEFAULT_CONFIG_YAML, + DEFAULT_STATE, } export type ModelPreference = z.infer @@ -72,3 +100,5 @@ export type Preferences = z.infer export type RecentFolder = z.infer export type OpenCodeBinary = z.infer export type ConfigFile = z.infer +export type ConfigYamlFile = z.infer +export type StateFile = z.infer diff --git a/packages/server/src/config/store.ts b/packages/server/src/config/store.ts index dda49e40..5d736f9c 100644 --- a/packages/server/src/config/store.ts +++ b/packages/server/src/config/store.ts @@ -1,15 +1,27 @@ import fs from "fs" import path from "path" +import { parse as parseYaml, stringify as stringifyYaml } from "yaml" import { EventBus } from "../events/bus" import { Logger } from "../logger" -import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema" +import { + ConfigFile, + ConfigFileSchema, + ConfigYamlSchema, + DEFAULT_CONFIG, + DEFAULT_CONFIG_YAML, + DEFAULT_STATE, + StateFile, + StateFileSchema, +} from "./schema" +import type { ConfigLocation } from "./location" export class ConfigStore { private cache: ConfigFile = DEFAULT_CONFIG + private state: StateFile = DEFAULT_STATE private loaded = false constructor( - private readonly configPath: string, + private readonly location: ConfigLocation, private readonly eventBus: EventBus | undefined, private readonly logger: Logger, ) {} @@ -20,19 +32,37 @@ export class ConfigStore { } try { - const resolved = this.resolvePath(this.configPath) - if (fs.existsSync(resolved)) { - const content = fs.readFileSync(resolved, "utf-8") - const parsed = JSON.parse(content) - this.cache = ConfigFileSchema.parse(parsed) - this.logger.debug({ resolved }, "Loaded existing config file") + const configYamlPath = this.location.configYamlPath + const stateYamlPath = this.location.stateYamlPath + const legacyJsonPath = this.location.legacyJsonPath + + if (fs.existsSync(configYamlPath)) { + const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config") + const stateDoc = fs.existsSync(stateYamlPath) + ? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state") + : DEFAULT_STATE + + this.state = stateDoc + this.cache = this.mergeDocs(configDoc, stateDoc) + this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state") + } else if (fs.existsSync(legacyJsonPath)) { + const migrated = this.migrateFromLegacyJson(legacyJsonPath) + this.state = migrated.state + this.cache = migrated.config } else { - this.cache = DEFAULT_CONFIG - this.logger.debug({ resolved }, "No config file found, using defaults") + // Fresh install: write defaults. + this.state = DEFAULT_STATE + this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE) + this.persist() + this.logger.debug( + { configYamlPath, stateYamlPath }, + "No config files found, created default YAML config/state", + ) } } catch (error) { - this.logger.warn({ err: error }, "Failed to load config, using defaults") - this.cache = DEFAULT_CONFIG + this.logger.warn({ err: error }, "Failed to load config/state, using defaults") + this.state = DEFAULT_STATE + this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE) } this.loaded = true @@ -48,9 +78,30 @@ export class ConfigStore { this.commit(validated) } + /** + * Apply a merge-patch update to the current config. + * - Missing keys are preserved. + * - Object values are merged recursively. + * - Explicit `null` deletes keys. + * - Arrays are replaced. + */ + mergePatch(patch: unknown) { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) { + throw new Error("Config patch must be a JSON object") + } + const current = this.get() + const next = applyMergePatch(current as any, patch as any) + const validated = ConfigFileSchema.parse(next) + this.commit(validated) + } + private commit(next: ConfigFile) { this.cache = next this.loaded = true + this.state = { + ...this.state, + recentFolders: next.recentFolders, + } this.persist() const published = Boolean(this.eventBus) this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) @@ -60,19 +111,134 @@ export class ConfigStore { private persist() { try { - const resolved = this.resolvePath(this.configPath) - fs.mkdirSync(path.dirname(resolved), { recursive: true }) - fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8") - this.logger.debug({ resolved }, "Persisted config file") + const configYamlPath = this.location.configYamlPath + const stateYamlPath = this.location.stateYamlPath + + fs.mkdirSync(this.location.baseDir, { recursive: true }) + fs.mkdirSync(path.dirname(configYamlPath), { recursive: true }) + + const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any) + const stateYaml = stringifyYaml(this.state as any) + + fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8") + fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8") + + this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state") } catch (error) { this.logger.warn({ err: error }, "Failed to persist config") } } - private resolvePath(filePath: string) { - if (filePath.startsWith("~/")) { - return path.join(process.env.HOME ?? "", filePath.slice(2)) + private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile { + const merged = { + ...(configDoc as any), + // State wins for recent folders. + recentFolders: stateDoc.recentFolders ?? [], } - return path.resolve(filePath) + + return ConfigFileSchema.parse(merged) + } + + private readYamlFile( + filePath: string, + fallback: T, + schema: { parse: (value: unknown) => T }, + label: string, + ): T { + try { + const content = fs.readFileSync(filePath, "utf-8") + const parsed = parseYaml(content) + return schema.parse(parsed ?? {}) + } catch (error) { + this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults") + return fallback + } + } + + private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } { + const configYamlPath = this.location.configYamlPath + const stateYamlPath = this.location.stateYamlPath + + const content = fs.readFileSync(legacyJsonPath, "utf-8") + const parsed = JSON.parse(content) + const legacy = ConfigFileSchema.parse(parsed) + + const state: StateFile = StateFileSchema.parse({ + ...DEFAULT_STATE, + recentFolders: legacy.recentFolders ?? [], + }) + + const merged = this.mergeDocs(stripRecentFolders(legacy), state) + + // Persist YAML docs first, then move legacy aside. + try { + fs.mkdirSync(this.location.baseDir, { recursive: true }) + fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8") + fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8") + this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML") + } catch (error) { + this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state") + } + + try { + const bakPath = pickBackupPath(legacyJsonPath) + fs.renameSync(legacyJsonPath, bakPath) + this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup") + } catch (error) { + this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup") + } + + return { config: merged, state } } } + +function ensureTrailingNewline(content: string): string { + if (!content) return "\n" + return content.endsWith("\n") ? content : `${content}\n` +} + +function stripRecentFolders(config: ConfigFile): Omit & Record { + const clone: Record = { ...(config as any) } + delete clone.recentFolders + return clone as any +} + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== "object") return false + if (Array.isArray(value)) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function applyMergePatch(current: any, patch: any): any { + // RFC 7396-ish merge patch with explicit null deletes. + if (!isPlainObject(patch)) { + return patch + } + + const base = isPlainObject(current) ? { ...current } : {} + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete base[key] + continue + } + + if (isPlainObject(value) && isPlainObject(base[key])) { + base[key] = applyMergePatch(base[key], value) + continue + } + + // Arrays and scalars replace. + base[key] = value + } + return base +} + +function pickBackupPath(legacyJsonPath: string): string { + const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath + const preferred = `${base}.json.bak` + if (!fs.existsSync(preferred)) { + return preferred + } + return `${base}.json.bak.${Date.now()}` +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 38b07d80..54753885 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,6 +9,7 @@ import { createRequire } from "module" import { createHttpServer } from "./server/http-server" import { WorkspaceManager } from "./workspaces/manager" import { ConfigStore } from "./config/store" +import { resolveConfigLocation } from "./config/location" import { BinaryRegistry } from "./config/binaries" import { FileSystemBrowser } from "./filesystem/browser" import { EventBus } from "./events/bus" @@ -210,13 +211,6 @@ function resolveHost(input: string | undefined): string { return trimmed } -function resolvePath(filePath: string) { - if (filePath.startsWith("~/")) { - return path.join(process.env.HOME ?? "", filePath.slice(2)) - } - return path.resolve(filePath) -} - function programHasArg(argv: string[], flag: string): boolean { return argv.includes(flag) } @@ -245,7 +239,8 @@ async function main() { const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") - const configDir = path.dirname(resolvePath(options.configPath)) + const configLocation = resolveConfigLocation(options.configPath) + const configDir = configLocation.baseDir if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) { throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together") @@ -266,7 +261,7 @@ async function main() { const authManager = new AuthManager( { - configPath: options.configPath, + configPath: configLocation.configYamlPath, username: options.authUsername, password: options.authPassword, generateToken: options.generateToken, @@ -295,7 +290,16 @@ async function main() { const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined - const configStore = new ConfigStore(options.configPath, eventBus, configLogger) + const configStore = new ConfigStore(configLocation, eventBus, configLogger) + + // Eagerly load config at boot so migrations run immediately + // (instead of waiting for the first /api/config request). + try { + configStore.get() + } catch (error) { + configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults") + } + const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) const workspaceManager = new WorkspaceManager({ rootDir: options.rootDir, @@ -307,7 +311,7 @@ async function main() { nodeExtraCaCertsPath, }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) - const instanceStore = new InstanceStore() + const instanceStore = new InstanceStore(configLocation.instancesDir) const instanceEventBridge = new InstanceEventBridge({ workspaceManager, eventBus, diff --git a/packages/server/src/server/routes/config.ts b/packages/server/src/server/routes/config.ts index fed364af..ea4e03c5 100644 --- a/packages/server/src/server/routes/config.ts +++ b/packages/server/src/server/routes/config.ts @@ -2,7 +2,6 @@ import { FastifyInstance } from "fastify" import { z } from "zod" import { ConfigStore } from "../../config/store" import { BinaryRegistry } from "../../config/binaries" -import { ConfigFileSchema } from "../../config/schema" interface RouteDeps { configStore: ConfigStore @@ -27,10 +26,25 @@ const BinaryValidateSchema = z.object({ export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/config/app", async () => deps.configStore.get()) - app.put("/api/config/app", async (request) => { - const body = ConfigFileSchema.parse(request.body ?? {}) - deps.configStore.replace(body) - return deps.configStore.get() + app.put("/api/config/app", async (request, reply) => { + // Backwards compatible: treat PUT as a merge-patch update. + try { + deps.configStore.mergePatch(request.body ?? {}) + return deps.configStore.get() + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid config patch" } + } + }) + + app.patch("/api/config/app", async (request, reply) => { + try { + deps.configStore.mergePatch(request.body ?? {}) + return deps.configStore.get() + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid config patch" } + } }) app.get("/api/config/binaries", async () => { diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index ede1e424..e388ea42 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -636,6 +636,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -3894,6 +3895,19 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.1", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5015,6 +5029,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.7" diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 99496fe7..f119c846 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -11,6 +11,7 @@ tauri-build = { version = "2.5.2", features = [] } tauri = { version = "2.5.2", features = [ "devtools"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" regex = "1" once_cell = "1" parking_lot = "0.12" diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 9e3242ea..ad0860f9 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -145,12 +145,33 @@ struct AppConfig { preferences: Option, } -fn resolve_config_path() -> PathBuf { +fn resolve_config_locations() -> (PathBuf, PathBuf) { let raw = env::var("CLI_CONFIG") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); - expand_home(&raw) + + let expanded = expand_home(&raw); + let lower = raw.trim().to_lowercase(); + + if lower.ends_with(".yaml") || lower.ends_with(".yml") { + let base = expanded + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| expanded.clone()); + return (expanded, base.join("config.json")); + } + + if lower.ends_with(".json") { + let base = expanded + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| expanded.clone()); + return (base.join("config.yaml"), expanded); + } + + // Treat as directory. + (expanded.join("config.yaml"), expanded.join("config.json")) } fn expand_home(path: &str) -> PathBuf { @@ -163,8 +184,27 @@ fn expand_home(path: &str) -> PathBuf { } fn resolve_listening_mode() -> String { - let path = resolve_config_path(); - if let Ok(content) = fs::read_to_string(path) { + let (yaml_path, json_path) = resolve_config_locations(); + + if let Ok(content) = fs::read_to_string(&yaml_path) { + if let Ok(config) = serde_yaml::from_str::(&content) { + if let Some(mode) = config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + { + if mode == "local" { + return "local".to_string(); + } + if mode == "all" { + return "all".to_string(); + } + } + } + } + + // Legacy fallback. + if let Ok(content) = fs::read_to_string(&json_path) { if let Ok(config) = serde_json::from_str::(&content) { if let Some(mode) = config .preferences @@ -260,7 +300,14 @@ impl CliProcessManager { let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); thread::spawn(move || { - if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) { + if let Err(err) = Self::spawn_cli( + app.clone(), + status_arc.clone(), + child_arc, + ready_flag, + token_arc, + dev, + ) { log_line(&format!("cli spawn failed: {err}")); let mut locked = status_arc.lock(); locked.state = CliState::Error; @@ -369,7 +416,9 @@ impl CliProcessManager { if !supports_user_shell() { if which::which(&resolution.node_binary).is_err() { - return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed.")); + return Err(anyhow::anyhow!( + "Node binary not found. Make sure Node.js is installed." + )); } } @@ -420,7 +469,6 @@ impl CliProcessManager { let token_clone = bootstrap_token.clone(); thread::spawn(move || { - let stdout = child_clone .lock() .as_mut() @@ -433,10 +481,24 @@ impl CliProcessManager { .map(BufReader::new); if let Some(reader) = stdout { - Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone); + Self::process_stream( + reader, + "stdout", + &app_clone, + &status_clone, + &ready_clone, + &token_clone, + ); } if let Some(reader) = stderr { - Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone); + Self::process_stream( + reader, + "stderr", + &app_clone, + &status_clone, + &ready_clone, + &token_clone, + ); } }); @@ -509,8 +571,14 @@ impl CliProcessManager { if locked.error.is_none() { locked.error = err_msg.clone(); } - log_line(&format!("cli process exited before ready: {:?}", locked.error)); - let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()})); + log_line(&format!( + "cli process exited before ready: {:?}", + locked.error + )); + let _ = app_clone.emit( + "cli:error", + json!({"message": locked.error.clone().unwrap_or_default()}), + ); } else { locked.state = CliState::Stopped; log_line("cli process stopped cleanly"); @@ -574,13 +642,25 @@ impl CliProcessManager { .and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|m| m.as_str().parse::().ok()) { - Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}")); + Self::mark_ready( + app, + status, + ready, + bootstrap_token, + format!("http://localhost:{port}"), + ); continue; } if let Ok(value) = serde_json::from_str::(line) { if let Some(port) = value.get("port").and_then(|p| p.as_u64()) { - Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port)); + Self::mark_ready( + app, + status, + ready, + bootstrap_token, + format!("http://localhost:{}", port), + ); continue; } } @@ -719,7 +799,12 @@ impl CliEntry { } fn build_args(&self, dev: bool, host: &str) -> Vec { - let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()]; + let mut args = vec![ + "serve".to_string(), + "--host".to_string(), + host.to_string(), + "--generate-token".to_string(), + ]; if dev { // Dev: plain HTTP + Vite dev server proxy. @@ -761,9 +846,10 @@ fn resolve_tsx(_app: &AppHandle) -> Option { std::env::current_dir() .ok() .map(|p| p.join("node_modules/tsx/dist/cli.js")), - std::env::current_exe() - .ok() - .and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))), + std::env::current_exe().ok().and_then(|ex| { + ex.parent() + .map(|p| p.join("../node_modules/tsx/dist/cli.js")) + }), ]; first_existing(candidates) @@ -786,7 +872,8 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { let base = workspace_root(); let mut candidates: Vec> = vec![ base.as_ref().map(|p| p.join("packages/server/dist/bin.js")), - base.as_ref().map(|p| p.join("packages/server/dist/index.js")), + base.as_ref() + .map(|p| p.join("packages/server/dist/index.js")), base.as_ref().map(|p| p.join("server/dist/bin.js")), base.as_ref().map(|p| p.join("server/dist/index.js")), ]; @@ -801,7 +888,9 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { candidates.push(Some(resources.join("resources/server/dist/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/index.js"))); candidates.push(Some(resources.join("resources/server/dist/server/bin.js"))); - candidates.push(Some(resources.join("resources/server/dist/server/index.js"))); + candidates.push(Some( + resources.join("resources/server/dist/server/index.js"), + )); let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; for root in linux_resource_roots { @@ -820,8 +909,10 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { first_existing(candidates) } -fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result { - +fn build_shell_command_string( + entry: &CliEntry, + cli_args: &[String], +) -> anyhow::Result { let shell = default_shell(); let mut quoted: Vec = Vec::new(); quoted.push(shell_escape(&entry.node_binary)); @@ -852,7 +943,7 @@ fn shell_escape(input: &str) -> String { "''".to_string() } else if !input .chars() - .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' )) + .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!')) { input.to_string() } else { From 45fab91e7fa67d92e866a401af8fbbc84dbae14b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 12 Feb 2026 23:53:05 +0000 Subject: [PATCH 09/34] feat(release): add dev prereleases and update notices Publish bleeding-edge builds from dev to GitHub prereleases and npm dist-tag 'dev'. Dev builds poll GitHub prereleases and surface update availability via /api/meta for UI notifications. --- .github/workflows/dev-release.yml | 34 ++++- .github/workflows/manual-npm-publish.yml | 10 ++ .github/workflows/reusable-release.yml | 18 ++- README.md | 5 + packages/server/.gitignore | 3 + packages/server/src/api-types.ts | 2 + packages/server/src/index.ts | 18 +++ .../src/releases/dev-release-monitor.ts | 118 ++++++++++++++++++ .../server/src/releases/release-monitor.ts | 8 +- packages/ui/src/lib/i18n/messages/en/app.ts | 4 + packages/ui/src/lib/i18n/messages/es/app.ts | 7 ++ packages/ui/src/lib/i18n/messages/fr/app.ts | 7 ++ packages/ui/src/lib/i18n/messages/ja/app.ts | 7 ++ packages/ui/src/lib/i18n/messages/ru/app.ts | 7 ++ .../ui/src/lib/i18n/messages/zh-Hans/app.ts | 7 ++ packages/ui/src/stores/releases.ts | 45 +++++++ 16 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/releases/dev-release-monitor.ts diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 235e9bc0..520a806b 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -1,4 +1,4 @@ -name: Dev CI +name: Develop Pre-Release on: push: @@ -7,12 +7,34 @@ on: workflow_dispatch: permissions: - contents: read + id-token: write + contents: write + +concurrency: + group: dev-prerelease + cancel-in-progress: true jobs: - dev-ci: - uses: ./.github/workflows/build-and-upload.yml + prepare: + runs-on: ubuntu-latest + outputs: + version_suffix: ${{ steps.vars.outputs.version_suffix }} + steps: + - name: Compute version suffix + id: vars + shell: bash + run: | + set -euo pipefail + SHA8="${GITHUB_SHA::8}" + TS=$(date -u +%Y%m%d%H%M%S) + echo "version_suffix=-dev.${TS}.${SHA8}" >> "$GITHUB_OUTPUT" + + prerelease: + needs: prepare + uses: ./.github/workflows/reusable-release.yml with: - upload: false - set_versions: false + version_suffix: ${{ needs.prepare.outputs.version_suffix }} + dist_tag: dev + prerelease: true + release_ui: false secrets: inherit diff --git a/.github/workflows/manual-npm-publish.yml b/.github/workflows/manual-npm-publish.yml index 86b8768a..403ffffc 100644 --- a/.github/workflows/manual-npm-publish.yml +++ b/.github/workflows/manual-npm-publish.yml @@ -67,6 +67,16 @@ jobs: run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version - name: Publish server package with provenance + if: ${{ secrets.NPM_TOKEN != '' }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + NPM_CONFIG_REGISTRY: https://registry.npmjs.org + run: | + npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance + + - name: Publish server package with provenance (OIDC) + if: ${{ secrets.NPM_TOKEN == '' }} env: NPM_CONFIG_PROVENANCE: true NPM_CONFIG_REGISTRY: https://registry.npmjs.org diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 2f6da125..460aa7d5 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -13,6 +13,16 @@ on: required: false default: dev type: string + prerelease: + description: "Create GitHub prerelease" + required: false + default: false + type: boolean + release_ui: + description: "Publish remote UI + manifest" + required: false + default: true + type: boolean permissions: id-token: write @@ -53,11 +63,16 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.versions.outputs.tag }} + IS_PRERELEASE: ${{ inputs.prerelease }} run: | if gh release view "$TAG" >/dev/null 2>&1; then echo "Release $TAG already exists" else - gh release create "$TAG" --title "$TAG" --generate-notes + if [ "${IS_PRERELEASE}" = "true" ]; then + gh release create "$TAG" --title "$TAG" --generate-notes --prerelease + else + gh release create "$TAG" --title "$TAG" --generate-notes + fi fi build-and-upload: @@ -71,6 +86,7 @@ jobs: release-ui: needs: prepare-release + if: ${{ inputs.release_ui }} permissions: contents: read uses: ./.github/workflows/release-ui.yml diff --git a/README.md b/README.md index 71798d16..f1f7c4bc 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ For dev version npx @neuralnomads/codenomad@dev --launch ``` +Dev builds are published as GitHub pre-releases: +https://github.com/shantur/CodeNomad/releases + +Dev releases are bleeding-edge builds, generated automatically every time a new commit is pushed to the `dev` branch. + This command starts the server and opens the web client in your default browser. ## Highlights diff --git a/packages/server/.gitignore b/packages/server/.gitignore index 364fdec1..531f28fe 100644 --- a/packages/server/.gitignore +++ b/packages/server/.gitignore @@ -1 +1,4 @@ public/ + +# Local developer config (may contain secrets) +config-*.json diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 41e8229b..a48cf096 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -286,6 +286,8 @@ export interface ServerMeta { serverVersion?: string ui?: UiMeta support?: SupportMeta + /** Optional update info (dev channel only). */ + update?: LatestReleaseInfo | null } export type BackgroundProcessStatus = "running" | "stopped" | "error" diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 54753885..3f4461e3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -22,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { resolveHttpsOptions } from "./server/tls" import { resolveNetworkAddresses } from "./server/network-addresses" +import { startDevReleaseMonitor } from "./releases/dev-release-monitor" const require = createRequire(import.meta.url) @@ -348,6 +349,21 @@ async function main() { minServerVersion: uiResolution.minServerVersion, } + const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase() + const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim() + const isDevVersion = packageJson.version.includes("-dev.") + const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion) + const devReleaseMonitor = enableDevUpdateChecks + ? startDevReleaseMonitor({ + currentVersion: packageJson.version, + repo: githubRepo, + logger: logger.child({ component: "updates" }), + onUpdate: (release) => { + serverMeta.update = release + }, + }) + : null + if (uiResolution.uiDevServerUrl && options.https) { throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true") } @@ -507,6 +523,8 @@ async function main() { // no-op: remote UI manifest replaces GitHub release monitor + devReleaseMonitor?.stop() + logger.info("Exiting process") process.exit(0) } diff --git a/packages/server/src/releases/dev-release-monitor.ts b/packages/server/src/releases/dev-release-monitor.ts new file mode 100644 index 00000000..5fe405d8 --- /dev/null +++ b/packages/server/src/releases/dev-release-monitor.ts @@ -0,0 +1,118 @@ +import { fetch } from "undici" +import type { LatestReleaseInfo } from "../api-types" +import type { Logger } from "../logger" +import { compareVersionStrings, stripTagPrefix } from "./release-monitor" + +interface DevReleaseMonitorOptions { + /** Current running server version (from package.json). */ + currentVersion: string + /** GitHub repo in the form "owner/name". */ + repo: string + logger: Logger + onUpdate: (release: LatestReleaseInfo | null) => void + pollIntervalMs?: number +} + +interface GithubReleaseListItem { + tag_name?: string + name?: string + html_url?: string + body?: string + published_at?: string + created_at?: string + prerelease?: boolean + draft?: boolean +} + +export interface DevReleaseMonitor { + stop(): void +} + +const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000 + +export function startDevReleaseMonitor(options: DevReleaseMonitorOptions): DevReleaseMonitor { + let stopped = false + let timer: ReturnType | null = null + + const pollIntervalMs = + Number.isFinite(options.pollIntervalMs) && (options.pollIntervalMs ?? 0) > 0 + ? (options.pollIntervalMs as number) + : DEFAULT_POLL_INTERVAL_MS + + const refresh = async () => { + if (stopped) return + try { + const release = await fetchLatestPrerelease({ + repo: options.repo, + currentVersion: options.currentVersion, + }) + options.onUpdate(release) + } catch (error) { + options.logger.debug({ err: error }, "Failed to refresh dev prerelease information") + } + } + + void refresh() + timer = setInterval(() => void refresh(), pollIntervalMs) + + return { + stop() { + stopped = true + if (timer) { + clearInterval(timer) + timer = null + } + }, + } +} + +async function fetchLatestPrerelease(args: { + repo: string + currentVersion: string +}): Promise { + const normalizedRepo = args.repo.trim() + if (!/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) { + throw new Error(`Invalid GitHub repo: ${args.repo}`) + } + + const apiUrl = `https://api.github.com/repos/${normalizedRepo}/releases?per_page=20` + const response = await fetch(apiUrl, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "CodeNomad-CLI", + }, + }) + + if (!response.ok) { + throw new Error(`GitHub releases API responded with ${response.status}`) + } + + const list = (await response.json()) as GithubReleaseListItem[] + const latest = list.find((r) => r && r.prerelease === true && r.draft !== true) + if (!latest) { + return null + } + + const tag = latest.tag_name || latest.name + if (!tag) { + return null + } + + const normalizedVersion = stripTagPrefix(tag) + if (!normalizedVersion) { + return null + } + + if (compareVersionStrings(normalizedVersion, args.currentVersion) <= 0) { + return null + } + + return { + version: normalizedVersion, + tag, + url: latest.html_url ?? `https://github.com/${normalizedRepo}/releases/tag/${encodeURIComponent(tag)}`, + channel: "dev", + publishedAt: latest.published_at ?? latest.created_at, + notes: latest.body, + } +} diff --git a/packages/server/src/releases/release-monitor.ts b/packages/server/src/releases/release-monitor.ts index 2fd80c99..d84e6959 100644 --- a/packages/server/src/releases/release-monitor.ts +++ b/packages/server/src/releases/release-monitor.ts @@ -52,6 +52,12 @@ export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMoni } } +export function compareVersionStrings(a: string, b: string): number { + const left = parseVersion(a) + const right = parseVersion(b) + return compareVersions(left, right) +} + async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise { const response = await fetch(RELEASES_API_URL, { headers: { @@ -92,7 +98,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise(null) const UI_VERSION_STORAGE_KEY = "codenomad:lastSeenUiVersion" +const DEV_RELEASE_STORAGE_KEY = "codenomad:lastSeenDevRelease" +const META_REFRESH_INTERVAL_MS = 10 * 60 * 1000 let initialized = false let visibilityEffectInitialized = false let activeToast: ToastHandle | null = null let activeToastKey: string | null = null let uiUpdateToasted = false +let metaRefreshInterval: ReturnType | null = null function dismissActiveToast() { if (activeToast) { @@ -80,6 +83,8 @@ async function refreshFromMeta() { const meta = await getServerMeta(true) setSupportInfo(meta.support ?? null) maybeNotifyUiUpdated(meta) + maybeNotifyDevReleaseAvailable(meta) + ensureMetaRefresh(meta) } catch (error) { log.warn("Unable to load server metadata for support info", error) } @@ -115,6 +120,46 @@ function maybeNotifyUiUpdated(meta: ServerMeta) { }) } +function maybeNotifyDevReleaseAvailable(meta: ServerMeta) { + const update = meta.update + if (!update || !update.version || !update.url) return + + const lastSeen = safeReadLocalStorage(DEV_RELEASE_STORAGE_KEY) + if (lastSeen === update.version) { + return + } + + safeWriteLocalStorage(DEV_RELEASE_STORAGE_KEY, update.version) + + showToastNotification({ + title: tGlobal("releases.devUpdateAvailable.title"), + message: tGlobal("releases.devUpdateAvailable.message", { version: update.version }), + variant: "info", + duration: 12000, + position: "bottom-right", + action: { + label: tGlobal("releases.devUpdateAvailable.action"), + href: update.url, + }, + }) +} + +function ensureMetaRefresh(meta: ServerMeta) { + if (metaRefreshInterval) return + + const version = meta.serverVersion?.trim() ?? "" + const looksLikeDev = version.includes("-dev.") + const hasDevUpdateChannel = Boolean(meta.update) + + if (!looksLikeDev && !hasDevUpdateChannel) { + return + } + + metaRefreshInterval = setInterval(() => { + void refreshFromMeta() + }, META_REFRESH_INTERVAL_MS) +} + function safeReadLocalStorage(key: string): string | null { try { if (typeof window === "undefined" || !window.localStorage) return null From e6c568988a25edd5aaf60fd8fc53c169675ca1a3 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 12 Feb 2026 23:55:58 +0000 Subject: [PATCH 10/34] fix(ci): declare NPM_TOKEN for reusable publish Expose NPM_TOKEN as an optional workflow_call secret so step conditionals can reference secrets.NPM_TOKEN. --- .github/workflows/manual-npm-publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/manual-npm-publish.yml b/.github/workflows/manual-npm-publish.yml index 403ffffc..5fc2ddbe 100644 --- a/.github/workflows/manual-npm-publish.yml +++ b/.github/workflows/manual-npm-publish.yml @@ -21,6 +21,9 @@ on: required: false type: string default: dev + secrets: + NPM_TOKEN: + required: false permissions: contents: read From 3047a1e602095761702616ffc88ef3b95e1fc034 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 12 Feb 2026 23:58:18 +0000 Subject: [PATCH 11/34] fix(ci): avoid secrets context in step if Remove secrets-based step conditionals in reusable npm publish workflow; decide token vs OIDC at runtime. --- .github/workflows/manual-npm-publish.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/manual-npm-publish.yml b/.github/workflows/manual-npm-publish.yml index 5fc2ddbe..b67f71fe 100644 --- a/.github/workflows/manual-npm-publish.yml +++ b/.github/workflows/manual-npm-publish.yml @@ -70,18 +70,19 @@ jobs: run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version - name: Publish server package with provenance - if: ${{ secrets.NPM_TOKEN != '' }} env: + # Optional: when present, npm will use token auth. + # When empty/unset, npm trusted publishing (OIDC) may be used if configured. NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true NPM_CONFIG_REGISTRY: https://registry.npmjs.org + shell: bash run: | - npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance - - - name: Publish server package with provenance (OIDC) - if: ${{ secrets.NPM_TOKEN == '' }} - env: - NPM_CONFIG_PROVENANCE: true - NPM_CONFIG_REGISTRY: https://registry.npmjs.org - run: | + set -euo pipefail + if [ -z "${NODE_AUTH_TOKEN:-}" ]; then + echo "NPM_TOKEN not set; attempting npm trusted publishing (OIDC)" + unset NODE_AUTH_TOKEN + else + echo "Using NPM_TOKEN authentication" + fi npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance From ffe991bbe42f69cbfa8023851c894b68012eab2b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 13 Feb 2026 00:07:33 +0000 Subject: [PATCH 12/34] chore(release): simplify dev version format Switch dev builds to use -dev-YYYYMMDD-sha8 suffix and update version parsing + dev detection accordingly. --- .github/workflows/dev-release.yml | 4 ++-- packages/server/src/index.ts | 2 +- packages/server/src/releases/release-monitor.ts | 4 +++- packages/ui/src/stores/releases.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 520a806b..b7bb5d18 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -26,8 +26,8 @@ jobs: run: | set -euo pipefail SHA8="${GITHUB_SHA::8}" - TS=$(date -u +%Y%m%d%H%M%S) - echo "version_suffix=-dev.${TS}.${SHA8}" >> "$GITHUB_OUTPUT" + DATE=$(date -u +%Y%m%d) + echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT" prerelease: needs: prepare diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3f4461e3..e1450244 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -351,7 +351,7 @@ async function main() { const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase() const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim() - const isDevVersion = packageJson.version.includes("-dev.") + const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-") const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion) const devReleaseMonitor = enableDevUpdateChecks ? startDevReleaseMonitor({ diff --git a/packages/server/src/releases/release-monitor.ts b/packages/server/src/releases/release-monitor.ts index d84e6959..11f97d5c 100644 --- a/packages/server/src/releases/release-monitor.ts +++ b/packages/server/src/releases/release-monitor.ts @@ -107,7 +107,9 @@ export function stripTagPrefix(tag: string | undefined): string | null { function parseVersion(value: string): NormalizedVersion { const normalized = stripTagPrefix(value) ?? "0.0.0" - const [core, prerelease = null] = normalized.split("-", 2) + const dashIndex = normalized.indexOf("-") + const core = dashIndex >= 0 ? normalized.slice(0, dashIndex) : normalized + const prerelease = dashIndex >= 0 ? normalized.slice(dashIndex + 1) : null const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => { const parsed = Number.parseInt(segment, 10) return Number.isFinite(parsed) ? parsed : 0 diff --git a/packages/ui/src/stores/releases.ts b/packages/ui/src/stores/releases.ts index e864513d..39c199d2 100644 --- a/packages/ui/src/stores/releases.ts +++ b/packages/ui/src/stores/releases.ts @@ -148,7 +148,7 @@ function ensureMetaRefresh(meta: ServerMeta) { if (metaRefreshInterval) return const version = meta.serverVersion?.trim() ?? "" - const looksLikeDev = version.includes("-dev.") + const looksLikeDev = version.includes("-dev.") || version.includes("-dev-") const hasDevUpdateChannel = Boolean(meta.update) if (!looksLikeDev && !hasDevUpdateChannel) { From ba418a851803d21b1dd7acbde013a0c4054e95c2 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 13 Feb 2026 00:39:14 +0000 Subject: [PATCH 13/34] chore(release): publish dev builds as codenomad-dev Switch dev workflow to publish the server under @neuralnomads/codenomad-dev with dist-tag latest, avoiding @dev dist-tags. Add workflow input to override package name at publish time. --- .github/workflows/dev-release.yml | 3 ++- .github/workflows/manual-npm-publish.yml | 20 ++++++++++++++++++-- .github/workflows/release.yml | 1 + .github/workflows/reusable-release.yml | 6 ++++++ README.md | 2 +- 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index b7bb5d18..4fdc3c0f 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -34,7 +34,8 @@ jobs: uses: ./.github/workflows/reusable-release.yml with: version_suffix: ${{ needs.prepare.outputs.version_suffix }} - dist_tag: dev + npm_package_name: "@neuralnomads/codenomad-dev" + dist_tag: latest prerelease: true release_ui: false secrets: inherit diff --git a/.github/workflows/manual-npm-publish.yml b/.github/workflows/manual-npm-publish.yml index b67f71fe..81d93fd1 100644 --- a/.github/workflows/manual-npm-publish.yml +++ b/.github/workflows/manual-npm-publish.yml @@ -12,6 +12,11 @@ on: required: false default: dev type: string + package_name: + description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)" + required: false + default: "@neuralnomads/codenomad" + type: string workflow_call: inputs: version: @@ -21,6 +26,10 @@ on: required: false type: string default: dev + package_name: + required: false + type: string + default: "@neuralnomads/codenomad" secrets: NPM_TOKEN: required: false @@ -54,7 +63,7 @@ jobs: run: npm install @rollup/rollup-linux-x64-gnu --no-save - name: Build server package (includes UI bundling) - run: npm run build --workspace @neuralnomads/codenomad + run: npm run build --workspace packages/server - name: Set publish metadata shell: bash @@ -65,10 +74,17 @@ jobs: fi echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV" echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV" + echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV" - name: Bump package version for publish run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + - name: Set server package name for publish + shell: bash + run: | + set -euo pipefail + node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);" + - name: Publish server package with provenance env: # Optional: when present, npm will use token auth. @@ -85,4 +101,4 @@ jobs: else echo "Using NPM_TOKEN authentication" fi - npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance + npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfd07e8e..0ce704b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,4 +14,5 @@ jobs: uses: ./.github/workflows/reusable-release.yml with: dist_tag: latest + npm_package_name: "@neuralnomads/codenomad" secrets: inherit diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 460aa7d5..c34959ba 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -13,6 +13,11 @@ on: required: false default: dev type: string + npm_package_name: + description: "npm package name to publish (defaults to server package name)" + required: false + default: "" + type: string prerelease: description: "Create GitHub prerelease" required: false @@ -100,4 +105,5 @@ jobs: with: version: ${{ needs.prepare-release.outputs.version }} dist_tag: ${{ inputs.dist_tag }} + package_name: ${{ inputs.npm_package_name }} secrets: inherit diff --git a/README.md b/README.md index f1f7c4bc..9276ad4c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npx @neuralnomads/codenomad --launch For dev version ```bash -npx @neuralnomads/codenomad@dev --launch +npx @neuralnomads/codenomad-dev --launch ``` Dev builds are published as GitHub pre-releases: From da70cc9944dacdf24b8cc2d0eba7df723d1bf4ae Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 13 Feb 2026 00:51:42 +0000 Subject: [PATCH 14/34] fix(ui): keep prompt attachments in sync --- packages/ui/src/components/prompt-input.tsx | 17 +- .../prompt-input/attachmentPlaceholders.ts | 35 +--- .../ui/src/components/prompt-input/types.ts | 1 + .../prompt-input/usePromptAttachments.ts | 166 ++++++++++++------ .../src/components/session/session-view.tsx | 20 ++- 5 files changed, 146 insertions(+), 93 deletions(-) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 77ce1aeb..87eace66 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -1,8 +1,8 @@ -import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js" +import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import UnifiedPicker from "./unified-picker" import ExpandButton from "./expand-button" -import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments" +import { clearAttachments, removeAttachment } from "../stores/attachments" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import Kbd from "./kbd" import { getActiveInstance } from "../stores/instances" @@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) { handleDrop, syncAttachmentCounters, handleExpandTextAttachment, + handleRemoveAttachment, } = usePromptAttachments({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, @@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) { if (!attachment) return handleExpandTextAttachment(attachment) }, + removeAttachment: (attachmentId: string) => { + handleRemoveAttachment(attachmentId) + }, setPromptText: (text: string, opts?: { focus?: boolean }) => { const textarea = textareaRef if (textarea) { @@ -166,10 +170,7 @@ export default function PromptInput(props: PromptInputProps) { setAtPosition(null) setSearchQuery("") - const instanceId = props.instanceId - const sessionId = props.sessionId - const currentAttachments = untrack(() => getAttachments(instanceId, sessionId)) - syncAttachmentCounters(prompt(), currentAttachments) + syncAttachmentCounters(prompt()) }, { defer: true }, ), @@ -238,10 +239,10 @@ export default function PromptInput(props: PromptInputProps) { // Ignore attachments for slash commands, but keep them for next prompt. if (!isKnownSlashCommand) { clearAttachments(props.instanceId, props.sessionId) - syncAttachmentCounters("", []) + syncAttachmentCounters("") setIgnoredAtPositions(new Set()) } else { - syncAttachmentCounters("", currentAttachments) + syncAttachmentCounters("") setIgnoredAtPositions(new Set()) } diff --git a/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts b/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts index 3139cc06..1e009761 100644 --- a/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts +++ b/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts @@ -1,5 +1,3 @@ -import type { Attachment } from "../../types/attachment" - export function formatPastedPlaceholder(value: string | number) { return `[pasted #${value}]` } @@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) { } export function createPastedPlaceholderRegex() { - return /\[pasted #(\d+)\]/g + return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi } export function createImagePlaceholderRegex() { - return /\[Image #(\d+)\]/g + return /\[\s*Image\s*#\s*(\d+)\s*\]/gi } export function createMentionRegex() { return /@(\S+)/g } -export const pastedDisplayCounterRegex = /pasted #(\d+)/ -export const imageDisplayCounterRegex = /Image #(\d+)/ -export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/ +export const pastedDisplayCounterRegex = /pasted #(\d+)/i +export const imageDisplayCounterRegex = /Image #(\d+)/i +export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i export function parseCounter(value: string) { const parsed = Number.parseInt(value, 10) return Number.isNaN(parsed) ? null : parsed } -export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { +export function findHighestAttachmentCounters(currentPrompt: string) { let highestPaste = 0 let highestImage = 0 @@ -40,27 +38,6 @@ export function findHighestAttachmentCounters(currentPrompt: string, sessionAtta } } - for (const attachment of sessionAttachments) { - if (attachment.source.type === "text") { - const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) - if (placeholderMatch) { - const parsed = parseCounter(placeholderMatch[1]) - if (parsed !== null) { - highestPaste = Math.max(highestPaste, parsed) - } - } - } - if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) { - const imageMatch = attachment.display.match(imageDisplayCounterRegex) - if (imageMatch) { - const parsed = parseCounter(imageMatch[1]) - if (parsed !== null) { - highestImage = Math.max(highestImage, parsed) - } - } - } - } - for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) { const parsed = parseCounter(match[1]) if (parsed !== null) { diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts index b3ff1a39..54757793 100644 --- a/packages/ui/src/components/prompt-input/types.ts +++ b/packages/ui/src/components/prompt-input/types.ts @@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code" export interface PromptInputApi { insertSelection(text: string, mode: PromptInsertMode): void expandTextAttachment(attachmentId: string): void + removeAttachment(attachmentId: string): void setPromptText(text: string, opts?: { focus?: boolean }): void focus(): void } diff --git a/packages/ui/src/components/prompt-input/usePromptAttachments.ts b/packages/ui/src/components/prompt-input/usePromptAttachments.ts index fa833cb2..d4a53b4e 100644 --- a/packages/ui/src/components/prompt-input/usePromptAttachments.ts +++ b/packages/ui/src/components/prompt-input/usePromptAttachments.ts @@ -1,4 +1,4 @@ -import { createSignal, type Accessor } from "solid-js" +import { createEffect, createSignal, type Accessor } from "solid-js" import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments" import { createFileAttachment, createTextAttachment } from "../../types/attachment" import type { Attachment } from "../../types/attachment" @@ -7,6 +7,7 @@ import { findHighestAttachmentCounters, formatImagePlaceholder, formatPastedPlaceholder, + imageDisplayCounterRegex, pastedDisplayCounterRegex, } from "./attachmentPlaceholders" @@ -23,7 +24,7 @@ type PromptAttachments = { attachments: Accessor pasteCount: Accessor imageCount: Accessor - syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void + syncAttachmentCounters: (promptText: string) => void handlePaste: (e: ClipboardEvent) => Promise isDragging: Accessor @@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA const [pasteCount, setPasteCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0) - function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { - const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments) + function syncAttachmentCounters(currentPrompt: string) { + const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt) setPasteCount(highestPaste) setImageCount(highestImage) } + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + + function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) { + const next = currentPrompt.replace(tokenRegex, "") + if (next === currentPrompt) return currentPrompt + + return next + .replace(/[ \t]{2,}/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .trim() + } + + const createLooseImagePlaceholderRegex = (counter: string | number) => + new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i") + const createLoosePastedPlaceholderRegex = (counter: string | number) => + new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i") + + // Keep placeholder-backed attachments in sync with prompt text. + // If the placeholder token disappears from the prompt, the attachment should disappear too. + createEffect(() => { + const currentPrompt = options.prompt() + const currentAttachments = attachments() + + const toRemove: string[] = [] + + for (const attachment of currentAttachments) { + if (attachment.source.type === "text") { + const match = attachment.display.match(pastedDisplayCounterRegex) + if (!match) continue + const counter = match[1] + if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) { + toRemove.push(attachment.id) + } + continue + } + + if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) { + const match = + attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex) + if (!match) continue + const counter = match[1] + if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) { + toRemove.push(attachment.id) + } + } + } + + for (const attachmentId of toRemove) { + removeAttachment(options.instanceId(), options.sessionId(), attachmentId) + } + }) + function handleRemoveAttachment(attachmentId: string) { const currentAttachments = attachments() const attachment = currentAttachments.find((a) => a.id === attachmentId) + // Always remove from store. removeAttachment(options.instanceId(), options.sessionId(), attachmentId) - if (attachment) { - const currentPrompt = options.prompt() - let newPrompt = currentPrompt + if (!attachment) return - if (attachment.source.type === "file") { - if (attachment.mediaType.startsWith("image/")) { - const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex) - if (imageMatch) { - const placeholder = formatImagePlaceholder(imageMatch[1]) - newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() - } - } else { - const filename = attachment.filename - newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim() + const currentPrompt = options.prompt() + let nextPrompt = currentPrompt + + if (attachment.source.type === "file") { + if (attachment.mediaType.startsWith("image/")) { + const imageMatch = + attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex) + if (imageMatch) { + nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1])) } - } else if (attachment.source.type === "agent") { - const agentName = attachment.filename - newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim() - } else if (attachment.source.type === "text") { - const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) - if (placeholderMatch) { - const placeholder = formatPastedPlaceholder(placeholderMatch[1]) - newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() + } else { + // For file mentions we insert `@`, but the chip might display `@`. + const candidates = [attachment.source.path, attachment.filename] + for (const candidate of candidates) { + if (!candidate) continue + const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i") + nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex) } } + } else if (attachment.source.type === "agent") { + const agentName = attachment.filename + const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i") + nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex) + } else if (attachment.source.type === "text") { + const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) + if (placeholderMatch) { + nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1])) + } + } - options.setPrompt(newPrompt) + if (nextPrompt !== currentPrompt) { + options.setPrompt(nextPrompt) } } @@ -143,13 +205,32 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA const blob = item.getAsFile() if (!blob) continue - const count = imageCount() + 1 + const { highestImage } = findHighestAttachmentCounters(options.prompt()) + const count = highestImage + 1 setImageCount(count) + const placeholder = formatImagePlaceholder(count) + const textarea = options.getTextarea() + + if (textarea) { + const start = textarea.selectionStart + const end = textarea.selectionEnd + const currentText = options.prompt() + const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) + options.setPrompt(newText) + + setTimeout(() => { + const newCursorPos = start + placeholder.length + textarea.setSelectionRange(newCursorPos, newCursorPos) + textarea.focus() + }, 0) + } else { + options.setPrompt(options.prompt() + placeholder) + } + const reader = new FileReader() reader.onload = () => { const base64Data = (reader.result as string).split(",")[1] - const display = formatImagePlaceholder(count) const filename = `image-${count}.png` const attachment = createFileAttachment( @@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA options.instanceFolder(), ) attachment.url = `data:image/png;base64,${base64Data}` - attachment.display = display + attachment.display = placeholder addAttachment(options.instanceId(), options.sessionId(), attachment) - - const textarea = options.getTextarea() - if (textarea) { - const start = textarea.selectionStart - const end = textarea.selectionEnd - const currentText = options.prompt() - const placeholder = formatImagePlaceholder(count) - const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) - options.setPrompt(newText) - - setTimeout(() => { - const newCursorPos = start + placeholder.length - textarea.setSelectionRange(newCursorPos, newCursorPos) - textarea.focus() - }, 0) - } } reader.readAsDataURL(blob) @@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA if (isLongPaste) { e.preventDefault() - const count = pasteCount() + 1 + const { highestPaste } = findHighestAttachmentCounters(options.prompt()) + const count = highestPaste + 1 setPasteCount(count) const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars` @@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA const filename = `paste-${count}.txt` const attachment = createTextAttachment(pastedText, display, filename) - addAttachment(options.instanceId(), options.sessionId(), attachment) - + const placeholder = formatPastedPlaceholder(count) const textarea = options.getTextarea() if (textarea) { const start = textarea.selectionStart const end = textarea.selectionEnd const currentText = options.prompt() - const placeholder = formatPastedPlaceholder(count) const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) options.setPrompt(newText) @@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.focus() }, 0) + } else { + options.setPrompt(options.prompt() + placeholder) } + + addAttachment(options.instanceId(), options.sessionId(), attachment) } } diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index cce1b454..e0de2457 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -299,13 +299,19 @@ export const SessionView: Component = (props) => { /> - 0}> - removeAttachment(props.instanceId, props.sessionId, attachmentId)} - onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)} - /> - + 0}> + { + if (promptInputApi) { + promptInputApi.removeAttachment(attachmentId) + return + } + removeAttachment(props.instanceId, props.sessionId, attachmentId) + }} + onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)} + /> + Date: Fri, 13 Feb 2026 09:21:24 +0000 Subject: [PATCH 15/34] fix(ui): hide keyboard hints on phone layout --- .../instance/shell/SessionSidebar.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx index 6f3fd70a..9e745fc3 100644 --- a/packages/ui/src/components/instance/shell/SessionSidebar.tsx +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -119,11 +119,13 @@ const SessionSidebar: Component = (props) => (
-
- - - -
+ +
+ + + +
+
@@ -166,11 +168,13 @@ const SessionSidebar: Component = (props) => ( - + + +
)} From 3678214e69dc997a06406ea65a5597fbcad4f34b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 13 Feb 2026 09:54:46 +0000 Subject: [PATCH 16/34] fix(ui): hide keyboard hints on non-desktop --- packages/ui/src/components/hint-row.tsx | 7 +++- .../instance/shell/SessionSidebar.tsx | 34 +++++++++++-------- packages/ui/src/components/keyboard-hint.tsx | 16 ++++++--- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/components/hint-row.tsx b/packages/ui/src/components/hint-row.tsx index 40d3745c..af3829b3 100644 --- a/packages/ui/src/components/hint-row.tsx +++ b/packages/ui/src/components/hint-row.tsx @@ -3,10 +3,15 @@ import { Component, JSX } from "solid-js" interface HintRowProps { children: JSX.Element class?: string + ariaHidden?: boolean } const HintRow: Component = (props) => { - return {props.children} + return ( + + {props.children} + + ) } export default HintRow diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx index 9e745fc3..7c846b40 100644 --- a/packages/ui/src/components/instance/shell/SessionSidebar.tsx +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -1,7 +1,7 @@ import { Show, type Accessor, type Component } from "solid-js" import type { SessionThread } from "../../../stores/session-state" import type { Session } from "../../../types/session" -import type { KeyboardShortcut } from "../../../lib/keyboard-registry" +import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry" import type { DrawerViewState } from "./types" import { PlusSquare, Search } from "lucide-solid" @@ -13,7 +13,6 @@ import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined" import SessionList from "../../session-list" import KeyboardHint from "../../keyboard-hint" -import Kbd from "../../kbd" import WorktreeSelector from "../../worktree-selector" import AgentSelector from "../../agent-selector" import ModelSelector from "../../model-selector" @@ -119,12 +118,13 @@ const SessionSidebar: Component = (props) => ( - -
- - - -
+ + @@ -168,13 +168,17 @@ const SessionSidebar: Component = (props) => ( - - - + Boolean(shortcut))} + separator=" " + showDescription={false} + /> )} diff --git a/packages/ui/src/components/keyboard-hint.tsx b/packages/ui/src/components/keyboard-hint.tsx index f68e6de4..1176550f 100644 --- a/packages/ui/src/components/keyboard-hint.tsx +++ b/packages/ui/src/components/keyboard-hint.tsx @@ -1,14 +1,20 @@ import { Component, For } from "solid-js" -import { formatShortcut, isMac } from "../lib/keyboard-utils" +import useMediaQuery from "@suid/material/useMediaQuery" import type { KeyboardShortcut } from "../lib/keyboard-registry" import Kbd from "./kbd" import HintRow from "./hint-row" const KeyboardHint: Component<{ shortcuts: KeyboardShortcut[] - separator?: string + separator?: string | null showDescription?: boolean + class?: string + ariaHidden?: boolean }> = (props) => { + // Centralize layout gating here so call sites don't need to. + // We only show keyboard hint UI on desktop layouts. + const desktopQuery = useMediaQuery("(min-width: 1280px)") + function buildShortcutString(shortcut: KeyboardShortcut): string { const parts: string[] = [] @@ -26,12 +32,14 @@ const KeyboardHint: Component<{ return parts.join("+") } + if (!desktopQuery()) return null + return ( - + {(shortcut, i) => ( <> - {i() > 0 && {props.separator || "•"}} + {i() > 0 && props.separator !== null && {props.separator ?? "•"}} {props.showDescription !== false && {shortcut.description}} From 36baac06b81f53ac182e0909b0a8e7c2bc2f0215 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 13 Feb 2026 10:02:15 +0000 Subject: [PATCH 17/34] fix(ui): hide kbd hints on non-desktop --- .../components/instance/shell/SessionSidebar.tsx | 13 +++++-------- packages/ui/src/components/kbd.tsx | 4 ++++ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx index 7c846b40..7916766d 100644 --- a/packages/ui/src/components/instance/shell/SessionSidebar.tsx +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -118,14 +118,11 @@ const SessionSidebar: Component = (props) => (
- - - +
+ + + +
diff --git a/packages/ui/src/components/kbd.tsx b/packages/ui/src/components/kbd.tsx index d5b9c977..b9869111 100644 --- a/packages/ui/src/components/kbd.tsx +++ b/packages/ui/src/components/kbd.tsx @@ -1,4 +1,5 @@ import { Component, JSX, For } from "solid-js" +import useMediaQuery from "@suid/material/useMediaQuery" import { isMac } from "../lib/keyboard-utils" interface KbdProps { @@ -27,6 +28,9 @@ const SPECIAL_KEY_LABELS: Record = { } const Kbd: Component = (props) => { + const desktopQuery = useMediaQuery("(min-width: 1280px)") + if (!desktopQuery()) return null + const parts = () => { if (props.children) return [{ text: props.children, isModifier: false }] if (!props.shortcut) return [] From e30ff6358de4fad3084905190c26ee1fba0d1ead Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 13 Feb 2026 14:34:33 +0000 Subject: [PATCH 18/34] feat(settings): move config/state to owner buckets Add generic /api/storage config/state endpoints with merge-patch, migrate legacy YAML/JSON layout, and update UI/server to read and write owner-scoped settings. Replace config SSE events and drop /api/config routes. --- .../electron/main/process-manager.ts | 2 +- packages/opencode-config/package.json | 2 +- packages/server/src/api-types.ts | 15 +- packages/server/src/config/binaries.ts | 192 ----- packages/server/src/config/store.ts | 244 ------ packages/server/src/events/bus.ts | 8 +- packages/server/src/index.ts | 27 +- packages/server/src/server/http-server.ts | 10 +- packages/server/src/server/routes/config.ts | 76 -- packages/server/src/server/routes/settings.ts | 110 +++ packages/server/src/settings/binaries.ts | 55 ++ packages/server/src/settings/merge-patch.ts | 39 + packages/server/src/settings/migrate.ts | 269 ++++++ packages/server/src/settings/service.ts | 55 ++ .../server/src/settings/yaml-doc-store.ts | 110 +++ packages/server/src/workspaces/manager.ts | 15 +- .../tauri-app/src-tauri/src/cli_manager.rs | 36 +- packages/ui/src/App.tsx | 3 +- .../environment-variables-editor.tsx | 4 +- .../src/components/folder-selection-view.tsx | 6 +- packages/ui/src/components/model-selector.tsx | 4 +- .../components/opencode-binary-selector.tsx | 12 +- .../src/components/remote-access-overlay.tsx | 4 +- packages/ui/src/lib/api-client.ts | 44 +- packages/ui/src/lib/storage.ts | 150 +++- packages/ui/src/main.tsx | 4 +- packages/ui/src/stores/instances.ts | 4 +- packages/ui/src/stores/preferences.tsx | 799 +++++++++--------- packages/ui/src/stores/session-models.ts | 4 +- 29 files changed, 1252 insertions(+), 1051 deletions(-) delete mode 100644 packages/server/src/config/binaries.ts delete mode 100644 packages/server/src/config/store.ts delete mode 100644 packages/server/src/server/routes/config.ts create mode 100644 packages/server/src/server/routes/settings.ts create mode 100644 packages/server/src/settings/binaries.ts create mode 100644 packages/server/src/settings/merge-patch.ts create mode 100644 packages/server/src/settings/migrate.ts create mode 100644 packages/server/src/settings/service.ts create mode 100644 packages/server/src/settings/yaml-doc-store.ts diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index fdbf822b..a9b940c0 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -97,7 +97,7 @@ function readListeningModeFromConfig(): ListeningMode { return "local" } - const mode = parsed?.preferences?.listeningMode + const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode if (mode === "local" || mode === "all") { return mode } diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index c81d7cf8..be820634 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.1.53" + "@opencode-ai/plugin": "1.1.59" } } \ No newline at end of file diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index a48cf096..c3dea831 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -1,7 +1,6 @@ import type { AgentModelSelection, AgentModelSelections, - ConfigFile, ModelPreference, OpenCodeBinary, Preferences, @@ -183,9 +182,9 @@ export interface BinaryRecord { validationError?: string } -export type AppConfig = ConfigFile -export type AppConfigResponse = AppConfig -export type AppConfigUpdateRequest = Partial +export type SettingsOwner = string +export type SettingsBucket = Record +export type SettingsDoc = Record export interface BinaryListResponse { binaries: BinaryRecord[] @@ -214,8 +213,8 @@ export type WorkspaceEventType = | "workspace.error" | "workspace.stopped" | "workspace.log" - | "config.appChanged" - | "config.binariesChanged" + | "storage.configChanged" + | "storage.stateChanged" | "instance.dataChanged" | "instance.event" | "instance.eventStatus" @@ -226,8 +225,8 @@ export type WorkspaceEventPayload = | { type: "workspace.error"; workspace: WorkspaceDescriptor } | { type: "workspace.stopped"; workspaceId: string } | { type: "workspace.log"; entry: WorkspaceLogEntry } - | { type: "config.appChanged"; config: AppConfig } - | { type: "config.binariesChanged"; binaries: BinaryRecord[] } + | { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket } + | { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } diff --git a/packages/server/src/config/binaries.ts b/packages/server/src/config/binaries.ts deleted file mode 100644 index 56d86d50..00000000 --- a/packages/server/src/config/binaries.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - BinaryCreateRequest, - BinaryRecord, - BinaryUpdateRequest, - BinaryValidationResult, -} from "../api-types" -import { spawnSync } from "child_process" -import { ConfigStore } from "./store" -import { EventBus } from "../events/bus" -import type { ConfigFile } from "./schema" -import { Logger } from "../logger" -import { buildSpawnSpec } from "../workspaces/runtime" - -export class BinaryRegistry { - constructor( - private readonly configStore: ConfigStore, - private readonly eventBus: EventBus | undefined, - private readonly logger: Logger, - ) {} - - list(): BinaryRecord[] { - return this.mapRecords() - } - - resolveDefault(): BinaryRecord { - const binaries = this.mapRecords() - if (binaries.length === 0) { - this.logger.warn("No configured binaries found, falling back to opencode") - return this.buildFallbackRecord("opencode") - } - return binaries.find((binary) => binary.isDefault) ?? binaries[0] - } - - create(request: BinaryCreateRequest): BinaryRecord { - this.logger.debug({ path: request.path }, "Registering OpenCode binary") - const entry = { - path: request.path, - version: undefined, - lastUsed: Date.now(), - label: request.label, - } - - const config = this.configStore.get() - const nextConfig = this.cloneConfig(config) - const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path) - nextConfig.opencodeBinaries = [entry, ...deduped] - - if (request.makeDefault) { - nextConfig.preferences.lastUsedBinary = request.path - } - - this.configStore.replace(nextConfig) - const record = this.getById(request.path) - this.emitChange() - return record - } - - update(id: string, updates: BinaryUpdateRequest): BinaryRecord { - this.logger.debug({ id }, "Updating OpenCode binary") - const config = this.configStore.get() - const nextConfig = this.cloneConfig(config) - nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) => - binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary, - ) - - if (updates.makeDefault) { - nextConfig.preferences.lastUsedBinary = id - } - - this.configStore.replace(nextConfig) - const record = this.getById(id) - this.emitChange() - return record - } - - remove(id: string) { - this.logger.debug({ id }, "Removing OpenCode binary") - const config = this.configStore.get() - const nextConfig = this.cloneConfig(config) - const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id) - nextConfig.opencodeBinaries = remaining - - if (nextConfig.preferences.lastUsedBinary === id) { - nextConfig.preferences.lastUsedBinary = remaining[0]?.path - } - - this.configStore.replace(nextConfig) - this.emitChange() - } - - validatePath(path: string): BinaryValidationResult { - this.logger.debug({ path }, "Validating OpenCode binary path") - return this.validateRecord({ - id: path, - path, - label: this.prettyLabel(path), - isDefault: false, - }) - } - - private cloneConfig(config: ConfigFile): ConfigFile { - return JSON.parse(JSON.stringify(config)) as ConfigFile - } - - private mapRecords(): BinaryRecord[] { - - const config = this.configStore.get() - const configuredBinaries = config.opencodeBinaries.map((binary) => ({ - id: binary.path, - path: binary.path, - label: binary.label ?? this.prettyLabel(binary.path), - version: binary.version, - isDefault: false, - })) - - const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode" - - const annotated = configuredBinaries.map((binary) => ({ - ...binary, - isDefault: binary.path === defaultPath, - })) - - if (!annotated.some((binary) => binary.path === defaultPath)) { - annotated.unshift(this.buildFallbackRecord(defaultPath)) - } - - return annotated - } - - private getById(id: string): BinaryRecord { - return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id) - } - - private emitChange() { - this.logger.debug("Emitting binaries changed event") - this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() }) - } - - private validateRecord(record: BinaryRecord): BinaryValidationResult { - const inputPath = record.path - if (!inputPath) { - return { valid: false, error: "Missing binary path" } - } - - const spec = buildSpawnSpec(inputPath, ["--version"]) - - try { - const result = spawnSync(spec.command, spec.args, { - encoding: "utf8", - windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), - }) - - if (result.error) { - return { valid: false, error: result.error.message } - } - - if (result.status !== 0) { - const stderr = result.stderr?.trim() - const stdout = result.stdout?.trim() - const combined = stderr || stdout - const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` - return { valid: false, error } - } - - const stdout = (result.stdout ?? "").trim() - const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) - const normalized = firstLine?.trim() - - const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) - const version = versionMatch?.[1] - - return { valid: true, version } - } catch (error) { - return { valid: false, error: error instanceof Error ? error.message : String(error) } - } - } - - private buildFallbackRecord(path: string): BinaryRecord { - return { - id: path, - path, - label: this.prettyLabel(path), - isDefault: true, - } - } - - private prettyLabel(path: string) { - const parts = path.split(/[\\/]/) - const last = parts[parts.length - 1] || path - return last || path - } -} diff --git a/packages/server/src/config/store.ts b/packages/server/src/config/store.ts deleted file mode 100644 index 5d736f9c..00000000 --- a/packages/server/src/config/store.ts +++ /dev/null @@ -1,244 +0,0 @@ -import fs from "fs" -import path from "path" -import { parse as parseYaml, stringify as stringifyYaml } from "yaml" -import { EventBus } from "../events/bus" -import { Logger } from "../logger" -import { - ConfigFile, - ConfigFileSchema, - ConfigYamlSchema, - DEFAULT_CONFIG, - DEFAULT_CONFIG_YAML, - DEFAULT_STATE, - StateFile, - StateFileSchema, -} from "./schema" -import type { ConfigLocation } from "./location" - -export class ConfigStore { - private cache: ConfigFile = DEFAULT_CONFIG - private state: StateFile = DEFAULT_STATE - private loaded = false - - constructor( - private readonly location: ConfigLocation, - private readonly eventBus: EventBus | undefined, - private readonly logger: Logger, - ) {} - - load(): ConfigFile { - if (this.loaded) { - return this.cache - } - - try { - const configYamlPath = this.location.configYamlPath - const stateYamlPath = this.location.stateYamlPath - const legacyJsonPath = this.location.legacyJsonPath - - if (fs.existsSync(configYamlPath)) { - const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config") - const stateDoc = fs.existsSync(stateYamlPath) - ? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state") - : DEFAULT_STATE - - this.state = stateDoc - this.cache = this.mergeDocs(configDoc, stateDoc) - this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state") - } else if (fs.existsSync(legacyJsonPath)) { - const migrated = this.migrateFromLegacyJson(legacyJsonPath) - this.state = migrated.state - this.cache = migrated.config - } else { - // Fresh install: write defaults. - this.state = DEFAULT_STATE - this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE) - this.persist() - this.logger.debug( - { configYamlPath, stateYamlPath }, - "No config files found, created default YAML config/state", - ) - } - } catch (error) { - this.logger.warn({ err: error }, "Failed to load config/state, using defaults") - this.state = DEFAULT_STATE - this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE) - } - - this.loaded = true - return this.cache - } - - get(): ConfigFile { - return this.load() - } - - replace(config: ConfigFile) { - const validated = ConfigFileSchema.parse(config) - this.commit(validated) - } - - /** - * Apply a merge-patch update to the current config. - * - Missing keys are preserved. - * - Object values are merged recursively. - * - Explicit `null` deletes keys. - * - Arrays are replaced. - */ - mergePatch(patch: unknown) { - if (!patch || typeof patch !== "object" || Array.isArray(patch)) { - throw new Error("Config patch must be a JSON object") - } - const current = this.get() - const next = applyMergePatch(current as any, patch as any) - const validated = ConfigFileSchema.parse(next) - this.commit(validated) - } - - private commit(next: ConfigFile) { - this.cache = next - this.loaded = true - this.state = { - ...this.state, - recentFolders: next.recentFolders, - } - this.persist() - const published = Boolean(this.eventBus) - this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) - this.logger.debug({ broadcast: published }, "Config SSE event emitted") - this.logger.trace({ config: this.cache }, "Config payload") - } - - private persist() { - try { - const configYamlPath = this.location.configYamlPath - const stateYamlPath = this.location.stateYamlPath - - fs.mkdirSync(this.location.baseDir, { recursive: true }) - fs.mkdirSync(path.dirname(configYamlPath), { recursive: true }) - - const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any) - const stateYaml = stringifyYaml(this.state as any) - - fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8") - fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8") - - this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state") - } catch (error) { - this.logger.warn({ err: error }, "Failed to persist config") - } - } - - private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile { - const merged = { - ...(configDoc as any), - // State wins for recent folders. - recentFolders: stateDoc.recentFolders ?? [], - } - - return ConfigFileSchema.parse(merged) - } - - private readYamlFile( - filePath: string, - fallback: T, - schema: { parse: (value: unknown) => T }, - label: string, - ): T { - try { - const content = fs.readFileSync(filePath, "utf-8") - const parsed = parseYaml(content) - return schema.parse(parsed ?? {}) - } catch (error) { - this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults") - return fallback - } - } - - private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } { - const configYamlPath = this.location.configYamlPath - const stateYamlPath = this.location.stateYamlPath - - const content = fs.readFileSync(legacyJsonPath, "utf-8") - const parsed = JSON.parse(content) - const legacy = ConfigFileSchema.parse(parsed) - - const state: StateFile = StateFileSchema.parse({ - ...DEFAULT_STATE, - recentFolders: legacy.recentFolders ?? [], - }) - - const merged = this.mergeDocs(stripRecentFolders(legacy), state) - - // Persist YAML docs first, then move legacy aside. - try { - fs.mkdirSync(this.location.baseDir, { recursive: true }) - fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8") - fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8") - this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML") - } catch (error) { - this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state") - } - - try { - const bakPath = pickBackupPath(legacyJsonPath) - fs.renameSync(legacyJsonPath, bakPath) - this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup") - } catch (error) { - this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup") - } - - return { config: merged, state } - } -} - -function ensureTrailingNewline(content: string): string { - if (!content) return "\n" - return content.endsWith("\n") ? content : `${content}\n` -} - -function stripRecentFolders(config: ConfigFile): Omit & Record { - const clone: Record = { ...(config as any) } - delete clone.recentFolders - return clone as any -} - -function isPlainObject(value: unknown): value is Record { - if (!value || typeof value !== "object") return false - if (Array.isArray(value)) return false - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - -function applyMergePatch(current: any, patch: any): any { - // RFC 7396-ish merge patch with explicit null deletes. - if (!isPlainObject(patch)) { - return patch - } - - const base = isPlainObject(current) ? { ...current } : {} - for (const [key, value] of Object.entries(patch)) { - if (value === null) { - delete base[key] - continue - } - - if (isPlainObject(value) && isPlainObject(base[key])) { - base[key] = applyMergePatch(base[key], value) - continue - } - - // Arrays and scalars replace. - base[key] = value - } - return base -} - -function pickBackupPath(legacyJsonPath: string): string { - const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath - const preferred = `${base}.json.bak` - if (!fs.existsSync(preferred)) { - return preferred - } - return `${base}.json.bak.${Date.now()}` -} diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index 61453024..7673f00a 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -24,8 +24,8 @@ export class EventBus extends EventEmitter { this.on("workspace.error", handler) this.on("workspace.stopped", handler) this.on("workspace.log", handler) - this.on("config.appChanged", handler) - this.on("config.binariesChanged", handler) + this.on("storage.configChanged", handler) + this.on("storage.stateChanged", handler) this.on("instance.dataChanged", handler) this.on("instance.event", handler) this.on("instance.eventStatus", handler) @@ -35,8 +35,8 @@ export class EventBus extends EventEmitter { this.off("workspace.error", handler) this.off("workspace.stopped", handler) this.off("workspace.log", handler) - this.off("config.appChanged", handler) - this.off("config.binariesChanged", handler) + this.off("storage.configChanged", handler) + this.off("storage.stateChanged", handler) this.off("instance.dataChanged", handler) this.off("instance.event", handler) this.off("instance.eventStatus", handler) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e1450244..74f0f0d6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,9 +8,9 @@ import { fileURLToPath } from "url" import { createRequire } from "module" import { createHttpServer } from "./server/http-server" import { WorkspaceManager } from "./workspaces/manager" -import { ConfigStore } from "./config/store" import { resolveConfigLocation } from "./config/location" -import { BinaryRegistry } from "./config/binaries" +import { SettingsService } from "./settings/service" +import { BinaryResolver } from "./settings/binaries" import { FileSystemBrowser } from "./filesystem/browser" import { EventBus } from "./events/bus" import { ServerMeta } from "./api-types" @@ -291,21 +291,12 @@ async function main() { const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined - const configStore = new ConfigStore(configLocation, eventBus, configLogger) - - // Eagerly load config at boot so migrations run immediately - // (instead of waiting for the first /api/config request). - try { - configStore.get() - } catch (error) { - configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults") - } - - const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) + const settings = new SettingsService(configLocation, eventBus, configLogger) + const binaryResolver = new BinaryResolver(settings) const workspaceManager = new WorkspaceManager({ rootDir: options.rootDir, - configStore, - binaryRegistry, + settings, + binaryResolver, eventBus, logger: workspaceLogger, getServerBaseUrl: () => serverMeta.localUrl, @@ -392,8 +383,7 @@ async function main() { defaultPort: options.httpPort, protocol: "http", workspaceManager, - configStore, - binaryRegistry, + settings, fileSystemBrowser, eventBus, serverMeta, @@ -413,8 +403,7 @@ async function main() { protocol: "https", httpsOptions: tlsResolution?.httpsOptions, workspaceManager, - configStore, - binaryRegistry, + settings, fileSystemBrowser, eventBus, serverMeta, diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index caa90c21..3fc7106e 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,12 +9,11 @@ import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" -import { ConfigStore } from "../config/store" -import { BinaryRegistry } from "../config/binaries" +import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" import { EventBus } from "../events/bus" import { registerWorkspaceRoutes } from "./routes/workspaces" -import { registerConfigRoutes } from "./routes/config" +import { registerSettingsRoutes } from "./routes/settings" import { registerFilesystemRoutes } from "./routes/filesystem" import { registerMetaRoutes } from "./routes/meta" import { registerEventRoutes } from "./routes/events" @@ -37,8 +36,7 @@ interface HttpServerDeps { protocol: "http" | "https" httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } workspaceManager: WorkspaceManager - configStore: ConfigStore - binaryRegistry: BinaryRegistry + settings: SettingsService fileSystemBrowser: FileSystemBrowser eventBus: EventBus serverMeta: ServerMeta @@ -244,7 +242,7 @@ export function createHttpServer(deps: HttpServerDeps) { }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) - registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry }) + registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) diff --git a/packages/server/src/server/routes/config.ts b/packages/server/src/server/routes/config.ts deleted file mode 100644 index ea4e03c5..00000000 --- a/packages/server/src/server/routes/config.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { FastifyInstance } from "fastify" -import { z } from "zod" -import { ConfigStore } from "../../config/store" -import { BinaryRegistry } from "../../config/binaries" - -interface RouteDeps { - configStore: ConfigStore - binaryRegistry: BinaryRegistry -} - -const BinaryCreateSchema = z.object({ - path: z.string(), - label: z.string().optional(), - makeDefault: z.boolean().optional(), -}) - -const BinaryUpdateSchema = z.object({ - label: z.string().optional(), - makeDefault: z.boolean().optional(), -}) - -const BinaryValidateSchema = z.object({ - path: z.string(), -}) - -export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) { - app.get("/api/config/app", async () => deps.configStore.get()) - - app.put("/api/config/app", async (request, reply) => { - // Backwards compatible: treat PUT as a merge-patch update. - try { - deps.configStore.mergePatch(request.body ?? {}) - return deps.configStore.get() - } catch (error) { - reply.code(400) - return { error: error instanceof Error ? error.message : "Invalid config patch" } - } - }) - - app.patch("/api/config/app", async (request, reply) => { - try { - deps.configStore.mergePatch(request.body ?? {}) - return deps.configStore.get() - } catch (error) { - reply.code(400) - return { error: error instanceof Error ? error.message : "Invalid config patch" } - } - }) - - app.get("/api/config/binaries", async () => { - return { binaries: deps.binaryRegistry.list() } - }) - - app.post("/api/config/binaries", async (request, reply) => { - const body = BinaryCreateSchema.parse(request.body ?? {}) - const binary = deps.binaryRegistry.create(body) - reply.code(201) - return { binary } - }) - - app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => { - const body = BinaryUpdateSchema.parse(request.body ?? {}) - const binary = deps.binaryRegistry.update(request.params.id, body) - return { binary } - }) - - app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => { - deps.binaryRegistry.remove(request.params.id) - reply.code(204) - }) - - app.post("/api/config/binaries/validate", async (request) => { - const body = BinaryValidateSchema.parse(request.body ?? {}) - return deps.binaryRegistry.validatePath(body.path) - }) -} diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts new file mode 100644 index 00000000..4f5a70eb --- /dev/null +++ b/packages/server/src/server/routes/settings.ts @@ -0,0 +1,110 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import { spawnSync } from "child_process" +import { buildSpawnSpec } from "../../workspaces/runtime" +import type { SettingsService } from "../../settings/service" +import type { Logger } from "../../logger" + +interface RouteDeps { + settings: SettingsService + logger: Logger +} + +const ValidateBinarySchema = z.object({ + path: z.string(), +}) + +function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } { + if (!binaryPath) { + return { valid: false, error: "Missing binary path" } + } + + const spec = buildSpawnSpec(binaryPath, ["--version"]) + try { + const result = spawnSync(spec.command, spec.args, { + encoding: "utf8", + windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), + }) + + if (result.error) { + return { valid: false, error: result.error.message } + } + if (result.status !== 0) { + const stderr = result.stderr?.trim() + const stdout = result.stdout?.trim() + const combined = stderr || stdout + const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` + return { valid: false, error } + } + + const stdout = (result.stdout ?? "").trim() + const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) + const normalized = firstLine?.trim() + const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) + const version = versionMatch?.[1] + return { valid: true, version } + } catch (error) { + return { valid: false, error: error instanceof Error ? error.message : String(error) } + } +} + +export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { + // Full-document access + app.get("/api/storage/config", async () => deps.settings.getDoc("config")) + app.patch("/api/storage/config", async (request, reply) => { + try { + return deps.settings.mergePatchDoc("config", request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => { + return deps.settings.getOwner("config", request.params.owner) + }) + + app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => { + try { + return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + app.get("/api/storage/state", async () => deps.settings.getDoc("state")) + app.patch("/api/storage/state", async (request, reply) => { + try { + return deps.settings.mergePatchDoc("state", request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => { + return deps.settings.getOwner("state", request.params.owner) + }) + + app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => { + try { + return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + // Binary validation helper (used by UI when adding binaries) + app.post("/api/storage/binaries/validate", async (request, reply) => { + try { + const body = ValidateBinarySchema.parse(request.body ?? {}) + return validateBinaryPath(body.path) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to validate binary") + reply.code(400) + return { valid: false, error: error instanceof Error ? error.message : "Invalid request" } + } + }) +} diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts new file mode 100644 index 00000000..e4b25960 --- /dev/null +++ b/packages/server/src/settings/binaries.ts @@ -0,0 +1,55 @@ +import type { SettingsService } from "./service" + +export interface OpenCodeBinaryEntry { + path: string + version?: string + lastUsed?: number + label?: string +} + +export interface ResolvedBinary { + path: string + label: string + version?: string +} + +function prettyLabel(p: string): string { + const parts = p.split(/[\\/]/) + const last = parts[parts.length - 1] || p + return last || p +} + +function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] { + const ui = settings.getOwner("state", "ui") + const list = (ui as any)?.opencodeBinaries + if (!Array.isArray(list)) return [] + return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any +} + +function readDefaultBinaryPath(settings: SettingsService): string | undefined { + const server = settings.getOwner("config", "server") + const value = (server as any)?.opencodeBinary + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined +} + +export class BinaryResolver { + constructor(private readonly settings: SettingsService) {} + + list(): OpenCodeBinaryEntry[] { + return readUiBinaries(this.settings) + } + + resolveDefault(): ResolvedBinary { + const binaries = this.list() + const configuredDefault = readDefaultBinaryPath(this.settings) + const fallback = binaries[0]?.path + const path = configuredDefault ?? fallback ?? "opencode" + + const entry = binaries.find((b) => b.path === path) + return { + path, + label: entry?.label ?? prettyLabel(path), + version: entry?.version, + } + } +} diff --git a/packages/server/src/settings/merge-patch.ts b/packages/server/src/settings/merge-patch.ts new file mode 100644 index 00000000..f5008f46 --- /dev/null +++ b/packages/server/src/settings/merge-patch.ts @@ -0,0 +1,39 @@ +type PlainObject = Record + +export function isPlainObject(value: unknown): value is PlainObject { + if (!value || typeof value !== "object") return false + if (Array.isArray(value)) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +/** + * RFC 7396-ish merge patch with explicit null deletes. + * - Objects merge recursively + * - Arrays/scalars replace + * - null deletes keys + */ +export function applyMergePatch(current: unknown, patch: unknown): unknown { + if (!isPlainObject(patch)) { + return patch + } + + const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {} + + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete base[key] + continue + } + + const existing = base[key] + if (isPlainObject(value) && isPlainObject(existing)) { + base[key] = applyMergePatch(existing, value) + continue + } + + base[key] = value + } + + return base +} diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts new file mode 100644 index 00000000..e693a96d --- /dev/null +++ b/packages/server/src/settings/migrate.ts @@ -0,0 +1,269 @@ +import fs from "fs" +import path from "path" +import { parse as parseYaml, stringify as stringifyYaml } from "yaml" +import type { Logger } from "../logger" +import type { ConfigLocation } from "../config/location" +import { isPlainObject } from "./merge-patch" + +type Doc = Record + +function ensureTrailingNewline(content: string): string { + if (!content) return "\n" + return content.endsWith("\n") ? content : `${content}\n` +} + +function safeReadYaml(filePath: string, logger: Logger): unknown { + try { + const content = fs.readFileSync(filePath, "utf-8") + return parseYaml(content) + } catch (error) { + logger.warn({ err: error, filePath }, "Failed to read YAML file during migration") + return null + } +} + +function safeReadJson(filePath: string, logger: Logger): unknown { + try { + const content = fs.readFileSync(filePath, "utf-8") + return JSON.parse(content) + } catch (error) { + logger.warn({ err: error, filePath }, "Failed to read JSON file during migration") + return null + } +} + +function writeYaml(filePath: string, doc: Doc, logger: Logger) { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const yaml = stringifyYaml(doc as any) + fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8") + } catch (error) { + logger.warn({ err: error, filePath }, "Failed to write YAML file during migration") + } +} + +function pickBackupPath(filePath: string): string { + const preferred = `${filePath}.bak` + if (!fs.existsSync(preferred)) { + return preferred + } + return `${filePath}.bak.${Date.now()}` +} + +function normalizeDoc(value: unknown): Doc { + return isPlainObject(value) ? (value as Doc) : {} +} + +function looksLikeNewOwnerDoc(value: unknown): boolean { + const doc = normalizeDoc(value) + // Heuristic: owner-bucket docs have at least one of these roots. + return Boolean(doc.ui || doc.server || doc.app || doc.legacy) +} + +function looksLikeLegacyConfig(value: unknown): boolean { + const doc = normalizeDoc(value) + return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders) +} + +function looksLikeLegacyState(value: unknown): boolean { + const doc = normalizeDoc(value) + return Boolean(doc.recentFolders) +} + +function omitKeys(source: Doc, keys: Set): Doc { + const out: Doc = {} + for (const [k, v] of Object.entries(source)) { + if (keys.has(k)) continue + out[k] = v + } + return out +} + +function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } { + const cfg = normalizeDoc(legacyConfig) + const st = normalizeDoc(legacyState) + + const outConfig: Doc = {} + const outState: Doc = {} + + const uiConfig: Doc = {} + const uiSettings: Doc = {} + const serverConfig: Doc = {} + const uiState: Doc = {} + + // theme -> config.ui.theme + if (typeof cfg.theme === "string") { + uiConfig.theme = cfg.theme + } + + const preferences = normalizeDoc(cfg.preferences) + if (Object.keys(preferences).length > 0) { + // Server-owned stable keys + const envVars = preferences.environmentVariables + if (isPlainObject(envVars)) { + serverConfig.environmentVariables = envVars + } + const listeningMode = preferences.listeningMode + if (typeof listeningMode === "string") { + serverConfig.listeningMode = listeningMode + } + const lastUsedBinary = preferences.lastUsedBinary + if (typeof lastUsedBinary === "string") { + serverConfig.opencodeBinary = lastUsedBinary + } + + // UI-owned state keys (drop preferences) + const modelRecents = preferences.modelRecents + const modelFavorites = preferences.modelFavorites + const modelThinkingSelections = preferences.modelThinkingSelections + + const models: Doc = {} + if (Array.isArray(modelRecents)) { + models.recents = modelRecents + } + if (Array.isArray(modelFavorites)) { + models.favorites = modelFavorites + } + if (isPlainObject(modelThinkingSelections)) { + models.thinkingSelections = modelThinkingSelections + } + if (Object.keys(models).length > 0) { + uiState.models = models + } + + // Remaining preferences are treated as stable UI settings. + const moved = new Set([ + "environmentVariables", + "listeningMode", + "lastUsedBinary", + "modelRecents", + "modelFavorites", + "modelThinkingSelections", + ]) + Object.assign(uiSettings, omitKeys(preferences, moved)) + } + + // recentFolders lives in legacy state (yaml) or legacy config.json + const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown + if (Array.isArray(recentFolders)) { + uiState.recentFolders = recentFolders + } + + // opencodeBinaries -> state.ui.opencodeBinaries + if (Array.isArray(cfg.opencodeBinaries)) { + uiState.opencodeBinaries = cfg.opencodeBinaries + } + + if (Object.keys(uiSettings).length > 0) { + uiConfig.settings = uiSettings + } + + if (Object.keys(uiConfig).length > 0) { + outConfig.ui = uiConfig + } + if (Object.keys(serverConfig).length > 0) { + outConfig.server = serverConfig + } + if (Object.keys(uiState).length > 0) { + outState.ui = uiState + } + + // Unknown top-level keys -> legacy.unknown + const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"]) + const unknownConfig = omitKeys(cfg, knownConfigKeys) + if (Object.keys(unknownConfig).length > 0) { + outConfig.legacy = { unknown: unknownConfig } + } + + const knownStateKeys = new Set(["recentFolders"]) + const unknownState = omitKeys(st, knownStateKeys) + if (Object.keys(unknownState).length > 0) { + outState.legacy = { unknown: unknownState } + } + + return { config: outConfig, state: outState } +} + +/** + * Migrate older config/state layouts into owner-bucket YAML docs. + * + * Legacy inputs supported: + * - config.yaml with { preferences, opencodeBinaries, theme } + * - state.yaml with { recentFolders } + * - legacy config.json with full ConfigFile schema + */ +export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) { + const configYamlPath = location.configYamlPath + const stateYamlPath = location.stateYamlPath + const legacyJsonPath = location.legacyJsonPath + + const configExists = fs.existsSync(configYamlPath) + const stateExists = fs.existsSync(stateYamlPath) + + const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null + const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null + + const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc) + const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc) + + if (configIsNew && stateIsNew) { + return + } + + const legacyJsonExists = fs.existsSync(legacyJsonPath) + + const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc)) + const shouldMigrateFromJson = !configExists && legacyJsonExists + + if (!hasLegacyYaml && !shouldMigrateFromJson) { + // Either fresh install or partially written docs; let stores create on first write. + return + } + + const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc + const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc + + const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState) + + try { + fs.mkdirSync(location.baseDir, { recursive: true }) + } catch (error) { + logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration") + } + + // Backup legacy files before rewriting. + if (configExists) { + try { + const bak = pickBackupPath(configYamlPath) + fs.renameSync(configYamlPath, bak) + logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml") + } catch (error) { + logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml") + } + } + + if (stateExists) { + try { + const bak = pickBackupPath(stateYamlPath) + fs.renameSync(stateYamlPath, bak) + logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml") + } catch (error) { + logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml") + } + } + + if (shouldMigrateFromJson) { + try { + const bak = pickBackupPath(legacyJsonPath) + fs.renameSync(legacyJsonPath, bak) + logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup") + } catch (error) { + logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup") + } + } + + writeYaml(configYamlPath, config, logger) + writeYaml(stateYamlPath, state, logger) + + logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout") +} diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts new file mode 100644 index 00000000..02a18422 --- /dev/null +++ b/packages/server/src/settings/service.ts @@ -0,0 +1,55 @@ +import type { Logger } from "../logger" +import type { EventBus } from "../events/bus" +import type { ConfigLocation } from "../config/location" +import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" +import { migrateSettingsLayout } from "./migrate" +import type { WorkspaceEventPayload } from "../api-types" + +export type DocKind = "config" | "state" + +export class SettingsService { + private readonly configStore: YamlDocStore + private readonly stateStore: YamlDocStore + + constructor( + private readonly location: ConfigLocation, + private readonly eventBus: EventBus | undefined, + private readonly logger: Logger, + ) { + migrateSettingsLayout(location, logger) + this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" })) + this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" })) + } + + getDoc(kind: DocKind): SettingsDoc { + return kind === "config" ? this.configStore.get() : this.stateStore.get() + } + + mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc { + const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch) + this.publish(kind, "*") + return updated + } + + getOwner(kind: DocKind, owner: string): SettingsDoc { + return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner) + } + + mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc { + const updated = + kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch) + this.publish(kind, owner, updated) + return updated + } + + private publish(kind: DocKind, owner: string, value?: SettingsDoc) { + if (!this.eventBus) return + const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged" + const payload: WorkspaceEventPayload = { + type, + owner, + value: value ?? this.getOwner(kind, owner), + } as any + this.eventBus.publish(payload) + } +} diff --git a/packages/server/src/settings/yaml-doc-store.ts b/packages/server/src/settings/yaml-doc-store.ts new file mode 100644 index 00000000..91c5540c --- /dev/null +++ b/packages/server/src/settings/yaml-doc-store.ts @@ -0,0 +1,110 @@ +import fs from "fs" +import path from "path" +import { parse as parseYaml, stringify as stringifyYaml } from "yaml" +import type { Logger } from "../logger" +import { applyMergePatch, isPlainObject } from "./merge-patch" + +export type SettingsDoc = Record + +function ensureTrailingNewline(content: string): string { + if (!content) return "\n" + return content.endsWith("\n") ? content : `${content}\n` +} + +function normalizeDoc(input: unknown): SettingsDoc { + if (!isPlainObject(input)) { + return {} + } + return input +} + +export class YamlDocStore { + private cache: SettingsDoc = {} + private loaded = false + + constructor( + private readonly filePath: string, + private readonly logger: Logger, + ) {} + + load(): SettingsDoc { + if (this.loaded) { + return this.cache + } + + try { + if (!fs.existsSync(this.filePath)) { + this.cache = {} + this.loaded = true + return this.cache + } + + const content = fs.readFileSync(this.filePath, "utf-8") + const parsed = parseYaml(content) + this.cache = normalizeDoc(parsed) + this.loaded = true + return this.cache + } catch (error) { + this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object") + this.cache = {} + this.loaded = true + return this.cache + } + } + + get(): SettingsDoc { + return this.load() + } + + replace(next: unknown): SettingsDoc { + const normalized = normalizeDoc(next) + this.cache = normalized + this.loaded = true + this.persist() + return this.cache + } + + mergePatch(patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } + const current = this.get() + const next = applyMergePatch(current, patch) + return this.replace(next) + } + + getOwner(owner: string): SettingsDoc { + const doc = this.get() + const value = (doc as any)?.[owner] + return normalizeDoc(value) + } + + replaceOwner(owner: string, value: unknown): SettingsDoc { + const doc = this.get() + const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) } + this.replace(nextDoc) + return nextDoc[owner] as SettingsDoc + } + + mergePatchOwner(owner: string, patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } + const doc = this.get() + const currentOwner = normalizeDoc((doc as any)?.[owner]) + const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch)) + const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner } + this.replace(nextDoc) + return nextOwner + } + + private persist() { + try { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }) + const yaml = stringifyYaml(this.cache as any) + fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8") + } catch (error) { + this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc") + } + } +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 8afca60b..a4b50e06 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -2,8 +2,8 @@ import path from "path" import { spawnSync } from "child_process" import { connect } from "net" import { EventBus } from "../events/bus" -import { ConfigStore } from "../config/store" -import { BinaryRegistry } from "../config/binaries" +import type { SettingsService } from "../settings/service" +import type { BinaryResolver } from "../settings/binaries" import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" @@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500 interface WorkspaceManagerOptions { rootDir: string - configStore: ConfigStore - binaryRegistry: BinaryRegistry + settings: SettingsService + binaryResolver: BinaryResolver eventBus: EventBus logger: Logger getServerBaseUrl: () => string @@ -86,7 +86,7 @@ export class WorkspaceManager { async create(folder: string, name?: string): Promise { const id = `${Date.now().toString(36)}` - const binary = this.options.binaryRegistry.resolveDefault() + const binary = this.options.binaryResolver.resolveDefault() const resolvedBinaryPath = this.resolveBinaryPath(binary.path) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) clearWorkspaceSearchCache(workspacePath) @@ -118,8 +118,9 @@ export class WorkspaceManager { this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) - const preferences = this.options.configStore.get().preferences ?? {} - const userEnvironment = preferences.environmentVariables ?? {} + const serverConfig = this.options.settings.getOwner("config", "server") + const envVars = (serverConfig as any)?.environmentVariables + const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} const opencodeUsername = DEFAULT_OPENCODE_USERNAME const opencodePassword = generateOpencodeServerPassword() diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index ad0860f9..6b55b945 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -140,9 +140,16 @@ struct PreferencesConfig { listening_mode: Option, } +#[derive(Debug, Deserialize)] +struct ServerConfig { + #[serde(rename = "listeningMode")] + listening_mode: Option, +} + #[derive(Debug, Deserialize)] struct AppConfig { preferences: Option, + server: Option, } fn resolve_config_locations() -> (PathBuf, PathBuf) { @@ -188,11 +195,18 @@ fn resolve_listening_mode() -> String { if let Ok(content) = fs::read_to_string(&yaml_path) { if let Ok(config) = serde_yaml::from_str::(&content) { - if let Some(mode) = config - .preferences + let mode = config + .server .as_ref() - .and_then(|prefs| prefs.listening_mode.as_ref()) - { + .and_then(|srv| srv.listening_mode.as_ref()) + .or_else(|| { + config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + }); + + if let Some(mode) = mode { if mode == "local" { return "local".to_string(); } @@ -206,11 +220,17 @@ fn resolve_listening_mode() -> String { // Legacy fallback. if let Ok(content) = fs::read_to_string(&json_path) { if let Ok(config) = serde_json::from_str::(&content) { - if let Some(mode) = config - .preferences + let mode = config + .server .as_ref() - .and_then(|prefs| prefs.listening_mode.as_ref()) - { + .and_then(|srv| srv.listening_mode.as_ref()) + .or_else(|| { + config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + }); + if let Some(mode) = mode { if mode == "local" { return "local".to_string(); } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index f4092ad3..c4e747c8 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -58,6 +58,7 @@ const App: Component = () => { const { t } = useI18n() const { preferences, + serverSettings, recordWorkspaceLaunch, toggleShowThinkingBlocks, toggleShowTimelineTools, @@ -177,7 +178,7 @@ const App: Component = () => { return } setIsSelectingFolder(true) - const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode" + const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" try { recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() diff --git a/packages/ui/src/components/environment-variables-editor.tsx b/packages/ui/src/components/environment-variables-editor.tsx index 07e3738d..e8653ba0 100644 --- a/packages/ui/src/components/environment-variables-editor.tsx +++ b/packages/ui/src/components/environment-variables-editor.tsx @@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps { const EnvironmentVariablesEditor: Component = (props) => { const { t } = useI18n() const { - preferences, + serverSettings, addEnvironmentVariable, removeEnvironmentVariable, updateEnvironmentVariables, } = useConfig() - const [envVars, setEnvVars] = createSignal>(preferences().environmentVariables || {}) + const [envVars, setEnvVars] = createSignal>(serverSettings().environmentVariables || {}) const [newKey, setNewKey] = createSignal("") const [newValue, setNewValue] = createSignal("") diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 281ea084..7de3fe43 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -26,11 +26,11 @@ interface FolderSelectionViewProps { } const FolderSelectionView: Component = (props) => { - const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig() + const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig() const { t, locale } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") - const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") + const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const nativeDialogsAvailable = supportsNativeDialogs() let recentListRef: HTMLDivElement | undefined @@ -53,7 +53,7 @@ const FolderSelectionView: Component = (props) => { // Update selected binary when preferences change createEffect(() => { - const lastUsed = preferences().lastUsedBinary + const lastUsed = serverSettings().opencodeBinary if (!lastUsed) return setSelectedBinary((current) => (current === lastUsed ? current : lastUsed)) }) diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index c7960633..005af3ad 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid" import type { Model } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" -import { preferences, toggleFavoriteModelPreference } from "../stores/preferences" +import { uiState, toggleFavoriteModelPreference } from "../stores/preferences" const log = getLogger("session") @@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) { const favoriteKeySet = createMemo(() => { const result = new Set() - for (const item of preferences().modelFavorites ?? []) { + for (const item of uiState().models.favorites ?? []) { if (item.providerId && item.modelId) { result.add(`${item.providerId}/${item.modelId}`) } diff --git a/packages/ui/src/components/opencode-binary-selector.tsx b/packages/ui/src/components/opencode-binary-selector.tsx index dc6cdc0d..9518c24b 100644 --- a/packages/ui/src/components/opencode-binary-selector.tsx +++ b/packages/ui/src/components/opencode-binary-selector.tsx @@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component = (props) = opencodeBinaries, addOpenCodeBinary, removeOpenCodeBinary, - preferences, - updatePreferences, + serverSettings, + updateLastUsedBinary, } = useConfig() const [customPath, setCustomPath] = createSignal("") const [validating, setValidating] = createSignal(false) @@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component = (props) = const binaries = () => opencodeBinaries() - const lastUsedBinary = () => preferences().lastUsedBinary + const lastUsedBinary = () => serverSettings().opencodeBinary const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode")) @@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component = (props) = if (validation.valid) { addOpenCodeBinary(path, validation.version) props.onBinaryChange(path) - updatePreferences({ lastUsedBinary: path }) + updateLastUsedBinary(path) setCustomPath("") setValidationError(null) } else { @@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component = (props) = if (props.disabled) return if (path === props.selectedBinary) return props.onBinaryChange(path) - updatePreferences({ lastUsedBinary: path }) + updateLastUsedBinary(path) } function handleRemoveBinary(path: string, event: Event) { @@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component = (props) = if (props.selectedBinary === path) { props.onBinaryChange("opencode") - updatePreferences({ lastUsedBinary: "opencode" }) + updateLastUsedBinary("opencode") } } diff --git a/packages/ui/src/components/remote-access-overlay.tsx b/packages/ui/src/components/remote-access-overlay.tsx index e6589b5c..08815a15 100644 --- a/packages/ui/src/components/remote-access-overlay.tsx +++ b/packages/ui/src/components/remote-access-overlay.tsx @@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" import { restartCli } from "../lib/native/cli" -import { preferences, setListeningMode } from "../stores/preferences" +import { serverSettings, setListeningMode } from "../stores/preferences" import { showConfirmDialog } from "../stores/alerts" import { getLogger } from "../lib/logger" import { useI18n } from "../lib/i18n" @@ -33,7 +33,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { const [savingPassword, setSavingPassword] = createSignal(false) const addresses = createMemo(() => meta()?.addresses ?? []) - const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode) + const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) const allowExternalConnections = createMemo(() => currentMode() === "all") const displayAddresses = createMemo(() => { const list = addresses() diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 74898c3f..96971e00 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -1,11 +1,7 @@ import type { - AppConfig, BackgroundProcess, BackgroundProcessListResponse, BackgroundProcessOutputResponse, - BinaryCreateRequest, - BinaryListResponse, - BinaryUpdateRequest, BinaryValidationResult, FileSystemEntry, FileSystemCreateFolderResponse, @@ -214,37 +210,27 @@ export const serverApi = { ) }, - fetchConfig(): Promise { - return request("/api/config/app") + fetchConfigOwner = Record>(owner: string): Promise { + return request(`/api/storage/config/${encodeURIComponent(owner)}`) }, - updateConfig(payload: AppConfig): Promise { - return request("/api/config/app", { - method: "PUT", - body: JSON.stringify(payload), - }) - }, - listBinaries(): Promise { - return request("/api/config/binaries") - }, - createBinary(payload: BinaryCreateRequest) { - return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", { - method: "POST", - body: JSON.stringify(payload), - }) - }, - - updateBinary(id: string, updates: BinaryUpdateRequest) { - return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, { + patchConfigOwner = Record>(owner: string, patch: unknown): Promise { + return request(`/api/storage/config/${encodeURIComponent(owner)}`, { method: "PATCH", - body: JSON.stringify(updates), + body: JSON.stringify(patch ?? {}), + }) + }, + fetchStateOwner = Record>(owner: string): Promise { + return request(`/api/storage/state/${encodeURIComponent(owner)}`) + }, + patchStateOwner = Record>(owner: string, patch: unknown): Promise { + return request(`/api/storage/state/${encodeURIComponent(owner)}`, { + method: "PATCH", + body: JSON.stringify(patch ?? {}), }) }, - deleteBinary(id: string): Promise { - return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" }) - }, validateBinary(path: string): Promise { - return request("/api/config/binaries/validate", { + return request("/api/storage/binaries/validate", { method: "POST", body: JSON.stringify({ path }), }) diff --git a/packages/ui/src/lib/storage.ts b/packages/ui/src/lib/storage.ts index a5f1afa6..4616bc94 100644 --- a/packages/ui/src/lib/storage.ts +++ b/packages/ui/src/lib/storage.ts @@ -1,11 +1,11 @@ -import type { AppConfig, InstanceData } from "../../../server/src/api-types" +import type { InstanceData, WorkspaceEventPayload } from "../../../server/src/api-types" import { serverApi } from "./api-client" import { serverEvents } from "./server-events" import { getLogger } from "./logger" const log = getLogger("actions") -export type ConfigData = AppConfig +export type OwnerBucket = Record const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], @@ -30,17 +30,25 @@ function isDeepEqual(a: unknown, b: unknown): boolean { } export class ServerStorage { - private configChangeListeners: Set<(config: ConfigData) => void> = new Set() - private configCache: ConfigData | null = null - private loadPromise: Promise | null = null + private configOwnerCache = new Map() + private stateOwnerCache = new Map() + private configOwnerLoadPromises = new Map>() + private stateOwnerLoadPromises = new Map>() + private configOwnerListeners = new Map void>>() + private stateOwnerListeners = new Map void>>() private instanceDataCache = new Map() private instanceDataListeners = new Map void>>() private instanceLoadPromises = new Map>() constructor() { - serverEvents.on("config.appChanged", (event) => { - if (event.type !== "config.appChanged") return - this.setConfigCache(event.config) + serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => { + if (event.type !== "storage.configChanged") return + this.setOwnerCache("config", event.owner, event.value) + }) + + serverEvents.on("storage.stateChanged", (event: WorkspaceEventPayload) => { + if (event.type !== "storage.stateChanged") return + this.setOwnerCache("state", event.owner, event.value) }) serverEvents.on("instance.dataChanged", (event) => { @@ -49,30 +57,56 @@ export class ServerStorage { }) } - async loadConfig(): Promise { - if (this.configCache) { - return this.configCache - } + async loadConfigOwner(owner: string): Promise { + const cached = this.configOwnerCache.get(owner) + if (cached) return cached - if (!this.loadPromise) { - this.loadPromise = serverApi - .fetchConfig() - .then((config) => { - this.setConfigCache(config) - return config + if (!this.configOwnerLoadPromises.has(owner)) { + const promise = serverApi + .fetchConfigOwner(owner) + .then((value) => { + this.setOwnerCache("config", owner, value) + return value }) .finally(() => { - this.loadPromise = null + this.configOwnerLoadPromises.delete(owner) }) + this.configOwnerLoadPromises.set(owner, promise) } - return this.loadPromise + return this.configOwnerLoadPromises.get(owner)! } - async updateConfig(next: ConfigData): Promise { - const nextConfig = await serverApi.updateConfig(next) - this.setConfigCache(nextConfig) - return nextConfig + async patchConfigOwner(owner: string, patch: unknown): Promise { + const updated = await serverApi.patchConfigOwner(owner, patch) + this.setOwnerCache("config", owner, updated) + return updated + } + + async loadStateOwner(owner: string): Promise { + const cached = this.stateOwnerCache.get(owner) + if (cached) return cached + + if (!this.stateOwnerLoadPromises.has(owner)) { + const promise = serverApi + .fetchStateOwner(owner) + .then((value) => { + this.setOwnerCache("state", owner, value) + return value + }) + .finally(() => { + this.stateOwnerLoadPromises.delete(owner) + }) + this.stateOwnerLoadPromises.set(owner, promise) + } + + return this.stateOwnerLoadPromises.get(owner)! + } + + async patchStateOwner(owner: string, patch: unknown): Promise { + const updated = await serverApi.patchStateOwner(owner, patch) + this.setOwnerCache("state", owner, updated) + return updated } async loadInstanceData(instanceId: string): Promise { @@ -110,12 +144,40 @@ export class ServerStorage { this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA) } - onConfigChanged(listener: (config: ConfigData) => void): () => void { - this.configChangeListeners.add(listener) - if (this.configCache) { - listener(this.configCache) + onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void { + if (!this.configOwnerListeners.has(owner)) { + this.configOwnerListeners.set(owner, new Set()) + } + const bucket = this.configOwnerListeners.get(owner)! + bucket.add(listener) + const cached = this.configOwnerCache.get(owner) + if (cached) { + listener(cached) + } + return () => { + bucket.delete(listener) + if (bucket.size === 0) { + this.configOwnerListeners.delete(owner) + } + } + } + + onStateOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void { + if (!this.stateOwnerListeners.has(owner)) { + this.stateOwnerListeners.set(owner, new Set()) + } + const bucket = this.stateOwnerListeners.get(owner)! + bucket.add(listener) + const cached = this.stateOwnerCache.get(owner) + if (cached) { + listener(cached) + } + return () => { + bucket.delete(listener) + if (bucket.size === 0) { + this.stateOwnerListeners.delete(owner) + } } - return () => this.configChangeListeners.delete(listener) } onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void { @@ -136,18 +198,30 @@ export class ServerStorage { } } - private setConfigCache(config: ConfigData) { - if (this.configCache && isDeepEqual(this.configCache, config)) { - this.configCache = config + private setOwnerCache(kind: "config" | "state", owner: string, value: OwnerBucket) { + if (owner === "*") { + // Full-doc updates are not tracked owner-by-owner; invalidate caches. + if (kind === "config") { + this.configOwnerCache.clear() + } else { + this.stateOwnerCache.clear() + } return } - this.configCache = config - this.notifyConfigChanged(config) - } - private notifyConfigChanged(config: ConfigData) { - for (const listener of this.configChangeListeners) { - listener(config) + const cache = kind === "config" ? this.configOwnerCache : this.stateOwnerCache + const listeners = kind === "config" ? this.configOwnerListeners : this.stateOwnerListeners + + const previous = cache.get(owner) + if (previous && isDeepEqual(previous, value)) { + cache.set(owner, value) + return + } + cache.set(owner, value) + const bucket = listeners.get(owner) + if (!bucket) return + for (const listener of bucket) { + listener(value) } } diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 4ab54714..c0a1b4ff 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -30,8 +30,8 @@ async function bootstrap() { document.documentElement.removeAttribute("data-theme") try { - const config = await storage.loadConfig() - const theme = config?.theme ?? "system" + const uiConfig = await storage.loadConfigOwner("ui") + const theme = (uiConfig as any)?.theme ?? "system" if (theme === "system") { document.documentElement.removeAttribute("data-theme") diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index f3e5c56e..f2a39bda 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -20,7 +20,7 @@ import { } from "./sessions" import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" import { fetchCommands, clearCommands } from "./commands" -import { preferences } from "./preferences" +import { serverSettings } from "./preferences" import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" @@ -91,7 +91,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath, binaryLabel: descriptor.binaryLabel, binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion, - environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {}, + environmentVariables: existing?.environmentVariables ?? serverSettings().environmentVariables ?? {}, } } diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 5907e78e..e66af3e9 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,6 +1,6 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" -import { storage, type ConfigData } from "../lib/storage" +import { storage, type OwnerBucket } from "../lib/storage" import { ensureInstanceConfigLoaded, getInstanceConfig, @@ -23,32 +23,21 @@ export interface ModelPreference { modelId: string } -export interface AgentModelSelections { - [instanceId: string]: Record -} - export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" - export type ListeningMode = "local" | "all" -export interface Preferences { +export interface UiSettings { showThinkingBlocks: boolean thinkingBlocksExpansion: ExpansionPreference showTimelineTools: boolean promptSubmitOnEnter: boolean - lastUsedBinary?: string locale?: string - environmentVariables: Record - modelRecents: ModelPreference[] - modelFavorites: ModelPreference[] - modelThinkingSelections: Record diffViewMode: DiffViewMode toolOutputExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference showUsageMetrics: boolean autoCleanupBlankSessions: boolean - listeningMode: ListeningMode // OS notifications osNotificationsEnabled: boolean @@ -57,12 +46,14 @@ export interface Preferences { notifyOnIdle: boolean } +// Backwards-compatible alias for older imports. +export type Preferences = UiSettings export interface OpenCodeBinary { - path: string version?: string lastUsed: number + label?: string } export interface RecentFolder { @@ -70,27 +61,53 @@ export interface RecentFolder { lastAccessed: number } -export type ThemePreference = NonNullable +export type ThemePreference = "light" | "dark" | "system" + +interface UiConfigBucket { + theme?: ThemePreference + settings?: Partial +} + +interface ServerConfigBucket { + listeningMode?: ListeningMode + environmentVariables?: Record + opencodeBinary?: string +} + +interface UiStateBucket { + recentFolders?: RecentFolder[] + opencodeBinaries?: OpenCodeBinary[] + models?: { + recents?: ModelPreference[] + favorites?: ModelPreference[] + thinkingSelections?: Record + } +} + +interface NormalizedUiState { + recentFolders: RecentFolder[] + opencodeBinaries: OpenCodeBinary[] + models: { + recents: ModelPreference[] + favorites: ModelPreference[] + thinkingSelections: Record + } +} const MAX_RECENT_FOLDERS = 20 const MAX_RECENT_MODELS = 5 const MAX_FAVORITE_MODELS = 50 -const defaultPreferences: Preferences = { +const defaultUiSettings: UiSettings = { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded", showTimelineTools: true, promptSubmitOnEnter: false, - environmentVariables: {}, - modelRecents: [], - modelFavorites: [], - modelThinkingSelections: {}, diffViewMode: "split", toolOutputExpansion: "expanded", diagnosticsExpansion: "expanded", showUsageMetrics: true, autoCleanupBlankSessions: true, - listeningMode: "local", osNotificationsEnabled: false, osNotificationsAllowWhenVisible: false, @@ -98,382 +115,351 @@ const defaultPreferences: Preferences = { notifyOnIdle: true, } - -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true - if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) { - try { - return JSON.stringify(a) === JSON.stringify(b) - } catch (error) { - log.warn("Failed to compare preference values", error) - } +function normalizeUiSettings(input?: Partial | null): UiSettings { + const sanitized = input ?? {} + return { + showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultUiSettings.showThinkingBlocks, + thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion, + showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools, + promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter, + locale: sanitized.locale ?? defaultUiSettings.locale, + diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode, + toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion, + diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultUiSettings.diagnosticsExpansion, + showUsageMetrics: sanitized.showUsageMetrics ?? defaultUiSettings.showUsageMetrics, + autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, + osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, + osNotificationsAllowWhenVisible: + sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible, + notifyOnNeedsInput: sanitized.notifyOnNeedsInput ?? defaultUiSettings.notifyOnNeedsInput, + notifyOnIdle: sanitized.notifyOnIdle ?? defaultUiSettings.notifyOnIdle, } - return false } -function normalizePreferences(pref?: Partial & { agentModelSelections?: unknown }): Preferences { - const sanitized = pref ?? {} - const environmentVariables = { - ...defaultPreferences.environmentVariables, - ...(sanitized.environmentVariables ?? {}), +function normalizeRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + if (typeof v === "string") out[k] = v } + return out +} - const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents - const modelRecents = sourceModelRecents.map((item) => ({ ...item })) - - const sourceModelFavorites = sanitized.modelFavorites ?? defaultPreferences.modelFavorites - const modelFavorites = sourceModelFavorites.map((item) => ({ ...item })) - - const modelThinkingSelections = { - ...defaultPreferences.modelThinkingSelections, - ...(sanitized.modelThinkingSelections ?? {}), +function cloneArray(value: unknown, mapper: (item: any) => T | null): T[] { + if (!Array.isArray(value)) return [] + const out: T[] = [] + for (const item of value) { + const mapped = mapper(item) + if (mapped) out.push(mapped) } + return out +} +function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { + const source = input ?? {} return { - showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, - thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion, - showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools, - promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter, - lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary, - locale: sanitized.locale ?? defaultPreferences.locale, - environmentVariables, - modelRecents, - modelFavorites, - modelThinkingSelections, - diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode, - toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, - diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, - showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics, - autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions, - listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode, - - osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultPreferences.osNotificationsEnabled, - osNotificationsAllowWhenVisible: - sanitized.osNotificationsAllowWhenVisible ?? defaultPreferences.osNotificationsAllowWhenVisible, - notifyOnNeedsInput: sanitized.notifyOnNeedsInput ?? defaultPreferences.notifyOnNeedsInput, - notifyOnIdle: sanitized.notifyOnIdle ?? defaultPreferences.notifyOnIdle, + recentFolders: cloneArray(source.recentFolders, (f) => { + if (!f || typeof f !== "object") return null + const p = (f as any).path + const lastAccessed = (f as any).lastAccessed + if (typeof p !== "string") return null + const ts = typeof lastAccessed === "number" ? lastAccessed : Date.now() + return { path: p, lastAccessed: ts } + }), + opencodeBinaries: cloneArray(source.opencodeBinaries, (b) => { + if (!b || typeof b !== "object") return null + const p = (b as any).path + if (typeof p !== "string") return null + const lastUsed = typeof (b as any).lastUsed === "number" ? (b as any).lastUsed : Date.now() + const version = typeof (b as any).version === "string" ? (b as any).version : undefined + const label = typeof (b as any).label === "string" ? (b as any).label : undefined + return { path: p, version, label, lastUsed } + }), + models: { + recents: cloneArray((source.models as any)?.recents, (m) => { + if (!m || typeof m !== "object") return null + const providerId = (m as any).providerId + const modelId = (m as any).modelId + if (typeof providerId !== "string" || typeof modelId !== "string") return null + return { providerId, modelId } + }), + favorites: cloneArray((source.models as any)?.favorites, (m) => { + if (!m || typeof m !== "object") return null + const providerId = (m as any).providerId + const modelId = (m as any).modelId + if (typeof providerId !== "string" || typeof modelId !== "string") return null + return { providerId, modelId } + }), + thinkingSelections: normalizeRecord((source.models as any)?.thinkingSelections), + }, } } +function normalizeServerConfig(input?: ServerConfigBucket | null): Required> { + const source = input ?? {} + const listeningMode = source.listeningMode === "all" ? "all" : "local" + const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode" + const environmentVariables = normalizeRecord(source.environmentVariables) + return { listeningMode, opencodeBinary, environmentVariables } +} + function getModelKey(model: { providerId: string; modelId: string }): string { return `${model.providerId}/${model.modelId}` } +function buildRecentFolderList(folderPath: string, source: RecentFolder[]): RecentFolder[] { + const folders = source.filter((f) => f.path !== folderPath) + folders.unshift({ path: folderPath, lastAccessed: Date.now() }) + return folders.slice(0, MAX_RECENT_FOLDERS) +} + +function buildBinaryList(binaryPath: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] { + const timestamp = Date.now() + const existing = source.find((b) => b.path === binaryPath) + if (existing) { + const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp, version: version ?? existing.version } + const remaining = source.filter((b) => b.path !== binaryPath) + return [updatedEntry, ...remaining] + } + const nextEntry: OpenCodeBinary = version + ? { path: binaryPath, version, lastUsed: timestamp } + : { path: binaryPath, lastUsed: timestamp } + return [nextEntry, ...source].slice(0, 10) +} + +const [uiConfigBucket, setUiConfigBucket] = createSignal({}) +const [serverConfigBucket, setServerConfigBucket] = createSignal({}) +const [uiStateBucket, setUiStateBucket] = createSignal({}) +const [isLoaded, setIsLoaded] = createSignal(false) + +const uiSettings = createMemo(() => normalizeUiSettings(uiConfigBucket().settings)) +const themePreference = createMemo(() => uiConfigBucket().theme ?? "system") +const serverSettings = createMemo(() => normalizeServerConfig(serverConfigBucket())) +const uiState = createMemo(() => normalizeUiState(uiStateBucket())) + +const preferences = uiSettings +const recentFolders = createMemo(() => uiState().recentFolders) +const opencodeBinaries = createMemo(() => uiState().opencodeBinaries) + +let loadPromise: Promise | null = null + +async function ensureLoaded(): Promise { + if (isLoaded()) return + if (!loadPromise) { + loadPromise = Promise.all([ + storage.loadConfigOwner("ui"), + storage.loadConfigOwner("server"), + storage.loadStateOwner("ui"), + ]) + .then(([uiCfg, srvCfg, uiSt]) => { + setUiConfigBucket(uiCfg as any) + setServerConfigBucket(srvCfg as any) + setUiStateBucket(uiSt as any) + setIsLoaded(true) + }) + .catch((error) => { + log.error("Failed to load settings", error) + setUiConfigBucket({}) + setServerConfigBucket({}) + setUiStateBucket({}) + setIsLoaded(true) + }) + .finally(() => { + loadPromise = null + }) + } + await loadPromise +} + +async function patchConfigOwner(owner: string, patch: unknown) { + await ensureLoaded() + const updated = await storage.patchConfigOwner(owner, patch) + if (owner === "ui") setUiConfigBucket(updated as any) + if (owner === "server") setServerConfigBucket(updated as any) +} + +async function patchStateOwner(owner: string, patch: unknown) { + await ensureLoaded() + const updated = await storage.patchStateOwner(owner, patch) + if (owner === "ui") setUiStateBucket(updated as any) +} + +function updateUiSettings(updates: Partial) { + const current = uiConfigBucket() + const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates }) + const patch = { settings: nextSettings } + void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error)) +} + +function updatePreferences(updates: Partial): void { + updateUiSettings(updates) +} + +function setThemePreference(preference: ThemePreference): void { + if (themePreference() === preference) return + void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error)) +} + +function setListeningMode(mode: ListeningMode): void { + if (serverSettings().listeningMode === mode) return + void patchConfigOwner("server", { listeningMode: mode }).catch((error) => log.error("Failed to set listening mode", error)) +} + +function updateEnvironmentVariables(envVars: Record): void { + void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) => + log.error("Failed to update environment variables", error), + ) +} + +function addEnvironmentVariable(key: string, value: string): void { + const current = serverSettings().environmentVariables + updateEnvironmentVariables({ ...current, [key]: value }) +} + +function removeEnvironmentVariable(key: string): void { + const current = serverSettings().environmentVariables + const { [key]: removed, ...rest } = current + updateEnvironmentVariables(rest) +} + +function updateLastUsedBinary(path: string): void { + const target = path && path.trim().length > 0 ? path : "opencode" + void patchConfigOwner("server", { opencodeBinary: target }).catch((error) => log.error("Failed to set default binary", error)) + + // also bump lastUsed in state ui.opencodeBinaries + const nextList = buildBinaryList(target, undefined, opencodeBinaries()) + void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error)) +} + +function addOpenCodeBinary(path: string, version?: string): void { + const nextList = buildBinaryList(path, version, opencodeBinaries()) + void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error)) +} + +function removeOpenCodeBinary(path: string): void { + const nextList = opencodeBinaries().filter((b) => b.path !== path) + void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to remove binary", error)) + + if (serverSettings().opencodeBinary === path) { + void patchConfigOwner("server", { opencodeBinary: "opencode" }).catch((error) => + log.error("Failed to reset default binary", error), + ) + } +} + +function addRecentFolder(folderPath: string): void { + const next = buildRecentFolderList(folderPath, recentFolders()) + void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to add recent folder", error)) +} + +function removeRecentFolder(folderPath: string): void { + const next = recentFolders().filter((f) => f.path !== folderPath) + void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to remove recent folder", error)) +} + +function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { + const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : serverSettings().opencodeBinary + const nextFolders = buildRecentFolderList(folderPath, recentFolders()) + const nextBinaries = buildBinaryList(targetBinary, undefined, opencodeBinaries()) + + void patchStateOwner("ui", { recentFolders: nextFolders, opencodeBinaries: nextBinaries }).catch((error) => + log.error("Failed to update ui state on launch", error), + ) + void patchConfigOwner("server", { opencodeBinary: targetBinary }).catch((error) => + log.error("Failed to persist selected binary", error), + ) +} + +function addRecentModelPreference(model: ModelPreference): void { + if (!model.providerId || !model.modelId) return + const recents = uiState().models.recents + const filtered = recents.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) + const updated = [model, ...filtered].slice(0, MAX_RECENT_MODELS) + void patchStateOwner("ui", { models: { recents: updated } }).catch((error) => log.error("Failed to update model recents", error)) +} + function isFavoriteModelPreference(model: ModelPreference): boolean { if (!model.providerId || !model.modelId) return false - return (preferences().modelFavorites ?? []).some( - (item) => item.providerId === model.providerId && item.modelId === model.modelId, - ) + return uiState().models.favorites.some((item) => item.providerId === model.providerId && item.modelId === model.modelId) } function toggleFavoriteModelPreference(model: ModelPreference): void { if (!model.providerId || !model.modelId) return - const favorites = preferences().modelFavorites ?? [] + const favorites = uiState().models.favorites const exists = favorites.some((item) => item.providerId === model.providerId && item.modelId === model.modelId) - if (exists) { - const updated = favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) - updatePreferences({ modelFavorites: updated }) - return - } + const updated = exists + ? favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) + : [model, ...favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId)].slice( + 0, + MAX_FAVORITE_MODELS, + ) - const filtered = favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) - const updated = [model, ...filtered].slice(0, MAX_FAVORITE_MODELS) - updatePreferences({ modelFavorites: updated }) + void patchStateOwner("ui", { models: { favorites: updated } }).catch((error) => log.error("Failed to update model favorites", error)) } function getModelThinkingSelection(model: { providerId: string; modelId: string }): string | undefined { if (!model.providerId || !model.modelId) return undefined - return preferences().modelThinkingSelections?.[getModelKey(model)] + return uiState().models.thinkingSelections[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] + const current = uiState().models.thinkingSelections[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(buildFallbackConfig()) - -const config = createMemo>(() => internalConfig()) -const [isConfigLoaded, setIsConfigLoaded] = createSignal(false) -const preferences = createMemo(() => internalConfig().preferences) -const recentFolders = createMemo(() => internalConfig().recentFolders ?? []) -const opencodeBinaries = createMemo(() => internalConfig().opencodeBinaries ?? []) -const themePreference = createMemo(() => internalConfig().theme ?? "system") -let loadPromise: Promise | null = null - -function normalizeConfig(config?: ConfigData | null): ConfigData { - return { - preferences: normalizePreferences(config?.preferences), - recentFolders: (config?.recentFolders ?? []).map((folder) => ({ ...folder })), - opencodeBinaries: (config?.opencodeBinaries ?? []).map((binary) => ({ ...binary })), - theme: config?.theme ?? "system", + const selections = { ...uiState().models.thinkingSelections } + if (!value) { + delete selections[key] + } else { + selections[key] = value } -} - -function buildFallbackConfig(): ConfigData { - return normalizeConfig() -} - -function removeLegacyAgentSelections(config?: ConfigData | null): { cleaned: ConfigData; migrated: boolean } { - const migrated = Boolean((config?.preferences as { agentModelSelections?: unknown } | undefined)?.agentModelSelections) - const cleanedConfig = normalizeConfig(config) - return { cleaned: cleanedConfig, migrated } -} - -async function syncConfig(source?: ConfigData): Promise { - try { - const loaded = source ?? (await storage.loadConfig()) - const { cleaned, migrated } = removeLegacyAgentSelections(loaded) - applyConfig(cleaned) - if (migrated) { - void storage.updateConfig(cleaned).catch((error: unknown) => { - log.error("Failed to persist legacy config cleanup", error) - }) - } - } catch (error) { - log.error("Failed to load config", error) - applyConfig(buildFallbackConfig()) - } -} - -function applyConfig(next: ConfigData) { - setInternalConfig(normalizeConfig(next)) - setIsConfigLoaded(true) -} - -function cloneConfigForUpdate(): ConfigData { - return normalizeConfig(internalConfig()) -} - -function logConfigDiff(previous: ConfigData, next: ConfigData) { - if (deepEqual(previous, next)) { - return - } - const changes = diffObjects(previous, next) - if (changes.length > 0) { - log.info("[Config] Changes", changes) - } -} - -function diffObjects(previous: unknown, next: unknown, path: string[] = []): string[] { - if (previous === next) { - return [] - } - - if (typeof previous !== "object" || previous === null || typeof next !== "object" || next === null) { - return [path.join(".")] - } - - const prevKeys = Object.keys(previous as Record) - const nextKeys = Object.keys(next as Record) - const allKeys = new Set([...prevKeys, ...nextKeys]) - const changes: string[] = [] - - for (const key of allKeys) { - const childPath = [...path, key] - const prevValue = (previous as Record)[key] - const nextValue = (next as Record)[key] - changes.push(...diffObjects(prevValue, nextValue, childPath)) - } - - return changes -} - -function updateConfig(mutator: (draft: ConfigData) => void): void { - const previous = internalConfig() - const draft = cloneConfigForUpdate() - mutator(draft) - logConfigDiff(previous, draft) - applyConfig(draft) - void persistFullConfig(draft) -} - -async function persistFullConfig(next: ConfigData): Promise { - try { - await ensureConfigLoaded() - await storage.updateConfig(next) - } catch (error) { - log.error("Failed to save config", error) - void syncConfig().catch((syncError: unknown) => { - log.error("Failed to refresh config", syncError) - }) - } -} - -function setThemePreference(preference: ThemePreference): void { - if (themePreference() === preference) { - return - } - updateConfig((draft) => { - draft.theme = preference - }) -} - -async function ensureConfigLoaded(): Promise { - if (isConfigLoaded()) return - if (!loadPromise) { - loadPromise = syncConfig().finally(() => { - loadPromise = null - }) - } - await loadPromise -} - -function buildRecentFolderList(path: string, source: RecentFolder[]): RecentFolder[] { - const folders = source.filter((f) => f.path !== path) - folders.unshift({ path, lastAccessed: Date.now() }) - return folders.slice(0, MAX_RECENT_FOLDERS) -} - -function buildBinaryList(path: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] { - const timestamp = Date.now() - const existing = source.find((b) => b.path === path) - if (existing) { - const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp } - const remaining = source.filter((b) => b.path !== path) - return [updatedEntry, ...remaining] - } - const nextEntry: OpenCodeBinary = version ? { path, version, lastUsed: timestamp } : { path, lastUsed: timestamp } - return [nextEntry, ...source].slice(0, 10) -} - -function updatePreferences(updates: Partial): void { - const current = internalConfig().preferences - const merged = normalizePreferences({ ...current, ...updates }) - if (deepEqual(current, merged)) { - return - } - updateConfig((draft) => { - draft.preferences = merged - }) -} - -function setListeningMode(mode: ListeningMode): void { - if (preferences().listeningMode === mode) return - updatePreferences({ listeningMode: mode }) + void patchStateOwner("ui", { models: { thinkingSelections: selections } }).catch((error) => + log.error("Failed to update thinking selection", error), + ) } function setDiffViewMode(mode: DiffViewMode): void { if (preferences().diffViewMode === mode) return - updatePreferences({ diffViewMode: mode }) + updateUiSettings({ diffViewMode: mode }) } function setToolOutputExpansion(mode: ExpansionPreference): void { if (preferences().toolOutputExpansion === mode) return - updatePreferences({ toolOutputExpansion: mode }) + updateUiSettings({ toolOutputExpansion: mode }) } function setDiagnosticsExpansion(mode: ExpansionPreference): void { if (preferences().diagnosticsExpansion === mode) return - updatePreferences({ diagnosticsExpansion: mode }) + updateUiSettings({ diagnosticsExpansion: mode }) } function setThinkingBlocksExpansion(mode: ExpansionPreference): void { if (preferences().thinkingBlocksExpansion === mode) return - updatePreferences({ thinkingBlocksExpansion: mode }) + updateUiSettings({ thinkingBlocksExpansion: mode }) } function toggleShowThinkingBlocks(): void { - updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks }) + updateUiSettings({ showThinkingBlocks: !preferences().showThinkingBlocks }) } function toggleShowTimelineTools(): void { - updatePreferences({ showTimelineTools: !preferences().showTimelineTools }) + updateUiSettings({ showTimelineTools: !preferences().showTimelineTools }) } function toggleUsageMetrics(): void { - updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics }) + updateUiSettings({ showUsageMetrics: !preferences().showUsageMetrics }) } function togglePromptSubmitOnEnter(): void { - updatePreferences({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter }) + updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter }) } function toggleAutoCleanupBlankSessions(): void { const nextValue = !preferences().autoCleanupBlankSessions log.info("toggle auto cleanup", { value: nextValue }) - updatePreferences({ autoCleanupBlankSessions: nextValue }) -} - -function addRecentFolder(path: string): void { - updateConfig((draft) => { - draft.recentFolders = buildRecentFolderList(path, draft.recentFolders) - }) -} - -function removeRecentFolder(path: string): void { - updateConfig((draft) => { - draft.recentFolders = draft.recentFolders.filter((f) => f.path !== path) - }) -} - -function addOpenCodeBinary(path: string, version?: string): void { - updateConfig((draft) => { - draft.opencodeBinaries = buildBinaryList(path, version, draft.opencodeBinaries) - }) -} - -function removeOpenCodeBinary(path: string): void { - updateConfig((draft) => { - draft.opencodeBinaries = draft.opencodeBinaries.filter((b) => b.path !== path) - }) -} - -function updateLastUsedBinary(path: string): void { - const target = path || preferences().lastUsedBinary || "opencode" - updateConfig((draft) => { - draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: target }) - draft.opencodeBinaries = buildBinaryList(target, undefined, draft.opencodeBinaries) - }) -} - -function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { - updateConfig((draft) => { - const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : draft.preferences.lastUsedBinary || "opencode" - draft.recentFolders = buildRecentFolderList(folderPath, draft.recentFolders) - draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: targetBinary }) - draft.opencodeBinaries = buildBinaryList(targetBinary, undefined, draft.opencodeBinaries) - }) -} - -function updateEnvironmentVariables(envVars: Record): void { - updatePreferences({ environmentVariables: envVars }) -} - -function addEnvironmentVariable(key: string, value: string): void { - const current = preferences().environmentVariables || {} - const updated = { ...current, [key]: value } - updateEnvironmentVariables(updated) -} - -function removeEnvironmentVariable(key: string): void { - const current = preferences().environmentVariables || {} - const { [key]: removed, ...rest } = current - updateEnvironmentVariables(rest) -} - -function addRecentModelPreference(model: ModelPreference): void { - if (!model.providerId || !model.modelId) return - const recents = preferences().modelRecents ?? [] - const filtered = recents.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) - const updated = [model, ...filtered].slice(0, MAX_RECENT_MODELS) - updatePreferences({ modelRecents: updated }) + updateUiSettings({ autoCleanupBlankSessions: nextValue }) } async function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): Promise { @@ -497,41 +483,52 @@ async function getAgentModelPreference(instanceId: string, agent: string): Promi return selections[agent] } -void ensureConfigLoaded().catch((error: unknown) => { - log.error("Failed to initialize config", error) +void ensureLoaded().catch((error: unknown) => { + log.error("Failed to initialize settings", error) }) interface ConfigContextValue { isLoaded: Accessor - config: typeof config preferences: typeof preferences - recentFolders: typeof recentFolders - opencodeBinaries: typeof opencodeBinaries + updatePreferences: typeof updatePreferences themePreference: typeof themePreference setThemePreference: typeof setThemePreference - updateConfig: typeof updateConfig + + // server-owned stable config + serverSettings: typeof serverSettings + setListeningMode: typeof setListeningMode + updateEnvironmentVariables: typeof updateEnvironmentVariables + addEnvironmentVariable: typeof addEnvironmentVariable + removeEnvironmentVariable: typeof removeEnvironmentVariable + updateLastUsedBinary: typeof updateLastUsedBinary + + // ui-owned state + recentFolders: typeof recentFolders + opencodeBinaries: typeof opencodeBinaries + uiState: typeof uiState + addRecentFolder: typeof addRecentFolder + removeRecentFolder: typeof removeRecentFolder + addOpenCodeBinary: typeof addOpenCodeBinary + removeOpenCodeBinary: typeof removeOpenCodeBinary + recordWorkspaceLaunch: typeof recordWorkspaceLaunch + addRecentModelPreference: typeof addRecentModelPreference + isFavoriteModelPreference: typeof isFavoriteModelPreference + toggleFavoriteModelPreference: typeof toggleFavoriteModelPreference + getModelThinkingSelection: typeof getModelThinkingSelection + setModelThinkingSelection: typeof setModelThinkingSelection + + // ui settings helpers toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks toggleShowTimelineTools: typeof toggleShowTimelineTools toggleUsageMetrics: typeof toggleUsageMetrics toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter - setDiffViewMode: typeof setDiffViewMode setToolOutputExpansion: typeof setToolOutputExpansion setDiagnosticsExpansion: typeof setDiagnosticsExpansion setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion - setListeningMode: typeof setListeningMode - addRecentFolder: typeof addRecentFolder - removeRecentFolder: typeof removeRecentFolder - addOpenCodeBinary: typeof addOpenCodeBinary - removeOpenCodeBinary: typeof removeOpenCodeBinary - updateLastUsedBinary: typeof updateLastUsedBinary - recordWorkspaceLaunch: typeof recordWorkspaceLaunch - updatePreferences: typeof updatePreferences - updateEnvironmentVariables: typeof updateEnvironmentVariables - addEnvironmentVariable: typeof addEnvironmentVariable - removeEnvironmentVariable: typeof removeEnvironmentVariable - addRecentModelPreference: typeof addRecentModelPreference + + // instance scoped setAgentModelPreference: typeof setAgentModelPreference getAgentModelPreference: typeof getAgentModelPreference } @@ -539,14 +536,30 @@ interface ConfigContextValue { const ConfigContext = createContext() const configContextValue: ConfigContextValue = { - isLoaded: isConfigLoaded, - config, + isLoaded, preferences, - recentFolders, - opencodeBinaries, + updatePreferences, themePreference, setThemePreference, - updateConfig, + serverSettings, + setListeningMode, + updateEnvironmentVariables, + addEnvironmentVariable, + removeEnvironmentVariable, + updateLastUsedBinary, + recentFolders, + opencodeBinaries, + uiState, + addRecentFolder, + removeRecentFolder, + addOpenCodeBinary, + removeOpenCodeBinary, + recordWorkspaceLaunch, + addRecentModelPreference, + isFavoriteModelPreference, + toggleFavoriteModelPreference, + getModelThinkingSelection, + setModelThinkingSelection, toggleShowThinkingBlocks, toggleShowTimelineTools, toggleUsageMetrics, @@ -556,43 +569,40 @@ const configContextValue: ConfigContextValue = { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, - setListeningMode, - addRecentFolder, - removeRecentFolder, - addOpenCodeBinary, - removeOpenCodeBinary, - updateLastUsedBinary, - recordWorkspaceLaunch, - updatePreferences, - updateEnvironmentVariables, - addEnvironmentVariable, - removeEnvironmentVariable, - addRecentModelPreference, setAgentModelPreference, getAgentModelPreference, } -const ConfigProvider: ParentComponent = (props) => { +export const ConfigProvider: ParentComponent = (props) => { onMount(() => { - ensureConfigLoaded().catch((error: unknown) => { - log.error("Failed to initialize config", error) + ensureLoaded().catch((error: unknown) => { + log.error("Failed to initialize settings", error) }) - const unsubscribe = storage.onConfigChanged((config) => { - syncConfig(config).catch((error: unknown) => { - log.error("Failed to refresh config", error) - }) + const unsubUi = storage.onConfigOwnerChanged("ui", (bucket) => { + setUiConfigBucket(bucket as any) + setIsLoaded(true) + }) + const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => { + setServerConfigBucket(bucket as any) + setIsLoaded(true) + }) + const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => { + setUiStateBucket(bucket as any) + setIsLoaded(true) }) return () => { - unsubscribe() + unsubUi() + unsubServer() + unsubStateUi() } }) return {props.children} } -function useConfig(): ConfigContextValue { +export function useConfig(): ConfigContextValue { const context = useContext(ConfigContext) if (!context) { throw new Error("useConfig must be used within ConfigProvider") @@ -601,41 +611,38 @@ function useConfig(): ConfigContextValue { } export { - ConfigProvider, - useConfig, - config, preferences, - updateConfig, - updatePreferences, - toggleShowThinkingBlocks, - toggleShowTimelineTools, - toggleAutoCleanupBlankSessions, - toggleUsageMetrics, - togglePromptSubmitOnEnter, + uiState, + serverSettings, recentFolders, - addRecentFolder, - removeRecentFolder, opencodeBinaries, - addOpenCodeBinary, - removeOpenCodeBinary, - updateLastUsedBinary, + themePreference, + setThemePreference, + updatePreferences, + setListeningMode, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, + updateLastUsedBinary, + addRecentFolder, + removeRecentFolder, + addOpenCodeBinary, + removeOpenCodeBinary, + recordWorkspaceLaunch, addRecentModelPreference, isFavoriteModelPreference, toggleFavoriteModelPreference, getModelThinkingSelection, setModelThinkingSelection, - setAgentModelPreference, - getAgentModelPreference, + toggleShowThinkingBlocks, + toggleShowTimelineTools, + toggleUsageMetrics, + toggleAutoCleanupBlankSessions, + togglePromptSubmitOnEnter, setDiffViewMode, setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, - setListeningMode, - themePreference, - setThemePreference, - recordWorkspaceLaunch, - } - + setAgentModelPreference, + getAgentModelPreference, +} diff --git a/packages/ui/src/stores/session-models.ts b/packages/ui/src/stores/session-models.ts index d03fd423..919e8a9e 100644 --- a/packages/ui/src/stores/session-models.ts +++ b/packages/ui/src/stores/session-models.ts @@ -1,5 +1,5 @@ import { agents, providers } from "./session-state" -import { preferences, getAgentModelPreference } from "./preferences" +import { uiState, getAgentModelPreference } from "./preferences" const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000 @@ -17,7 +17,7 @@ function isModelValid( function getRecentModelPreferenceForInstance( instanceId: string, ): { providerId: string; modelId: string } | undefined { - const recents = preferences().modelRecents ?? [] + const recents = uiState().models.recents ?? [] for (const item of recents) { if (isModelValid(instanceId, item)) { return item From 1ef01da0199eb47907d6640987bdfd908be76ebe Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 13 Feb 2026 22:52:42 +0000 Subject: [PATCH 19/34] feat(ui): improve picker actions and directory attach --- .../prompt-input/usePromptPicker.ts | 197 ++++++++++++------ packages/ui/src/components/unified-picker.tsx | 15 +- 2 files changed, 143 insertions(+), 69 deletions(-) diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts index ada32cc9..98e53bc6 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -1,9 +1,10 @@ import { createSignal, type Accessor, type Setter } from "solid-js" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { Agent } from "../../types/session" -import { createAgentAttachment, createFileAttachment } from "../../types/attachment" +import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment" import { addAttachment, getAttachments } from "../../stores/attachments" import type { PickerMode } from "./types" +import type { PickerSelectAction } from "../unified-picker" type PickerItem = | { type: "agent"; agent: Agent } @@ -37,7 +38,7 @@ type PromptPickerController = { setIgnoredAtPositions: Setter> handleInput: (e: Event) => void - handlePickerSelect: (item: PickerItem) => void + handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void handlePickerClose: () => void } @@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr setAtPosition(null) } - function handlePickerSelect(item: PickerItem) { + function handlePickerSelect(item: PickerItem, action: PickerSelectAction) { const textarea = options.getTextarea() if (item.type === "command") { + // For commands, Tab/Enter/Shift+Enter/click all mean "select". const name = item.command.name const currentPrompt = options.prompt() @@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr } }, 0) } else if (item.type === "agent") { + // For agents, Tab/Enter/Shift+Enter/click all mean "select". const agentName = item.agent.name const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) const alreadyAttached = existingAttachments.some( @@ -163,76 +166,144 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const relativePath = item.file.relativePath ?? displayPath const isFolder = item.file.isDirectory ?? displayPath.endsWith("/") - if (isFolder) { - const currentPrompt = options.prompt() - const pos = atPosition() - const cursorPos = textarea?.selectionStart || 0 - const folderMention = - relativePath === "." || relativePath === "" - ? "/" - : relativePath.replace(/\/+$/, "") + "/" - - if (pos !== null) { - const before = currentPrompt.substring(0, pos + 1) - const after = currentPrompt.substring(cursorPos) - const newPrompt = before + folderMention + after - options.setPrompt(newPrompt) - setSearchQuery(folderMention) - - setTimeout(() => { - const nextTextarea = options.getTextarea() - if (nextTextarea) { - const newCursorPos = pos + 1 + folderMention.length - nextTextarea.setSelectionRange(newCursorPos, newCursorPos) - } - }, 0) - } - - return - } - - const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath - const pathSegments = normalizedPath.split("/") - const filename = (() => { - const candidate = pathSegments[pathSegments.length - 1] || normalizedPath - return candidate === "." ? "/" : candidate - })() - - const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) - const alreadyAttached = existingAttachments.some( - (att) => att.source.type === "file" && att.source.path === normalizedPath, - ) - - if (!alreadyAttached) { - const attachment = createFileAttachment( - normalizedPath, - filename, - "text/plain", - undefined, - options.instanceFolder(), - ) - addAttachment(options.instanceId(), options.sessionId(), attachment) - } - - const currentPrompt = options.prompt() const pos = atPosition() const cursorPos = textarea?.selectionStart || 0 - if (pos !== null) { + const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => { + if (pos === null) return + const currentPrompt = options.prompt() const before = currentPrompt.substring(0, pos) const after = currentPrompt.substring(cursorPos) - const attachmentText = `@${normalizedPath}` - const newPrompt = before + attachmentText + " " + after - options.setPrompt(newPrompt) + const suffix = opts?.trailingSpace ? " " : "" + const nextPrompt = before + mentionText + suffix + after + options.setPrompt(nextPrompt) setTimeout(() => { const nextTextarea = options.getTextarea() - if (nextTextarea) { - const newCursorPos = pos + attachmentText.length + 1 - nextTextarea.setSelectionRange(newCursorPos, newCursorPos) - } + if (!nextTextarea) return + const nextCursorPos = pos + mentionText.length + suffix.length + nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos) }, 0) } + + const replaceMentionQueryAfterAt = (value: string) => { + // Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation. + if (pos === null) return + const currentPrompt = options.prompt() + const before = currentPrompt.substring(0, pos + 1) + const after = currentPrompt.substring(cursorPos) + const nextPrompt = before + value + after + options.setPrompt(nextPrompt) + + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (!nextTextarea) return + const nextCursorPos = pos + 1 + value.length + nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos) + }, 0) + } + + const folderMention = + relativePath === "." || relativePath === "" + ? "/" + : relativePath.replace(/\/+$/, "") + "/" + + const normalizedFolderPath = (() => { + const trimmed = relativePath.replace(/\/+$/, "") + return trimmed.length > 0 ? trimmed : "." + })() + + const addPathOnlyAttachment = (value: string) => { + const display = `path: ${value}` + const filename = value + const existing = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existing.some( + (att) => att.source.type === "text" && att.source.value === value && att.display === display, + ) + if (!alreadyAttached) { + addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename)) + } + } + + if (isFolder) { + if (action === "tab") { + // TAB on directory: autocomplete directory name and show its contents. + replaceMentionQueryAfterAt(folderMention) + setSearchQuery(folderMention) + return + } + + const mentionText = `@${folderMention}` + + if (action === "shiftEnter") { + // SHIFT+ENTER on directory: attach path as text only. + addPathOnlyAttachment(folderMention) + replaceMentionToken(mentionText, { trailingSpace: true }) + } else { + // ENTER/click on directory: attach as a file part pointing at a file:// directory URL. + const dirLabel = + normalizedFolderPath === "." ? "/" : normalizedFolderPath.split("/").pop() || normalizedFolderPath + const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/` + + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory", + ) + + if (!alreadyAttached) { + const attachment = createFileAttachment( + normalizedFolderPath, + dirFilename, + "inode/directory", + undefined, + options.instanceFolder(), + ) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + replaceMentionToken(mentionText, { trailingSpace: true }) + } + } else { + const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath + + if (action === "tab") { + // TAB on file: autocomplete the file path but do not attach. + replaceMentionToken(`@${normalizedPath}`) + setSearchQuery(normalizedPath) + return + } + + if (action === "shiftEnter") { + // SHIFT+ENTER on file: attach path as text only. + addPathOnlyAttachment(normalizedPath) + replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) + } else { + // ENTER/click on file: attach file (existing behavior). + const pathSegments = normalizedPath.split("/") + const filename = (() => { + const candidate = pathSegments[pathSegments.length - 1] || normalizedPath + return candidate === "." ? "/" : candidate + })() + + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedPath, + ) + + if (!alreadyAttached) { + const attachment = createFileAttachment( + normalizedPath, + filename, + "text/plain", + undefined, + options.instanceFolder(), + ) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) + } + } } setShowPicker(false) diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 0abc00be..7284d145 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -74,10 +74,12 @@ type PickerItem = | { type: "file"; file: FileItem } | { type: "command"; command: SDKCommand } +export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter" + interface UnifiedPickerProps { open: boolean mode?: "mention" | "command" - onSelect: (item: PickerItem) => void + onSelect: (item: PickerItem, action: PickerSelectAction) => void onClose: () => void agents: Agent[] commands?: SDKCommand[] @@ -356,7 +358,7 @@ const UnifiedPicker: Component = (props) => { } function handleSelect(item: PickerItem) { - props.onSelect(item) + props.onSelect(item, "click") } function handleKeyDown(e: KeyboardEvent) { @@ -379,7 +381,8 @@ const UnifiedPicker: Component = (props) => { e.stopPropagation() const selected = items[selectedIndex()] if (selected) { - handleSelect(selected) + const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter" + props.onSelect(selected, action) } } else if (e.key === "Escape") { e.preventDefault() @@ -443,7 +446,7 @@ const UnifiedPicker: Component = (props) => {
handleSelect({ type: "command", command })} + onClick={() => props.onSelect({ type: "command", command }, "click")} >
@@ -479,7 +482,7 @@ const UnifiedPicker: Component = (props) => { itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" }`} data-picker-selected={itemIndex === selectedIndex()} - onClick={() => handleSelect({ type: "agent", agent })} + onClick={() => props.onSelect({ type: "agent", agent }, "click")} >
= (props) => { itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" }`} data-picker-selected={itemIndex === selectedIndex()} - onClick={() => handleSelect({ type: "file", file })} + onClick={() => props.onSelect({ type: "file", file }, "click")} >
Date: Fri, 13 Feb 2026 23:44:28 +0000 Subject: [PATCH 20/34] fix(ui): add keyboard shortcut hints toggle Hide shortcut hints in WebUI and allow toggling in native desktop apps. --- packages/ui/src/App.tsx | 9 ++++++++ .../ui/src/components/command-palette.tsx | 13 +++++++++-- .../components/filesystem-browser-dialog.tsx | 2 +- .../src/components/folder-selection-view.tsx | 6 ++--- packages/ui/src/components/hint-row.tsx | 2 +- .../src/components/instance-welcome-view.tsx | 4 ++-- .../components/instance/instance-shell2.tsx | 4 ++-- .../ui/src/components/message-list-header.tsx | 2 +- .../ui/src/components/message-section.tsx | 2 +- packages/ui/src/components/prompt-input.tsx | 2 +- packages/ui/src/components/session-picker.tsx | 2 +- packages/ui/src/lib/commands.ts | 1 + packages/ui/src/lib/hooks/use-commands.ts | 22 +++++++++++++++++++ .../ui/src/lib/i18n/messages/en/commands.ts | 6 +++++ .../ui/src/lib/i18n/messages/es/commands.ts | 6 +++++ .../ui/src/lib/i18n/messages/fr/commands.ts | 6 +++++ .../ui/src/lib/i18n/messages/ja/commands.ts | 6 +++++ .../ui/src/lib/i18n/messages/ru/commands.ts | 6 +++++ .../src/lib/i18n/messages/zh-Hans/commands.ts | 6 +++++ packages/ui/src/stores/preferences.tsx | 10 +++++++++ packages/ui/src/styles/panels/modal.css | 5 +++++ packages/ui/src/styles/utilities.css | 13 +++++++++++ 22 files changed, 120 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index f4092ad3..0f2a85ff 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -60,6 +60,7 @@ const App: Component = () => { preferences, recordWorkspaceLaunch, toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, toggleShowTimelineTools, toggleAutoCleanupBlankSessions, toggleUsageMetrics, @@ -80,6 +81,13 @@ const App: Component = () => { const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) + createEffect(() => { + if (typeof document === "undefined") return + const shouldShow = + runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true) + document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide" + }) + const updateInstanceTabBarHeight = () => { if (typeof document === "undefined") return const element = document.querySelector(".tab-bar-instance") @@ -293,6 +301,7 @@ const App: Component = () => { preferences, toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, toggleShowTimelineTools, toggleUsageMetrics, togglePromptSubmitOnEnter, diff --git a/packages/ui/src/components/command-palette.tsx b/packages/ui/src/components/command-palette.tsx index 36416617..87947b5a 100644 --- a/packages/ui/src/components/command-palette.tsx +++ b/packages/ui/src/components/command-palette.tsx @@ -112,6 +112,10 @@ const CommandPalette: Component = (props) => { const groupedCommandList = () => processedCommands().groups const orderedCommands = () => processedCommands().ordered + + const isCommandDisabled = (command: Command) => { + return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false + } const selectedIndex = createMemo(() => { const ordered = orderedCommands() if (ordered.length === 0) return -1 @@ -138,10 +142,11 @@ const CommandPalette: Component = (props) => { } return } - + const currentId = selectedCommandId() if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) { - setSelectedCommandId(ordered[0].id) + const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd)) + setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null) } }) @@ -195,12 +200,14 @@ const CommandPalette: Component = (props) => { if (index < 0 || index >= ordered.length) return const command = ordered[index] if (!command) return + if (isCommandDisabled(command)) return props.onExecute(command) props.onClose() } } function handleCommandClick(command: Command) { + if (isCommandDisabled(command)) return props.onExecute(command) props.onClose() } @@ -265,11 +272,13 @@ const CommandPalette: Component = (props) => { {(command, localIndex) => { const commandIndex = group.startIndex + localIndex() + const disabled = isCommandDisabled(command) return (
- @@ -539,7 +539,7 @@ const InstanceWelcomeView: Component = (props) => {
- -
+
= (props) => {
- + Cmd+Enter diff --git a/packages/ui/src/lib/commands.ts b/packages/ui/src/lib/commands.ts index a38b2fec..f158da5f 100644 --- a/packages/ui/src/lib/commands.ts +++ b/packages/ui/src/lib/commands.ts @@ -18,6 +18,7 @@ export interface Command { description: Resolvable keywords?: Resolvable shortcut?: KeyboardShortcut + disabled?: Resolvable action: () => void | Promise category?: Resolvable } diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index bdd2e8d5..c89a95d4 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -14,6 +14,7 @@ import { getLogger } from "../logger" import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" +import { runtimeEnv } from "../runtime-env" const log = getLogger("actions") @@ -28,6 +29,7 @@ function splitKeywords(key: string): string[] { export interface UseCommandsOptions { preferences: Accessor toggleShowThinkingBlocks: () => void + toggleKeyboardShortcutHints: () => void toggleShowTimelineTools: () => void toggleUsageMetrics: () => void toggleAutoCleanupBlankSessions: () => void @@ -454,6 +456,26 @@ export function useCommands(options: UseCommandsOptions) { action: options.toggleShowTimelineTools, }) + commandRegistry.register({ + id: "keyboard-shortcut-hints", + label: () => + tGlobal( + options.preferences().showKeyboardShortcutHints + ? "commands.keyboardShortcutHints.label.hide" + : "commands.keyboardShortcutHints.label.show", + ), + description: () => + tGlobal( + runtimeEnv.host === "web" + ? "commands.keyboardShortcutHints.description.disabledWeb" + : "commands.keyboardShortcutHints.description", + ), + category: "System", + keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"), + disabled: () => runtimeEnv.host === "web", + action: options.toggleKeyboardShortcutHints, + }) + commandRegistry.register({ id: "thinking-default-visibility", label: () => { diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index 66ff78f7..5b26c0c9 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline", "commands.timelineToolCalls.keywords": "timeline, tool, toggle", + "commands.keyboardShortcutHints.label.show": "Show Keyboard Shortcut Hints", + "commands.keyboardShortcutHints.label.hide": "Hide Keyboard Shortcut Hints", + "commands.keyboardShortcutHints.description": "Show or hide keyboard shortcut hints across the UI", + "commands.keyboardShortcutHints.description.disabledWeb": "Disabled in WebUI (shortcut hints are always hidden)", + "commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, hints", + "commands.common.expanded": "Expanded", "commands.common.collapsed": "Collapsed", "commands.common.visible": "Visible", diff --git a/packages/ui/src/lib/i18n/messages/es/commands.ts b/packages/ui/src/lib/i18n/messages/es/commands.ts index 0bad4e2f..c6a75e7e 100644 --- a/packages/ui/src/lib/i18n/messages/es/commands.ts +++ b/packages/ui/src/lib/i18n/messages/es/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes", "commands.timelineToolCalls.keywords": "línea de tiempo, herramienta, alternar", + "commands.keyboardShortcutHints.label.show": "Mostrar atajos de teclado", + "commands.keyboardShortcutHints.label.hide": "Ocultar atajos de teclado", + "commands.keyboardShortcutHints.description": "Mostrar u ocultar sugerencias de atajos de teclado en la interfaz", + "commands.keyboardShortcutHints.description.disabledWeb": "Desactivado en WebUI (los atajos siempre se ocultan)", + "commands.keyboardShortcutHints.keywords": "atajo, atajos, teclado, keybind, pistas", + "commands.common.expanded": "Expandido", "commands.common.collapsed": "Colapsado", "commands.common.visible": "Visible", diff --git a/packages/ui/src/lib/i18n/messages/fr/commands.ts b/packages/ui/src/lib/i18n/messages/fr/commands.ts index 52bdea76..63e7c666 100644 --- a/packages/ui/src/lib/i18n/messages/fr/commands.ts +++ b/packages/ui/src/lib/i18n/messages/fr/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages", "commands.timelineToolCalls.keywords": "timeline, outil, basculer", + "commands.keyboardShortcutHints.label.show": "Afficher les raccourcis clavier", + "commands.keyboardShortcutHints.label.hide": "Masquer les raccourcis clavier", + "commands.keyboardShortcutHints.description": "Afficher ou masquer les indices de raccourcis clavier dans l'interface", + "commands.keyboardShortcutHints.description.disabledWeb": "Désactivé en WebUI (les raccourcis sont toujours masqués)", + "commands.keyboardShortcutHints.keywords": "raccourci, raccourcis, clavier, keybind, indices", + "commands.common.expanded": "Développé", "commands.common.collapsed": "Réduit", "commands.common.visible": "Visible", diff --git a/packages/ui/src/lib/i18n/messages/ja/commands.ts b/packages/ui/src/lib/i18n/messages/ja/commands.ts index 30a2adc5..75c1c5f3 100644 --- a/packages/ui/src/lib/i18n/messages/ja/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ja/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え", "commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle", + "commands.keyboardShortcutHints.label.show": "キーボードショートカットのヒントを表示", + "commands.keyboardShortcutHints.label.hide": "キーボードショートカットのヒントを非表示", + "commands.keyboardShortcutHints.description": "UI 全体のキーボードショートカットヒントを表示/非表示", + "commands.keyboardShortcutHints.description.disabledWeb": "WebUI では無効(ヒントは常に非表示)", + "commands.keyboardShortcutHints.keywords": "ショートカット, キーボード, ヒント, shortcuts, keyboard, hints", + "commands.common.expanded": "展開", "commands.common.collapsed": "折りたたみ", "commands.common.visible": "表示", diff --git a/packages/ui/src/lib/i18n/messages/ru/commands.ts b/packages/ui/src/lib/i18n/messages/ru/commands.ts index 6c3f28ec..068f020d 100644 --- a/packages/ui/src/lib/i18n/messages/ru/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ru/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений", "commands.timelineToolCalls.keywords": "таймлайн, tool, переключить", + "commands.keyboardShortcutHints.label.show": "Показать подсказки сочетаний", + "commands.keyboardShortcutHints.label.hide": "Скрыть подсказки сочетаний", + "commands.keyboardShortcutHints.description": "Показать или скрыть подсказки сочетаний клавиш в интерфейсе", + "commands.keyboardShortcutHints.description.disabledWeb": "Отключено в WebUI (подсказки всегда скрыты)", + "commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, подсказки", + "commands.common.expanded": "Развернуто", "commands.common.collapsed": "Свернуто", "commands.common.visible": "Видимо", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts index 9c95f63e..85997488 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目", "commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换", + "commands.keyboardShortcutHints.label.show": "显示键盘快捷键提示", + "commands.keyboardShortcutHints.label.hide": "隐藏键盘快捷键提示", + "commands.keyboardShortcutHints.description": "显示或隐藏界面中的键盘快捷键提示", + "commands.keyboardShortcutHints.description.disabledWeb": "WebUI 中已禁用(提示始终隐藏)", + "commands.keyboardShortcutHints.keywords": "shortcuts, keyboard, hints, 快捷键, 键盘, 提示", + "commands.common.expanded": "展开", "commands.common.collapsed": "折叠", "commands.common.visible": "可见", diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 5907e78e..11298811 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -34,6 +34,7 @@ export type ListeningMode = "local" | "all" export interface Preferences { showThinkingBlocks: boolean + showKeyboardShortcutHints: boolean thinkingBlocksExpansion: ExpansionPreference showTimelineTools: boolean promptSubmitOnEnter: boolean @@ -78,6 +79,7 @@ const MAX_FAVORITE_MODELS = 50 const defaultPreferences: Preferences = { showThinkingBlocks: false, + showKeyboardShortcutHints: true, thinkingBlocksExpansion: "expanded", showTimelineTools: true, promptSubmitOnEnter: false, @@ -131,6 +133,7 @@ function normalizePreferences(pref?: Partial & { agentModelSelectio return { showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, + showKeyboardShortcutHints: sanitized.showKeyboardShortcutHints ?? defaultPreferences.showKeyboardShortcutHints, thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion, showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools, promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter, @@ -393,6 +396,10 @@ function toggleShowThinkingBlocks(): void { updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks }) } +function toggleKeyboardShortcutHints(): void { + updatePreferences({ showKeyboardShortcutHints: !preferences().showKeyboardShortcutHints }) +} + function toggleShowTimelineTools(): void { updatePreferences({ showTimelineTools: !preferences().showTimelineTools }) } @@ -511,6 +518,7 @@ interface ConfigContextValue { setThemePreference: typeof setThemePreference updateConfig: typeof updateConfig toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks + toggleKeyboardShortcutHints: typeof toggleKeyboardShortcutHints toggleShowTimelineTools: typeof toggleShowTimelineTools toggleUsageMetrics: typeof toggleUsageMetrics toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions @@ -548,6 +556,7 @@ const configContextValue: ConfigContextValue = { setThemePreference, updateConfig, toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, toggleShowTimelineTools, toggleUsageMetrics, toggleAutoCleanupBlankSessions, @@ -608,6 +617,7 @@ export { updateConfig, updatePreferences, toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, toggleShowTimelineTools, toggleAutoCleanupBlankSessions, toggleUsageMetrics, diff --git a/packages/ui/src/styles/panels/modal.css b/packages/ui/src/styles/panels/modal.css index 2fa598b5..4edc3b81 100644 --- a/packages/ui/src/styles/panels/modal.css +++ b/packages/ui/src/styles/panels/modal.css @@ -46,6 +46,11 @@ color: var(--text-primary); } +.modal-item:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .modal-list-container[data-pointer-mode="pointer"] .modal-item:hover { background-color: var(--surface-hover); } diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 230f9bd0..44aaa1fd 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -153,6 +153,19 @@ @apply opacity-50; } +/* + Shortcut hints are useful on desktop native apps, but are noisy/irrelevant on + touch-first devices and in WebUI where browser shortcuts often conflict. +*/ +html[data-runtime-host="web"] .keyboard-hints, +html[data-runtime-host="web"] .kbd-hint, +html[data-runtime-platform="mobile"] .keyboard-hints, +html[data-runtime-platform="mobile"] .kbd-hint, +html[data-keyboard-hints="hide"] .keyboard-hints, +html[data-keyboard-hints="hide"] .kbd-hint { + display: none !important; +} + /* Truncate from the start (keeps end visible; good for paths) */ .truncate-start { overflow: hidden; From 5067db3dd0024553204bd41a50e416a133a7d6b1 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 15 Feb 2026 00:54:31 +0000 Subject: [PATCH 21/34] fix(ui): handle message.part.delta streaming Wire message.part.delta SSE events into the v2 message store and append deltas onto existing part fields. --- packages/ui/src/lib/sse-manager.ts | 6 +++ packages/ui/src/stores/message-v2/bridge.ts | 16 ++++++ .../src/stores/message-v2/instance-store.ts | 50 +++++++++++++++++-- packages/ui/src/stores/session-events.ts | 11 ++++ packages/ui/src/stores/sessions.ts | 2 + packages/ui/src/types/message.ts | 13 +++++ 6 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index d777ef38..77a4fdb4 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -4,6 +4,7 @@ import { MessageRemovedEvent, MessagePartUpdatedEvent, MessagePartRemovedEvent, + MessagePartDeltaEvent, } from "../types/message" import type { EventLspUpdated, @@ -58,6 +59,7 @@ type SSEEvent = | MessageRemovedEvent | MessagePartUpdatedEvent | MessagePartRemovedEvent + | MessagePartDeltaEvent | EventSessionUpdated | EventSessionCompacted | EventSessionDiff @@ -118,6 +120,9 @@ class SSEManager { case "message.part.updated": this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent) break + case "message.part.delta": + this.onMessagePartDelta?.(instanceId, event as MessagePartDeltaEvent) + break case "message.removed": this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent) break @@ -184,6 +189,7 @@ class SSEManager { onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void + onMessagePartDelta?: (instanceId: string, event: MessagePartDeltaEvent) => void onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 65e20bd8..af22d94f 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -104,6 +104,22 @@ export function applyPartUpdateV2(instanceId: string, part: ClientPart | null | }) } +export function applyPartDeltaV2( + instanceId: string, + input: { messageId: string; partId: string; field: string; delta: string }, +): void { + if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") { + return + } + const store = messageStoreBus.getOrCreate(instanceId) + store.applyPartDelta({ + messageId: input.messageId, + partId: input.partId, + field: input.field, + delta: input.delta, + }) +} + export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void { if (!oldId || !newId || oldId === newId) return const store = messageStoreBus.getOrCreate(instanceId) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 0ebc3c8b..1bebb023 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -189,6 +189,7 @@ export interface InstanceMessageStore { hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void upsertMessage: (input: MessageUpsertInput) => void applyPartUpdate: (input: PartUpdateInput) => void + applyPartDelta: (input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) => void removeMessage: (messageId: string) => void removeMessagePart: (messageId: string, partId: string) => void bufferPendingPart: (entry: PendingPartEntry) => void @@ -597,6 +598,45 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt bumpSessionRevision(message.sessionId) } + function applyPartDelta(input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) { + if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") { + return + } + + const message = state.messages[input.messageId] + if (!message) { + // Best-effort: drop deltas for unknown messages. + return + } + + let applied = false + + setState( + "messages", + input.messageId, + produce((draft: MessageRecord) => { + const entry = draft.parts[input.partId] + if (!entry?.data) return + const part = entry.data as any + const currentValue = part?.[input.field] + if (typeof currentValue === "string" || currentValue === undefined || currentValue === null) { + part[input.field] = `${currentValue ?? ""}${input.delta}` + applied = true + } + if (!applied) return + entry.revision += 1 + draft.updatedAt = Date.now() + if (input.bumpRevision ?? true) { + draft.revision += 1 + } + }), + ) + + if (applied) { + bumpSessionRevision(message.sessionId) + } + } + function removeMessage(messageId: string) { if (!messageId) return @@ -1087,19 +1127,20 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState(reconcile(createInitialState(instanceId))) } - return { + return { instanceId, state, setState, addOrUpdateSession, - hydrateMessages, - upsertMessage, + hydrateMessages, + upsertMessage, applyPartUpdate, + applyPartDelta, removeMessage, removeMessagePart, bufferPendingPart, - flushPendingParts, + flushPendingParts, replaceMessageId, setMessageInfo, getMessageInfo, @@ -1125,4 +1166,3 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } } - diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index f5516a8a..abe24e75 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -1,6 +1,7 @@ import type { MessageInfo, MessagePartRemovedEvent, + MessagePartDeltaEvent, MessagePartUpdatedEvent, MessageRemovedEvent, MessageUpdateEvent, @@ -48,6 +49,7 @@ import { loadMessages } from "./session-api" import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees" import { applyPartUpdateV2, + applyPartDeltaV2, replaceMessageIdV2, reconcilePendingQuestionsV2, upsertMessageInfoV2, @@ -348,6 +350,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes } } +function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent): void { + const props = event.properties + if (!props) return + const { messageID, partID, field, delta } = props + if (!messageID || !partID || !field || typeof delta !== "string") return + applyPartDeltaV2(instanceId, { messageId: messageID, partId: partID, field, delta }) +} + function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void { const info = event.properties?.info @@ -625,6 +635,7 @@ function handleQuestionAnswered( export { handleMessagePartRemoved, handleMessageRemoved, + handleMessagePartDelta, handleMessageUpdate, handlePermissionReplied, handlePermissionUpdated, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index ef056a0e..8b5e9a3e 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -58,6 +58,7 @@ import { import { handleMessagePartRemoved, handleMessageRemoved, + handleMessagePartDelta, handleMessageUpdate, handlePermissionReplied, handlePermissionUpdated, @@ -74,6 +75,7 @@ import { sseManager.onMessageUpdate = handleMessageUpdate sseManager.onMessagePartUpdated = handleMessageUpdate +sseManager.onMessagePartDelta = handleMessagePartDelta sseManager.onMessageRemoved = handleMessageRemoved sseManager.onMessagePartRemoved = handleMessagePartRemoved sseManager.onSessionUpdate = handleSessionUpdate diff --git a/packages/ui/src/types/message.ts b/packages/ui/src/types/message.ts index 712d3d78..db964d27 100644 --- a/packages/ui/src/types/message.ts +++ b/packages/ui/src/types/message.ts @@ -20,6 +20,19 @@ export type { SDKMessage } +// Server streaming event: append-only delta updates. +// Emitted over SSE by newer OpenCode builds. +export interface MessagePartDeltaEvent { + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + export interface RenderCache { text: string html: string From 682937e945651aad4e0fb3b88869e8f7f7586f9c Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 15 Feb 2026 15:21:09 +0000 Subject: [PATCH 22/34] docs(server): improve CLI flag/env var docs Make server usage easier to discover from the root README, add local install/run instructions, and document additional CLI flags/env vars for UI and logging. --- README.md | 16 ++++++++++++++++ packages/server/README.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/README.md b/README.md index 9276ad4c..b0d2709f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,22 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for npx @neuralnomads/codenomad --launch ``` +Full server/CLI documentation (flags + env vars, TLS, auth, remote access): +`packages/server/README.md` + +To see all available options: + +```bash +npx @neuralnomads/codenomad --help +``` + +If you prefer not to run it as a one-off `npx @neuralnomads/codenomad`, you can install it and run the local binary: + +```bash +npm install @neuralnomads/codenomad +npx codenomad --launch +``` + For dev version ```bash diff --git a/packages/server/README.md b/packages/server/README.md index 2eff0a24..05d42742 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -31,6 +31,12 @@ You can run CodeNomad directly without installing it: npx @neuralnomads/codenomad --launch ``` +To list all CLI options: + +```sh +npx @neuralnomads/codenomad --help +``` + On startup, CodeNomad prints two URLs: - `Local Connection URL : ...` (used by desktop shells) @@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad codenomad --launch ``` +### Install Locally (per-project) +If you prefer to install CodeNomad into a project and run the local binary: + +```sh +npm install @neuralnomads/codenomad +npx codenomad --launch +``` + +(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.) + ### Common Flags You can configure the server using flags or environment variables: @@ -63,10 +79,24 @@ You can configure the server using flags or environment variables: | `--config ` | `CLI_CONFIG` | Config file location | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--log-level ` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) | +| `--log-destination ` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) | | `--username ` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) | | `--password ` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth | | `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows | | `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) | +| `--ui-dir ` | `CLI_UI_DIR` | Directory containing the built UI bundle | +| `--ui-dev-server ` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) | +| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates | +| `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) | +| `--ui-manifest-url ` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | + +### Update Checks (Advanced) +These environment variables control how CodeNomad checks for dev updates: + +| Env Variable | Description | +|-------------|-------------| +| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) | +| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) | ### HTTP vs HTTPS From 8c24a7daf371ee15115f6a1328bef1a323b86d0e Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 15 Feb 2026 15:29:06 +0000 Subject: [PATCH 23/34] docs: reorganize server and dev release docs --- README.md | 19 +++---------------- packages/server/README.md | 8 +++++++- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b0d2709f..eae634f3 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npx @neuralnomads/codenomad --launch ``` Full server/CLI documentation (flags + env vars, TLS, auth, remote access): -`packages/server/README.md` +- [packages/server/README.md](packages/server/README.md) To see all available options: @@ -53,26 +53,13 @@ To see all available options: npx @neuralnomads/codenomad --help ``` -If you prefer not to run it as a one-off `npx @neuralnomads/codenomad`, you can install it and run the local binary: - -```bash -npm install @neuralnomads/codenomad -npx codenomad --launch -``` - -For dev version +### 🧪 Dev Releases +Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch. ```bash npx @neuralnomads/codenomad-dev --launch ``` -Dev builds are published as GitHub pre-releases: -https://github.com/shantur/CodeNomad/releases - -Dev releases are bleeding-edge builds, generated automatically every time a new commit is pushed to the `dev` branch. - -This command starts the server and opens the web client in your default browser. - ## Highlights - **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs. diff --git a/packages/server/README.md b/packages/server/README.md index 05d42742..cb798eb1 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -90,7 +90,13 @@ You can configure the server using flags or environment variables: | `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) | | `--ui-manifest-url ` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | -### Update Checks (Advanced) +### Dev Releases (Advanced) +If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package: + +```sh +npx @neuralnomads/codenomad-dev --launch +``` + These environment variables control how CodeNomad checks for dev updates: | Env Variable | Description | From ff713029692b58e4175b5516d7874b13b044321e Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 15 Feb 2026 15:43:54 +0000 Subject: [PATCH 24/34] fix(ui): prevent close button overlapping theme toggle --- packages/ui/src/App.tsx | 18 +++++------------- .../src/components/folder-selection-view.tsx | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 0f2a85ff..7c515506 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -460,25 +460,17 @@ const App: Component = () => {
- setIsAdvancedSettingsOpen(true)} onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)} + onClose={() => { + setShowFolderSelection(false) + setIsAdvancedSettingsOpen(false) + clearLaunchError() + }} />
diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index e4a5e68b..96934272 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -1,6 +1,6 @@ import { Select } from "@kobalte/core/select" import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" -import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid" +import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid" import { useConfig } from "../stores/preferences" import AdvancedSettingsModal from "./advanced-settings-modal" import DirectoryBrowserDialog from "./directory-browser-dialog" @@ -23,6 +23,7 @@ interface FolderSelectionViewProps { onAdvancedSettingsOpen?: () => void onAdvancedSettingsClose?: () => void onOpenRemoteAccess?: () => void + onClose?: () => void } const FolderSelectionView: Component = (props) => { @@ -373,7 +374,18 @@ const FolderSelectionView: Component = (props) => { class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" onClick={() => props.onOpenRemoteAccess?.()} > - + + +
+ +
From 9a4d205d97d5bcc5fcdedd937858d8cf831ee142 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 15 Feb 2026 20:38:57 +0000 Subject: [PATCH 25/34] refactor(ui): rename message time.completed to time.end Update all references from info.time.completed to info.time.end to align with SDK schema changes. Affects message status tracking and rendering. --- packages/ui/src/components/message-item.tsx | 3 ++- packages/ui/src/stores/message-v2/bridge.ts | 6 +++--- packages/ui/src/stores/session-events.ts | 10 +++++----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 2f8eb26e..3a1b6357 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -151,7 +151,8 @@ export default function MessageItem(props: MessageItemProps) { } const info = props.messageInfo - return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0) + const timeInfo = info?.time as { created: number; end?: number } | undefined + return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0)) } const handleRevert = () => { diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index af22d94f..ea566bf9 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -77,9 +77,9 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null return } const store = messageStoreBus.getOrCreate(instanceId) - const timeInfo = (info.time ?? {}) as { created?: number; completed?: number } + const timeInfo = (info.time ?? {}) as { created?: number; end?: number } const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now() - const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined + const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined store.upsertMessage({ id: info.id, @@ -87,7 +87,7 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null role: info.role === "user" ? "user" : "assistant", status: options?.status ?? "complete", createdAt, - updatedAt: completedAt ?? createdAt, + updatedAt: endAt ?? createdAt, bumpRevision: Boolean(options?.bumpRevision), }) store.setMessageInfo(info.id, info) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index abe24e75..58d2a64a 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -300,10 +300,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return - const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; completed?: number } + const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number } const nextUpdated = - typeof timeInfo.completed === "number" && timeInfo.completed > 0 - ? timeInfo.completed + typeof timeInfo.end === "number" && timeInfo.end > 0 + ? timeInfo.end : typeof timeInfo.updated === "number" && timeInfo.updated > 0 ? timeInfo.updated : typeof timeInfo.created === "number" && timeInfo.created > 0 @@ -333,14 +333,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (!record) { const createdAt = info.time?.created ?? Date.now() - const completedAt = (info.time as { completed?: number } | undefined)?.completed + const endAt = (info.time as { end?: number } | undefined)?.end store.upsertMessage({ id: messageId, sessionId, role, status, createdAt, - updatedAt: completedAt ?? createdAt, + updatedAt: endAt ?? createdAt, }) } From 56a052086f8c09c7220bc63a7c465fe2cf98f74b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 15 Feb 2026 22:26:17 +0000 Subject: [PATCH 26/34] fix(ui): ignore unsupported patch parts --- packages/ui/src/components/message-block.tsx | 37 ++++++++++---------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 0fd2aaf7..0d485c94 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -198,6 +198,16 @@ interface MessageContentItemProps { onContentRendered?: () => void } +function isSupportedPartType(part: unknown): boolean { + const type = (part as any)?.type + // Ignore part types the UI does not support rendering yet. + return !(typeof type === "string" && type === "patch") +} + +function isContentPartType(type: unknown): boolean { + return type === "text" || type === "file" +} + function MessageContentItem(props: MessageContentItemProps) { const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) @@ -222,15 +232,9 @@ function MessageContentItem(props: MessageContentItemProps) { const partId = ids[idx] const part = current.parts[partId]?.data if (!part) continue - if ( - part.type === "tool" || - part.type === "reasoning" || - part.type === "compaction" || - part.type === "step-start" || - part.type === "step-finish" - ) { - break - } + if (!isSupportedPartType(part)) continue + + if (!isContentPartType((part as any).type)) break resolved.push(part) } @@ -256,15 +260,9 @@ function MessageContentItem(props: MessageContentItemProps) { const partId = ids[idx] const part = current.parts[partId]?.data if (!part) continue - if ( - part.type === "tool" || - part.type === "reasoning" || - part.type === "compaction" || - part.type === "step-start" || - part.type === "step-finish" - ) { - continue - } + if (!isSupportedPartType(part)) continue + + if (!isContentPartType((part as any).type)) continue if (partHasRenderableText(part)) { return false } @@ -549,6 +547,9 @@ export default function MessageBlock(props: MessageBlockProps) { } orderedParts.forEach((part, partIndex) => { + if (!isSupportedPartType(part)) { + return + } if (part.type === "tool") { flushContent() const partId = part.id From 265d497ef4206a3954b5850f6f6abdcee83b8216 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 15 Feb 2026 22:26:17 +0000 Subject: [PATCH 27/34] chore(opencode-config): bump @opencode-ai/plugin to 1.2.4 --- packages/opencode-config/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index be820634..efa583b3 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.1.59" + "@opencode-ai/plugin": "1.2.4" } -} \ No newline at end of file +} From 95c747923c44e99212f4dad32f4dfe86900f26e6 Mon Sep 17 00:00:00 2001 From: VooDisss Date: Mon, 16 Feb 2026 01:11:53 +0200 Subject: [PATCH 28/34] fix(ui): improve picker actions, directory navigation, @ handling, and message display --- packages/ui/src/components/message-item.tsx | 41 ++++++++++----- packages/ui/src/components/message-part.tsx | 20 ++++++-- .../prompt-input/usePromptKeyDown.ts | 30 +++++++++-- .../prompt-input/usePromptPicker.ts | 26 +++++++++- packages/ui/src/components/unified-picker.tsx | 49 +++++++++++++++++- packages/ui/src/lib/prompt-placeholders.ts | 50 +++++++++++++++++-- packages/ui/src/stores/session-actions.ts | 5 +- 7 files changed, 193 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 2f8eb26e..503464b4 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -1,5 +1,5 @@ import { For, Show, createSignal } from "solid-js" -import { Copy, Split, Trash2, Undo } from "lucide-solid" +import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid" import type { MessageInfo, ClientPart } from "../types/message" import { partHasRenderableText } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" @@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard" import { useI18n } from "../lib/i18n" import { showAlertDialog } from "../stores/alerts" import { deleteMessagePart } from "../stores/session-actions" +import { isTauriHost } from "../lib/runtime-env" interface MessageItemProps { record: MessageRecord @@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) { const messageParts = () => props.parts + // User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads). + // We only want to display the primary prompt text for the user message; other synthetic text + // parts should be hidden. + const primaryUserTextPartId = () => { + if (!isUser()) return null + const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined + return typeof firstText?.id === "string" ? firstText.id : null + } + const fileAttachments = () => messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string") @@ -96,7 +106,8 @@ export default function MessageItem(props: MessageItemProps) { } if (url.startsWith("file://")) { - window.open(url, "_blank", "noopener") + // Local filesystem URLs are not reliably downloadable from the message stream. + // We hide the download action for these chips. return } @@ -372,6 +383,7 @@ export default function MessageItem(props: MessageItemProps) { messageType={props.record.role} instanceId={props.instanceId} sessionId={props.sessionId} + primaryUserTextPartId={primaryUserTextPartId()} onRendered={props.onContentRendered} /> )} @@ -398,17 +410,20 @@ export default function MessageItem(props: MessageItemProps) { {name}
{name} - + + +
diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index 3e07f574..008eef09 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -23,15 +23,52 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen let result = prompt // For each path attachment (SHIFT+ENTER), find and replace @path with path in the prompt - // We ALWAYS strip @ for SHIFT+ENTER paths, even if there's also a file attachment for (const path of pathAttachments) { - // Try both with and without trailing slash - const variants = [path, path + "/"] + if (!path) continue + + // The path should already have ./ prefix from usePromptPicker + // We need to find @path in prompt and replace with path + + // For "./docs/" path, try to match @docs/, @./docs/, @docs, etc. + const basePath = path.replace(/^\.\//, "").replace(/\/+$/, "") // "docs" + const withSlash = basePath + "/" // "docs/" + + const patterns = [ + "@" + path, // @./docs/ + "@" + basePath, // @docs + "@" + withSlash, // @docs/ + ] + + for (const pattern of patterns) { + if (result.includes(pattern)) { + result = result.replace(pattern, path) + } + } + } - for (const variant of variants) { - // Replace @path with path (exact match) - const searchPattern = "@" + variant - result = result.split(searchPattern).join(variant) + // Also strip @ for paths that have file attachments (ENTER case) + for (const filePath of fileAttachments) { + if (!filePath || filePath.length === 0) continue + + // Special case: if attachment is "./" or ".", handle separately + if (filePath === "./" || filePath === ".") { + result = result.replace("@./", "./") + result = result.replace("@.", "./") + continue + } + + // Normal path handling + const pathToFind = filePath.replace(/^\.\//, "") + const patterns = [ + "@" + filePath, + "@./" + pathToFind, + "@" + pathToFind, + ] + + for (const pattern of patterns) { + if (result.includes(pattern)) { + result = result.replace(pattern, filePath) + } } } From 32113ea1008432cf75d98d59db3291bf0d3b92b3 Mon Sep 17 00:00:00 2001 From: VooDisss Date: Mon, 16 Feb 2026 05:03:27 +0200 Subject: [PATCH 33/34] fix(ui): resolve root path @. and @./ correctly --- packages/ui/src/lib/prompt-placeholders.ts | 81 ++++++++-------------- 1 file changed, 28 insertions(+), 53 deletions(-) diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index 008eef09..b5c8ee55 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -5,15 +5,12 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen return prompt } - // Get file attachments (ENTER case) - these are sent separately, keep @ in prompt const fileAttachments = new Set( attachments - .filter((a) => a.source.type === "file" && "path" in a.source) - .map((a) => (a.source as { path: string }).path), + .filter((a): a is Attachment & { source: FileSource } => a.source.type === "file") + .map((a) => a.source.path), ) - // Build a set of paths that were added via SHIFT+ENTER (text attachments with path: display) - // These need @ stripped from the prompt const pathAttachments = new Set( attachments .filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:")) @@ -22,57 +19,35 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen let result = prompt - // For each path attachment (SHIFT+ENTER), find and replace @path with path in the prompt - for (const path of pathAttachments) { + // Step 1: Handle root paths FIRST using unique placeholders + // Replace longer pattern first to avoid partial match issues + result = result.replace(/@(\.\/)/g, "___ROOT___") + result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___") + // Note: The regex @(\.)(?!\.) means @. NOT followed by another . + + // Step 2: Build set of non-root paths + const allPaths = new Set() + for (const p of fileAttachments) { + if (p && p !== "." && p !== "./") allPaths.add(p) + } + for (const p of pathAttachments) { + if (p && p !== "." && p !== "./") allPaths.add(p) + } + + // Step 3: Replace @path with ./path for non-root paths + for (const path of allPaths) { if (!path) continue - - // The path should already have ./ prefix from usePromptPicker - // We need to find @path in prompt and replace with path - - // For "./docs/" path, try to match @docs/, @./docs/, @docs, etc. - const basePath = path.replace(/^\.\//, "").replace(/\/+$/, "") // "docs" - const withSlash = basePath + "/" // "docs/" - - const patterns = [ - "@" + path, // @./docs/ - "@" + basePath, // @docs - "@" + withSlash, // @docs/ - ] - - for (const pattern of patterns) { - if (result.includes(pattern)) { - result = result.replace(pattern, path) - } - } + const withoutPrefix = path.startsWith("./") ? path.slice(2) : path + const withPrefix = path.startsWith("./") ? path : "./" + path + result = result.replace("@" + withoutPrefix, withPrefix) + result = result.replace("@" + withoutPrefix + "/", withPrefix + "/") } - // Also strip @ for paths that have file attachments (ENTER case) - for (const filePath of fileAttachments) { - if (!filePath || filePath.length === 0) continue + // Step 4: Convert placeholders back to ./ + result = result.replace("___ROOT___", "./") + result = result.replace("___ROOT_NOSLASH___", "./") - // Special case: if attachment is "./" or ".", handle separately - if (filePath === "./" || filePath === ".") { - result = result.replace("@./", "./") - result = result.replace("@.", "./") - continue - } - - // Normal path handling - const pathToFind = filePath.replace(/^\.\//, "") - const patterns = [ - "@" + filePath, - "@./" + pathToFind, - "@" + pathToFind, - ] - - for (const pattern of patterns) { - if (result.includes(pattern)) { - result = result.replace(pattern, filePath) - } - } - } - - // Then, resolve [pasted #N] placeholders + // Step 5: Resolve [pasted #N] placeholders if (!result.includes("[pasted #")) { return result } @@ -87,7 +62,7 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen const source = attachment?.source if (!source || source.type !== "text") continue const display = attachment?.display - const value = source.value + const value = (source as { value?: string }).value if (typeof display !== "string" || typeof value !== "string") continue const match = display.match(/pasted #(\d+)/) if (!match) continue From b7f638f07d9feb8eece8e462e74d962a7ec6999c Mon Sep 17 00:00:00 2001 From: VooDisss Date: Mon, 16 Feb 2026 05:21:22 +0200 Subject: [PATCH 34/34] fix(i18n): add workspace root translation key --- packages/ui/src/components/unified-picker.tsx | 2 +- packages/ui/src/lib/i18n/messages/en/commands.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 810771b3..70a0be97 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -571,7 +571,7 @@ const UnifiedPicker: Component = (props) => { d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> - . (workspace root) + . {t("unifiedPicker.sections.workspaceRoot")}
diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index 66ff78f7..8ddd31db 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -158,6 +158,7 @@ export const commandMessages = { "unifiedPicker.sections.commands": "COMMANDS", "unifiedPicker.sections.agents": "AGENTS", "unifiedPicker.sections.files": "FILES", + "unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT", "unifiedPicker.badge.subagent": "subagent", "unifiedPicker.footer.navigate": "navigate", "unifiedPicker.footer.select": "select",