From bb63d16fa86c07425158f95a9e619678fe261e50 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Jan 2026 14:13:18 -0500 Subject: [PATCH 01/38] Set temperature for kimi k2.5 --- packages/opencode/src/provider/transform.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 39eef6c9165..f52b6a2cfa9 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -283,6 +283,7 @@ export namespace ProviderTransform { if (id.includes("glm-4.6")) return 1.0 if (id.includes("glm-4.7")) return 1.0 if (id.includes("minimax-m2")) return 1.0 + if (id.includes("kimi-k2.5")) return 1.0 if (id.includes("kimi-k2")) { // kimi-k2-thinking & kimi-k2.5 if (id.includes("thinking") || id.includes("k2.")) { From 2649dcae7f6c850b7fceaf1e452bd83e7a3eed1c Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Jan 2026 14:37:33 -0500 Subject: [PATCH 02/38] Revert "ci: make tests passing a requirement pre-release" This reverts commit 8c00818108700b4aad81a9127d9e1e9adc8254bd. --- .github/workflows/publish.yml | 6 +----- .github/workflows/test.yml | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f2c7f4ea001..8d7a823b144 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,13 +29,9 @@ permissions: packages: write jobs: - test: - if: github.event_name == 'workflow_dispatch' - uses: ./.github/workflows/test.yml publish: - needs: test runs-on: blacksmith-4vcpu-ubuntu-2404 - if: always() && github.repository == 'anomalyco/opencode' && (github.event_name != 'workflow_dispatch' || needs.test.result == 'success') + if: github.repository == 'anomalyco/opencode' steps: - uses: actions/checkout@v3 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fca227fa869..d95de94d232 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,6 @@ on: - dev pull_request: workflow_dispatch: - workflow_call: jobs: test: name: test (${{ matrix.settings.name }}) From df8b23db9ed6b06d126c69de157fe997dacacb05 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Jan 2026 14:48:08 -0500 Subject: [PATCH 03/38] Revert "Set temperature for kimi k2.5" This reverts commit bb63d16fa86c07425158f95a9e619678fe261e50. --- packages/opencode/src/provider/transform.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f52b6a2cfa9..39eef6c9165 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -283,7 +283,6 @@ export namespace ProviderTransform { if (id.includes("glm-4.6")) return 1.0 if (id.includes("glm-4.7")) return 1.0 if (id.includes("minimax-m2")) return 1.0 - if (id.includes("kimi-k2.5")) return 1.0 if (id.includes("kimi-k2")) { // kimi-k2-thinking & kimi-k2.5 if (id.includes("thinking") || id.includes("k2.")) { From 82068955f7dae6f1837cb7d56e71ed42407fd54b Mon Sep 17 00:00:00 2001 From: David Hill Date: Tue, 27 Jan 2026 14:42:52 +0000 Subject: [PATCH 04/38] feat(app): color filetree change dots by diff kind --- packages/app/src/components/file-tree.tsx | 37 ++++++++++++++++++++--- packages/app/src/pages/session.tsx | 12 ++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 64988af5327..3b6524b5774 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -17,6 +17,8 @@ import { import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" +type Kind = "add" | "del" | "mix" + type Filter = { files: Set dirs: Set @@ -29,6 +31,7 @@ export default function FileTree(props: { level?: number allowed?: readonly string[] modified?: readonly string[] + kinds?: ReadonlyMap draggable?: boolean tooltip?: boolean onFileClick?: (file: FileNode) => void @@ -36,6 +39,7 @@ export default function FileTree(props: { _filter?: Filter _marks?: Set _deeps?: Map + _kinds?: ReadonlyMap }) { const file = useFile() const level = props.level ?? 0 @@ -66,11 +70,16 @@ export default function FileTree(props: { const marks = createMemo(() => { if (props._marks) return props._marks - const modified = props.modified + const modified = props.modified ?? (props.kinds ? Array.from(props.kinds.keys()) : undefined) if (!modified || modified.length === 0) return return new Set(modified) }) + const kinds = createMemo(() => { + if (props._kinds) return props._kinds + return props.kinds + }) + const deeps = createMemo(() => { if (props._deeps) return props._deeps @@ -179,9 +188,27 @@ export default function FileTree(props: { > {local.node.name} - {local.node.type === "file" && marks()?.has(local.node.path) ? ( -
- ) : null} + {(() => { + if (local.node.type !== "file") return null + if (!marks()?.has(local.node.path)) return null + + const kind = kinds()?.get(local.node.path) + return ( +
+ ) + })()} ) } @@ -235,12 +262,14 @@ export default function FileTree(props: { level={level + 1} allowed={props.allowed} modified={props.modified} + kinds={props.kinds} draggable={props.draggable} tooltip={props.tooltip} onFileClick={props.onFileClick} _filter={filter()} _marks={marks()} _deeps={deeps()} + _kinds={kinds()} /> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 04f34b2a929..d16719d8402 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -479,6 +479,16 @@ export default function Page() { scrollToMessage(msgs[targetIndex], "auto") } + const kinds = createMemo(() => { + const out = new Map() + for (const diff of diffs()) { + const add = diff.additions > 0 + const del = diff.deletions > 0 + const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix" + out.set(diff.file, kind) + } + return out + }) const emptyDiffFiles: string[] = [] const diffFiles = createMemo(() => diffs().map((d) => d.file), emptyDiffFiles, { equals: same }) const diffsReady = createMemo(() => { @@ -2652,6 +2662,7 @@ export default function Page() { focusReviewDiff(node.path)} @@ -2669,6 +2680,7 @@ export default function Page() { openTab(file.tab(node.path))} /> From 8ee5376f9b0711e2dd720c1587c875f0af39f27a Mon Sep 17 00:00:00 2001 From: David Hill Date: Tue, 27 Jan 2026 16:04:31 +0000 Subject: [PATCH 05/38] feat(app): add filetree tooltips with diff labels --- packages/app/src/components/file-tree.tsx | 44 ++++++++++++++++++++++- packages/app/src/pages/session.tsx | 2 -- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 3b6524b5774..24b15483e34 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -8,6 +8,7 @@ import { createMemo, For, Match, + Show, splitProps, Switch, untrack, @@ -221,8 +222,49 @@ export default function FileTree(props: { const deep = () => deeps().get(node.path) ?? -1 const Wrapper = (p: ParentProps) => { if (!tooltip()) return p.children + + const parts = node.path.split("/") + const leaf = parts[parts.length - 1] ?? node.path + const head = parts.slice(0, -1).join("/") + const prefix = head ? `${head}/` : "" + + const kind = () => (node.type === "file" ? kinds()?.get(node.path) : undefined) + const label = () => { + if (!marks()?.has(node.path)) return + const k = kind() + if (!k) return + if (k === "add") return "Additions" + if (k === "del") return "Deletions" + return "Modifications" + } + return ( - + + + {prefix} + + {leaf} + + {(t: () => string) => ( + <> + + {t()} + + )} + +
+ } + > {p.children} ) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d16719d8402..80a1ec70313 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -2664,7 +2664,6 @@ export default function Page() { allowed={diffFiles()} kinds={kinds()} draggable={false} - tooltip={false} onFileClick={(node) => focusReviewDiff(node.path)} /> @@ -2681,7 +2680,6 @@ export default function Page() { path="" modified={diffFiles()} kinds={kinds()} - tooltip={false} onFileClick={(node) => openTab(file.tab(node.path))} /> From 2ca69ac953b8884310cc01013105f2849dfae452 Mon Sep 17 00:00:00 2001 From: David Hill Date: Tue, 27 Jan 2026 16:41:28 +0000 Subject: [PATCH 06/38] fix(app): shorten nav tooltips --- packages/app/src/i18n/en.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8fb819798dd..173976db744 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -166,8 +166,8 @@ export const dict = { "model.tooltip.context": "Context limit {{limit}}", "common.search.placeholder": "Search", - "common.goBack": "Go back", - "common.goForward": "Go forward", + "common.goBack": "Back", + "common.goForward": "Forward", "common.loading": "Loading", "common.loading.ellipsis": "...", "common.cancel": "Cancel", From 1fffbc6fb3cd7c4add26dd00a171ec84b39a2bc0 Mon Sep 17 00:00:00 2001 From: David Hill Date: Tue, 27 Jan 2026 16:43:34 +0000 Subject: [PATCH 07/38] fix(app): adjust titlebar left spacing --- packages/app/src/components/titlebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 7a08456c7b7..c50d54e35b5 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -165,7 +165,7 @@ export function Titlebar() { />
-
+
Date: Tue, 27 Jan 2026 16:53:02 +0000 Subject: [PATCH 08/38] fix(app): delay nav tooltips --- packages/app/src/components/titlebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index c50d54e35b5..e3831c70fe5 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -195,7 +195,7 @@ export function Titlebar() { +
@@ -2597,6 +2622,7 @@ export default function Page() { diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} onScrollRef={setReviewScroll} + focusedFile={activeDiff()} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -2664,6 +2690,7 @@ export default function Page() { allowed={diffFiles()} kinds={kinds()} draggable={false} + active={activeDiff()} onFileClick={(node) => focusReviewDiff(node.path)} /> diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 933c7ce5f24..d9b34592304 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -43,6 +43,10 @@ background-color: transparent; color: var(--text-strong); + [data-slot="icon-svg"] { + color: var(--icon-base); + } + &:hover:not(:disabled) { background-color: var(--surface-raised-base-hover); } @@ -54,8 +58,11 @@ } &:disabled { color: var(--text-weak); - opacity: 0.7; cursor: not-allowed; + + [data-slot="icon-svg"] { + color: var(--icon-disabled); + } } &[data-selected="true"]:not(:disabled) { background-color: var(--surface-raised-base-hover); diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index dd75b190524..20d2fef1529 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -54,6 +54,13 @@ background-color: var(--background-stronger) !important; } + [data-slot="session-review-accordion-item"][data-selected] { + [data-slot="session-review-accordion-content"] { + box-shadow: var(--shadow-xs-border-select); + border-radius: var(--radius-lg); + } + } + [data-slot="accordion-item"] { [data-slot="accordion-content"] { display: none; diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 8dfbbb1ca68..84ec934e24d 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -44,6 +44,7 @@ export interface SessionReviewProps { comments?: SessionReviewComment[] focusedComment?: SessionReviewFocus | null onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void + focusedFile?: string open?: string[] onOpenChange?: (open: string[]) => void scrollRef?: (el: HTMLDivElement) => void @@ -501,6 +502,7 @@ export const SessionReview = (props: SessionReviewProps) => { id={diffId(diff.file)} data-file={diff.file} data-slot="session-review-accordion-item" + data-selected={props.focusedFile === diff.file ? "" : undefined} > diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 04a9837fd8a..951450d540a 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -286,6 +286,7 @@ --icon-diff-add-active: var(--mint-light-12); --icon-diff-delete-base: var(--ember-light-10); --icon-diff-delete-hover: var(--ember-light-11); + --icon-diff-modified-base: var(--icon-warning-base); --syntax-comment: var(--text-weak); --syntax-regexp: var(--text-base); --syntax-string: #006656; @@ -543,6 +544,7 @@ --icon-diff-add-active: var(--mint-dark-11); --icon-diff-delete-base: var(--ember-dark-9); --icon-diff-delete-hover: var(--ember-dark-10); + --icon-diff-modified-base: var(--icon-warning-base); --syntax-comment: var(--text-weak); --syntax-regexp: var(--text-base); --syntax-string: #00ceb9; diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index 8c25aefd7b8..2e9c3a52144 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -240,6 +240,7 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["icon-diff-add-active"] = diffAdd[isDark ? 10 : 11] tokens["icon-diff-delete-base"] = diffDelete[isDark ? 8 : 9] tokens["icon-diff-delete-hover"] = diffDelete[isDark ? 9 : 10] + tokens["icon-diff-modified-base"] = tokens["icon-warning-base"] tokens["syntax-comment"] = "var(--text-weak)" tokens["syntax-regexp"] = "var(--text-base)" From 892113ab39e233ec4c538558a9035de03ce6cadb Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:37:23 -0600 Subject: [PATCH 12/38] chore(app): show 5 highlights --- packages/app/src/context/highlights.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index e55bca675d5..cc4c021beb0 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -126,7 +126,7 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p seen.add(key) return true }) - return unique.slice(0, 3) + return unique.slice(0, 5) } export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ From d7948c2376ee2611bd64cc5ea1f787330313c2ef Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:38:57 -0600 Subject: [PATCH 13/38] fix(app): auto-scroll --- packages/app/src/pages/session.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e75bd2fed01..7257cdc1193 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -524,6 +524,15 @@ export default function Page() { const scrollGestureWindowMs = 250 + const scrollIgnoreWindowMs = 250 + let scrollIgnore = 0 + + const markScrollIgnore = () => { + scrollIgnore = Date.now() + } + + const hasScrollIgnore = () => Date.now() - scrollIgnore < scrollIgnoreWindowMs + const markScrollGesture = (target?: EventTarget | null) => { const root = scroller if (!root) return @@ -1341,7 +1350,9 @@ export default function Page() { requestAnimationFrame(() => { const delta = el.scrollHeight - beforeHeight - if (delta) el.scrollTop = beforeTop + delta + if (!delta) return + markScrollIgnore() + el.scrollTop = beforeTop + delta }) scheduleTurnBackfill() @@ -1378,6 +1389,7 @@ export default function Page() { if (stick && el) { requestAnimationFrame(() => { + markScrollIgnore() el.scrollTo({ top: el.scrollHeight, behavior: "auto" }) }) } @@ -1779,8 +1791,9 @@ export default function Page() { markScrollGesture(e.target) }} onScroll={(e) => { - autoScroll.handleScroll() - if (!hasScrollGesture()) return + const gesture = hasScrollGesture() + if (!hasScrollIgnore() || gesture) autoScroll.handleScroll() + if (!gesture) return markScrollGesture(e.target) if (isDesktop()) scheduleScrollSpy(e.currentTarget) }} From 1ebf63c70c552c95794325f40bbd278ba3e0c725 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:55:50 -0600 Subject: [PATCH 14/38] fix(app): don't connect to localhost through vpn --- packages/desktop/src-tauri/src/lib.rs | 12 ++++++++++-- packages/desktop/src-tauri/src/main.rs | 27 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index e086acd9364..dab98f4a006 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -328,7 +328,15 @@ pub fn run() { .hidden_title(true); #[cfg(windows)] - let window_builder = window_builder.decorations(false); + let window_builder = window_builder + // Some VPNs set a global/system proxy that WebView2 applies even for loopback + // connections, which breaks the app's localhost sidecar server. + // Note: when setting additional args, we must re-apply wry's default + // `--disable-features=...` flags. + .additional_browser_args( + "--proxy-bypass-list=<-loopback> --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection", + ) + .decorations(false); let window = window_builder.build().expect("Failed to create window"); @@ -525,4 +533,4 @@ async fn spawn_local_server( break Ok(child); } } -} \ No newline at end of file +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index e3be4a44dab..9ffee8aa5ce 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -52,7 +52,32 @@ fn configure_display_backend() -> Option { } fn main() { - unsafe { std::env::set_var("NO_PROXY", "127.0.0.1,localhost,::1") }; + // Ensure loopback connections are never sent through proxy settings. + // Some VPNs/proxies set HTTP_PROXY/HTTPS_PROXY/ALL_PROXY without excluding localhost. + const LOOPBACK: [&str; 3] = ["127.0.0.1", "localhost", "::1"]; + + let upsert = |key: &str| { + let mut items = std::env::var(key) + .unwrap_or_default() + .split(',') + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()) + .collect::>(); + + for host in LOOPBACK { + if items.iter().any(|v| v.eq_ignore_ascii_case(host)) { + continue; + } + items.push(host.to_string()); + } + + // Safety: called during startup before any threads are spawned. + unsafe { std::env::set_var(key, items.join(",")) }; + }; + + upsert("NO_PROXY"); + upsert("no_proxy"); #[cfg(target_os = "linux")] { From 842f17d6d97c52d1efac66a8dca298f6ca692a56 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:51:34 -0600 Subject: [PATCH 15/38] perf(app): better memory management --- packages/app/src/components/prompt-input.tsx | 8 +- packages/app/src/components/terminal.tsx | 96 +++++++++++----- packages/app/src/context/file.tsx | 72 +++++++++++- packages/app/src/context/global-sync.tsx | 47 ++++++-- packages/app/src/context/permission.tsx | 24 +++- packages/app/src/pages/layout.tsx | 50 +++++++- packages/app/src/utils/persist.ts | 72 ++++++++++-- packages/app/src/utils/speech.ts | 39 +++++-- packages/app/src/utils/worktree.ts | 49 +++++--- specs/09-persist-cache-memory-bounds.md | 108 ++++++++++++++++++ specs/10-global-sync-message-part-cleanup.md | 106 +++++++++++++++++ specs/11-layout-prefetch-memory-budget.md | 103 +++++++++++++++++ specs/12-terminal-unmount-race-cleanup.md | 92 +++++++++++++++ .../13-speech-recognition-timeout-cleanup.md | 73 ++++++++++++ specs/14-prompt-worktree-timeout-cleanup.md | 71 ++++++++++++ specs/15-file-content-cache-bounds.md | 96 ++++++++++++++++ specs/16-worktree-waiter-dedup-and-prune.md | 90 +++++++++++++++ specs/17-permission-responded-bounds.md | 71 ++++++++++++ 18 files changed, 1185 insertions(+), 82 deletions(-) create mode 100644 specs/09-persist-cache-memory-bounds.md create mode 100644 specs/10-global-sync-message-part-cleanup.md create mode 100644 specs/11-layout-prefetch-memory-budget.md create mode 100644 specs/12-terminal-unmount-race-cleanup.md create mode 100644 specs/13-speech-recognition-timeout-cleanup.md create mode 100644 specs/14-prompt-worktree-timeout-cleanup.md create mode 100644 specs/15-file-content-cache-bounds.md create mode 100644 specs/16-worktree-waiter-dedup-and-prune.md create mode 100644 specs/17-permission-responded-bounds.md diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 1bd7aa4ebcd..9f038b6e83b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1557,13 +1557,17 @@ export const PromptInput: Component = (props) => { }) const timeoutMs = 5 * 60 * 1000 + const timer = { id: undefined as number | undefined } const timeout = new Promise>>((resolve) => { - setTimeout(() => { + timer.id = window.setTimeout(() => { resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) }, timeoutMs) }) - const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]) + const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => { + if (timer.id === undefined) return + clearTimeout(timer.id) + }) pending.delete(session.id) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 022369afeac..d388448024b 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -67,6 +67,19 @@ export const Terminal = (props: TerminalProps) => { let handleTextareaFocus: () => void let handleTextareaBlur: () => void let disposed = false + const cleanups: VoidFunction[] = [] + + const cleanup = () => { + if (!cleanups.length) return + const fns = cleanups.splice(0).reverse() + for (const fn of fns) { + try { + fn() + } catch { + // ignore + } + } + } const getTerminalColors = (): TerminalColors => { const mode = theme.mode() @@ -128,7 +141,7 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return const mod = loaded.mod - ghostty = loaded.ghostty + const g = loaded.ghostty const once = { value: false } @@ -138,6 +151,13 @@ export const Terminal = (props: TerminalProps) => { url.password = window.__OPENCODE__?.serverPassword } const socket = new WebSocket(url) + cleanups.push(() => { + if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() + }) + if (disposed) { + cleanup() + return + } ws = socket const t = new mod.Terminal({ @@ -148,8 +168,14 @@ export const Terminal = (props: TerminalProps) => { allowTransparency: true, theme: terminalColors(), scrollback: 10_000, - ghostty, + ghostty: g, }) + cleanups.push(() => t.dispose()) + if (disposed) { + cleanup() + return + } + ghostty = g term = t const copy = () => { @@ -201,13 +227,17 @@ export const Terminal = (props: TerminalProps) => { return false }) - fitAddon = new mod.FitAddon() - serializeAddon = new SerializeAddon() - t.loadAddon(serializeAddon) - t.loadAddon(fitAddon) + const fit = new mod.FitAddon() + const serializer = new SerializeAddon() + cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.()) + t.loadAddon(serializer) + t.loadAddon(fit) + fitAddon = fit + serializeAddon = serializer t.open(container) container.addEventListener("pointerdown", handlePointerDown) + cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown)) handleTextareaFocus = () => { t.options.cursorBlink = true @@ -218,6 +248,8 @@ export const Terminal = (props: TerminalProps) => { t.textarea?.addEventListener("focus", handleTextareaFocus) t.textarea?.addEventListener("blur", handleTextareaBlur) + cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus)) + cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur)) focusTerminal() @@ -233,10 +265,11 @@ export const Terminal = (props: TerminalProps) => { }) } - fitAddon.observeResize() - handleResize = () => fitAddon.fit() + fit.observeResize() + handleResize = () => fit.fit() window.addEventListener("resize", handleResize) - t.onResize(async (size) => { + cleanups.push(() => window.removeEventListener("resize", handleResize)) + const onResize = t.onResize(async (size) => { if (socket.readyState === WebSocket.OPEN) { await sdk.client.pty .update({ @@ -249,20 +282,24 @@ export const Terminal = (props: TerminalProps) => { .catch(() => {}) } }) - t.onData((data) => { + cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.()) + const onData = t.onData((data) => { if (socket.readyState === WebSocket.OPEN) { socket.send(data) } }) - t.onKey((key) => { + cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.()) + const onKey = t.onKey((key) => { if (key.key == "Enter") { props.onSubmit?.() } }) + cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.()) // t.onScroll((ydisp) => { // console.log("Scroll position:", ydisp) // }) - socket.addEventListener("open", () => { + + const handleOpen = () => { local.onConnect?.() sdk.client.pty .update({ @@ -273,18 +310,27 @@ export const Terminal = (props: TerminalProps) => { }, }) .catch(() => {}) - }) - socket.addEventListener("message", (event) => { + } + socket.addEventListener("open", handleOpen) + cleanups.push(() => socket.removeEventListener("open", handleOpen)) + + const handleMessage = (event: MessageEvent) => { t.write(event.data) - }) - socket.addEventListener("error", (error) => { + } + socket.addEventListener("message", handleMessage) + cleanups.push(() => socket.removeEventListener("message", handleMessage)) + + const handleError = (error: Event) => { if (disposed) return if (once.value) return once.value = true console.error("WebSocket error:", error) local.onConnectError?.(error) - }) - socket.addEventListener("close", (event) => { + } + socket.addEventListener("error", handleError) + cleanups.push(() => socket.removeEventListener("error", handleError)) + + const handleClose = (event: CloseEvent) => { if (disposed) return // Normal closure (code 1000) means PTY process exited - server event handles cleanup // For other codes (network issues, server restart), trigger error handler @@ -293,7 +339,9 @@ export const Terminal = (props: TerminalProps) => { once.value = true local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`)) } - }) + } + socket.addEventListener("close", handleClose) + cleanups.push(() => socket.removeEventListener("close", handleClose)) } void run().catch((err) => { @@ -309,13 +357,6 @@ export const Terminal = (props: TerminalProps) => { onCleanup(() => { disposed = true - if (handleResize) { - window.removeEventListener("resize", handleResize) - } - container.removeEventListener("pointerdown", handlePointerDown) - term?.textarea?.removeEventListener("focus", handleTextareaFocus) - term?.textarea?.removeEventListener("blur", handleTextareaBlur) - const t = term if (serializeAddon && props.onCleanup && t) { const buffer = (() => { @@ -334,8 +375,7 @@ export const Terminal = (props: TerminalProps) => { }) } - ws?.close() - t?.dispose() + cleanup() }) return ( diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 16deacfe837..7509334edb7 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -151,6 +151,28 @@ const WORKSPACE_KEY = "__workspace__" const MAX_FILE_VIEW_SESSIONS = 20 const MAX_VIEW_FILES = 500 +const MAX_FILE_CONTENT_ENTRIES = 40 +const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 + +const contentLru = new Map() + +function approxBytes(content: FileContent) { + const patchBytes = + content.patch?.hunks.reduce((total, hunk) => { + return total + hunk.lines.reduce((sum, line) => sum + line.length, 0) + }, 0) ?? 0 + + return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2 +} + +function touchContent(path: string, bytes?: number) { + const prev = contentLru.get(path) + if (prev === undefined && bytes === undefined) return + const value = bytes ?? prev ?? 0 + contentLru.delete(path) + contentLru.set(path, value) +} + type ViewSession = ReturnType type ViewCacheEntry = { @@ -315,10 +337,40 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ dir: { "": { expanded: true } }, }) + const evictContent = (keep?: Set) => { + const protectedSet = keep ?? new Set() + const total = () => { + return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0) + } + + while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) { + const path = contentLru.keys().next().value + if (!path) return + + if (protectedSet.has(path)) { + touchContent(path) + if (contentLru.size <= protectedSet.size) return + continue + } + + contentLru.delete(path) + if (!store.file[path]) continue + setStore( + "file", + path, + produce((draft) => { + draft.content = undefined + draft.loaded = false + }), + ) + } + } + createEffect(() => { scope() inflight.clear() treeInflight.clear() + contentLru.clear() setStore("file", {}) setTree("node", {}) setTree("dir", { "": { expanded: true } }) @@ -399,15 +451,20 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ .read({ path }) .then((x) => { if (scope() !== directory) return + const content = x.data setStore( "file", path, produce((draft) => { draft.loaded = true draft.loading = false - draft.content = x.data + draft.content = content }), ) + + if (!content) return + touchContent(path, approxBytes(content)) + evictContent(new Set([path])) }) .catch((e) => { if (scope() !== directory) return @@ -597,7 +654,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ listDir(parent, { force: true }) }) - const get = (input: string) => store.file[normalize(input)] + const get = (input: string) => { + const path = normalize(input) + const file = store.file[path] + const content = file?.content + if (!content) return file + if (contentLru.has(path)) { + touchContent(path) + return file + } + touchContent(path, approxBytes(content)) + return file + } const scrollTop = (input: string) => view().scrollTop(normalize(input)) const scrollLeft = (input: string) => view().scrollLeft(normalize(input)) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 4efbf62aa18..fb67193ab66 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -546,6 +546,37 @@ function createGlobalSync() { return promise } + function purgeMessageParts(setStore: SetStoreFunction, messageID: string | undefined) { + if (!messageID) return + setStore( + produce((draft) => { + delete draft.part[messageID] + }), + ) + } + + function purgeSessionData(store: Store, setStore: SetStoreFunction, sessionID: string | undefined) { + if (!sessionID) return + + const messages = store.message[sessionID] + const messageIDs = (messages ?? []).map((m) => m.id).filter((id): id is string => !!id) + + setStore( + produce((draft) => { + delete draft.message[sessionID] + delete draft.session_diff[sessionID] + delete draft.todo[sessionID] + delete draft.permission[sessionID] + delete draft.question[sessionID] + delete draft.session_status[sessionID] + + for (const messageID of messageIDs) { + delete draft.part[messageID] + } + }), + ) + } + const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details @@ -651,9 +682,7 @@ function createGlobalSync() { }), ) } - cleanupSessionCaches(info.id) - if (info.parentID) break setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -679,9 +708,7 @@ function createGlobalSync() { }), ) } - cleanupSessionCaches(sessionID) - if (event.properties.info.parentID) break setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -757,15 +784,19 @@ function createGlobalSync() { break } case "message.part.removed": { - const parts = store.part[event.properties.messageID] + const messageID = event.properties.messageID + const parts = store.part[messageID] if (!parts) break const result = Binary.search(parts, event.properties.partID, (p) => p.id) if (result.found) { setStore( - "part", - event.properties.messageID, produce((draft) => { - draft.splice(result.index, 1) + const list = draft.part[messageID] + if (!list) return + const next = Binary.search(list, event.properties.partID, (p) => p.id) + if (!next.found) return + list.splice(next.index, 1) + if (list.length === 0) delete draft.part[messageID] }), ) } diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index d85f2ef2410..a701dbd1fec 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -67,7 +67,21 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }), ) - const responded = new Set() + const MAX_RESPONDED = 1000 + const RESPONDED_TTL_MS = 60 * 60 * 1000 + const responded = new Map() + + function pruneResponded(now: number) { + for (const [id, ts] of responded) { + if (now - ts < RESPONDED_TTL_MS) break + responded.delete(id) + } + + for (const id of responded.keys()) { + if (responded.size <= MAX_RESPONDED) break + responded.delete(id) + } + } const respond: PermissionRespondFn = (input) => { globalSDK.client.permission.respond(input).catch(() => { @@ -76,8 +90,12 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple } function respondOnce(permission: PermissionRequest, directory?: string) { - if (responded.has(permission.id)) return - responded.add(permission.id) + const now = Date.now() + const hit = responded.has(permission.id) + responded.delete(permission.id) + responded.set(permission.id, now) + pruneResponded(now) + if (hit) return respond({ sessionID: permission.sessionID, permissionID: permission.id, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 82a3fa6c91e..1328b96bebd 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -685,12 +685,34 @@ export default function Layout(props: ParentProps) { running: number } - const prefetchChunk = 600 + const prefetchChunk = 200 const prefetchConcurrency = 1 const prefetchPendingLimit = 6 const prefetchToken = { value: 0 } const prefetchQueues = new Map() + const PREFETCH_MAX_SESSIONS_PER_DIR = 10 + const prefetchedByDir = new Map>() + + const lruFor = (directory: string) => { + const existing = prefetchedByDir.get(directory) + if (existing) return existing + const created = new Map() + prefetchedByDir.set(directory, created) + return created + } + + const markPrefetched = (directory: string, sessionID: string) => { + const lru = lruFor(directory) + if (lru.has(sessionID)) lru.delete(sessionID) + lru.set(sessionID, true) + while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) { + const oldest = lru.keys().next().value as string | undefined + if (!oldest) return + lru.delete(oldest) + } + } + createEffect(() => { params.dir globalSDK.url @@ -783,6 +805,11 @@ export default function Layout(props: ParentProps) { if (q.inflight.has(session.id)) return if (q.pendingSet.has(session.id)) return + const lru = lruFor(directory) + const known = lru.has(session.id) + if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return + markPrefetched(directory, session.id) + if (priority === "high") q.pending.unshift(session.id) if (priority !== "high") q.pending.push(session.id) q.pendingSet.add(session.id) @@ -1669,6 +1696,22 @@ export default function Layout(props: ParentProps) { pendingRename: false, }) + const hoverPrefetch = { current: undefined as ReturnType | undefined } + const cancelHoverPrefetch = () => { + if (hoverPrefetch.current === undefined) return + clearTimeout(hoverPrefetch.current) + hoverPrefetch.current = undefined + } + const scheduleHoverPrefetch = () => { + if (hoverPrefetch.current !== undefined) return + hoverPrefetch.current = setTimeout(() => { + hoverPrefetch.current = undefined + prefetchSession(props.session) + }, 200) + } + + onCleanup(cancelHoverPrefetch) + const messageLabel = (message: Message) => { const parts = sessionStore.part[message.id] ?? [] const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) @@ -1679,7 +1722,10 @@ export default function Layout(props: ParentProps) { prefetchSession(props.session, "high")} + onPointerEnter={scheduleHoverPrefetch} + onPointerLeave={cancelHoverPrefetch} + onMouseEnter={scheduleHoverPrefetch} + onMouseLeave={cancelHoverPrefetch} onFocus={() => prefetchSession(props.session, "high")} onClick={() => { setState("hoverSession", undefined) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 129695f8649..0ca3abad069 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -18,7 +18,52 @@ const LEGACY_STORAGE = "default.dat" const GLOBAL_STORAGE = "opencode.global.dat" const LOCAL_PREFIX = "opencode." const fallback = { disabled: false } -const cache = new Map() + +const CACHE_MAX_ENTRIES = 500 +const CACHE_MAX_BYTES = 8 * 1024 * 1024 + +type CacheEntry = { value: string; bytes: number } +const cache = new Map() +const cacheTotal = { bytes: 0 } + +function cacheDelete(key: string) { + const entry = cache.get(key) + if (!entry) return + cacheTotal.bytes -= entry.bytes + cache.delete(key) +} + +function cachePrune() { + for (;;) { + if (cache.size <= CACHE_MAX_ENTRIES && cacheTotal.bytes <= CACHE_MAX_BYTES) return + const oldest = cache.keys().next().value as string | undefined + if (!oldest) return + cacheDelete(oldest) + } +} + +function cacheSet(key: string, value: string) { + const bytes = value.length * 2 + if (bytes > CACHE_MAX_BYTES) { + cacheDelete(key) + return + } + + const entry = cache.get(key) + if (entry) cacheTotal.bytes -= entry.bytes + cache.delete(key) + cache.set(key, { value, bytes }) + cacheTotal.bytes += bytes + cachePrune() +} + +function cacheGet(key: string) { + const entry = cache.get(key) + if (!entry) return + cache.delete(key) + cache.set(key, entry) + return entry.value +} function quota(error: unknown) { if (error instanceof DOMException) { @@ -63,9 +108,11 @@ function evict(storage: Storage, keep: string, value: string) { for (const item of items) { storage.removeItem(item.key) + cacheDelete(item.key) try { storage.setItem(keep, value) + cacheSet(keep, value) return true } catch (error) { if (!quota(error)) throw error @@ -78,6 +125,7 @@ function evict(storage: Storage, keep: string, value: string) { function write(storage: Storage, key: string, value: string) { try { storage.setItem(key, value) + cacheSet(key, value) return true } catch (error) { if (!quota(error)) throw error @@ -85,13 +133,17 @@ function write(storage: Storage, key: string, value: string) { try { storage.removeItem(key) + cacheDelete(key) storage.setItem(key, value) + cacheSet(key, value) return true } catch (error) { if (!quota(error)) throw error } - return evict(storage, key, value) + const ok = evict(storage, key, value) + if (!ok) cacheSet(key, value) + return ok } function snapshot(value: unknown) { @@ -148,7 +200,7 @@ function localStorageWithPrefix(prefix: string): SyncStorage { return { getItem: (key) => { const name = item(key) - const cached = cache.get(name) + const cached = cacheGet(name) if (fallback.disabled && cached !== undefined) return cached const stored = (() => { @@ -160,12 +212,12 @@ function localStorageWithPrefix(prefix: string): SyncStorage { } })() if (stored === null) return cached ?? null - cache.set(name, stored) + cacheSet(name, stored) return stored }, setItem: (key, value) => { const name = item(key) - cache.set(name, value) + cacheSet(name, value) if (fallback.disabled) return try { if (write(localStorage, name, value)) return @@ -177,7 +229,7 @@ function localStorageWithPrefix(prefix: string): SyncStorage { }, removeItem: (key) => { const name = item(key) - cache.delete(name) + cacheDelete(name) if (fallback.disabled) return try { localStorage.removeItem(name) @@ -191,7 +243,7 @@ function localStorageWithPrefix(prefix: string): SyncStorage { function localStorageDirect(): SyncStorage { return { getItem: (key) => { - const cached = cache.get(key) + const cached = cacheGet(key) if (fallback.disabled && cached !== undefined) return cached const stored = (() => { @@ -203,11 +255,11 @@ function localStorageDirect(): SyncStorage { } })() if (stored === null) return cached ?? null - cache.set(key, stored) + cacheSet(key, stored) return stored }, setItem: (key, value) => { - cache.set(key, value) + cacheSet(key, value) if (fallback.disabled) return try { if (write(localStorage, key, value)) return @@ -218,7 +270,7 @@ function localStorageDirect(): SyncStorage { fallback.disabled = true }, removeItem: (key) => { - cache.delete(key) + cacheDelete(key) if (fallback.disabled) return try { localStorage.removeItem(key) diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts index c8acf5241c1..201c1261bd1 100644 --- a/packages/app/src/utils/speech.ts +++ b/packages/app/src/utils/speech.ts @@ -78,6 +78,7 @@ export function createSpeechRecognition(opts?: { let lastInterimSuffix = "" let shrinkCandidate: string | undefined let commitTimer: number | undefined + let restartTimer: number | undefined const cancelPendingCommit = () => { if (commitTimer === undefined) return @@ -85,6 +86,26 @@ export function createSpeechRecognition(opts?: { commitTimer = undefined } + const clearRestart = () => { + if (restartTimer === undefined) return + window.clearTimeout(restartTimer) + restartTimer = undefined + } + + const scheduleRestart = () => { + clearRestart() + if (!shouldContinue) return + if (!recognition) return + restartTimer = window.setTimeout(() => { + restartTimer = undefined + if (!shouldContinue) return + if (!recognition) return + try { + recognition.start() + } catch {} + }, 150) + } + const commitSegment = (segment: string) => { const nextCommitted = appendSegment(committedText, segment) if (nextCommitted === committedText) return @@ -214,17 +235,14 @@ export function createSpeechRecognition(opts?: { } recognition.onerror = (e: { error: string }) => { + clearRestart() cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined if (e.error === "no-speech" && shouldContinue) { setStore("interim", "") if (opts?.onInterim) opts.onInterim("") - setTimeout(() => { - try { - recognition?.start() - } catch {} - }, 150) + scheduleRestart() return } shouldContinue = false @@ -232,6 +250,7 @@ export function createSpeechRecognition(opts?: { } recognition.onstart = () => { + clearRestart() sessionCommitted = "" pendingHypothesis = "" cancelPendingCommit() @@ -243,22 +262,20 @@ export function createSpeechRecognition(opts?: { } recognition.onend = () => { + clearRestart() cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined setStore("isRecording", false) if (shouldContinue) { - setTimeout(() => { - try { - recognition?.start() - } catch {} - }, 150) + scheduleRestart() } } } const start = () => { if (!recognition) return + clearRestart() shouldContinue = true sessionCommitted = "" pendingHypothesis = "" @@ -274,6 +291,7 @@ export function createSpeechRecognition(opts?: { const stop = () => { if (!recognition) return shouldContinue = false + clearRestart() promotePending() cancelPendingCommit() lastInterimSuffix = "" @@ -287,6 +305,7 @@ export function createSpeechRecognition(opts?: { onCleanup(() => { shouldContinue = false + clearRestart() promotePending() cancelPendingCommit() lastInterimSuffix = "" diff --git a/packages/app/src/utils/worktree.ts b/packages/app/src/utils/worktree.ts index 7c0055920b7..581afd5535e 100644 --- a/packages/app/src/utils/worktree.ts +++ b/packages/app/src/utils/worktree.ts @@ -13,7 +13,21 @@ type State = } const state = new Map() -const waiters = new Map void>>() +const waiters = new Map< + string, + { + promise: Promise + resolve: (state: State) => void + } +>() + +function deferred() { + const box = { resolve: (_: State) => {} } + const promise = new Promise((resolve) => { + box.resolve = resolve + }) + return { promise, resolve: box.resolve } +} export const Worktree = { get(directory: string) { @@ -27,32 +41,33 @@ export const Worktree = { }, ready(directory: string) { const key = normalize(directory) - state.set(key, { status: "ready" }) - const list = waiters.get(key) - if (!list) return + const next = { status: "ready" } as const + state.set(key, next) + const waiter = waiters.get(key) + if (!waiter) return waiters.delete(key) - for (const fn of list) fn({ status: "ready" }) + waiter.resolve(next) }, failed(directory: string, message: string) { const key = normalize(directory) - state.set(key, { status: "failed", message }) - const list = waiters.get(key) - if (!list) return + const next = { status: "failed", message } as const + state.set(key, next) + const waiter = waiters.get(key) + if (!waiter) return waiters.delete(key) - for (const fn of list) fn({ status: "failed", message }) + waiter.resolve(next) }, wait(directory: string) { const key = normalize(directory) const current = state.get(key) if (current && current.status !== "pending") return Promise.resolve(current) - return new Promise((resolve) => { - const list = waiters.get(key) - if (!list) { - waiters.set(key, [resolve]) - return - } - list.push(resolve) - }) + const existing = waiters.get(key) + if (existing) return existing.promise + + const waiter = deferred() + + waiters.set(key, waiter) + return waiter.promise }, } diff --git a/specs/09-persist-cache-memory-bounds.md b/specs/09-persist-cache-memory-bounds.md new file mode 100644 index 00000000000..e96dec780f4 --- /dev/null +++ b/specs/09-persist-cache-memory-bounds.md @@ -0,0 +1,108 @@ +## Persist in-memory cache bounds + +Fix unbounded `persist.ts` string cache growth + +--- + +### Summary + +`packages/app/src/utils/persist.ts` maintains a module-level `cache: Map()` that mirrors values written to storage. This cache can retain very large JSON strings (prompt history, image dataUrls, terminal buffers) indefinitely, even after the underlying `localStorage` keys are evicted. Over long sessions this can become an in-process memory leak. + +This spec adds explicit bounds (entries + approximate bytes) and makes eviction/removal paths delete from the in-memory cache. + +--- + +### Scoped files (parallel-safe) + +- `packages/app/src/utils/persist.ts` + +--- + +### Goals + +- Prevent unbounded memory growth from the module-level persist cache +- Ensure keys removed/evicted from storage are also removed from the in-memory cache +- Preserve current semantics when `localStorage` access throws (fallback mode) +- Keep changes self-contained to `persist.ts` + +--- + +### Non-goals + +- Changing persisted schemas or moving payloads out of KV storage (covered elsewhere) +- Introducing a shared cache utility used by multiple modules + +--- + +### Current state + +- `cache` stores raw strings for every key read/written. +- `evict()` removes items from `localStorage` but does not clear the corresponding entries from `cache`. +- When `localStorage` is unavailable (throws), reads fall back to `cache`, so the cache is also a functional in-memory persistence layer. + +--- + +### Proposed approach + +1. Replace `cache: Map` with a bounded LRU-like map + +- Store `{ value: string, bytes: number }` per key. +- Maintain a running `totalBytes`. +- Enforce caps: + - `CACHE_MAX_ENTRIES` (e.g. 500) + - `CACHE_MAX_BYTES` (e.g. 8 _ 1024 _ 1024) +- Use Map insertion order as LRU: + - On `get`, re-insert the key to the end. + - On `set`, insert/update then evict oldest until within bounds. +- Approximate bytes as `value.length * 2` (UTF-16) to avoid `TextEncoder` allocations. + +2. Ensure all removal paths clear the in-memory cache + +- In `localStorageDirect().removeItem` and `localStorageWithPrefix().removeItem`: already calls `cache.delete(name)`; keep. +- In `write(...)` failure recovery where it calls `storage.removeItem(key)`: also `cache.delete(key)`. +- In `evict(...)` loop where it removes large keys: also `cache.delete(item.key)`. + +3. Add a small dev-only diagnostic (optional) + +- In dev, expose a lightweight `cacheStats()` helper (entries, totalBytes) for debugging memory reports. + +--- + +### Implementation steps + +1. Introduce constants and cache helpers in `persist.ts` + +- `const CACHE_MAX_ENTRIES = ...` +- `const CACHE_MAX_BYTES = ...` +- `function cacheSet(key: string, value: string)` +- `function cacheGet(key: string): string | undefined` +- `function cacheDelete(key: string)` +- `function cachePrune()` + +2. Route all existing `cache.set/get/delete` calls through helpers + +- `localStorageDirect()` +- `localStorageWithPrefix()` + +3. Update `evict()` and `write()` to delete from cache when deleting from storage + +4. (Optional) Add dev logging guardrails + +- If a single value exceeds `CACHE_MAX_BYTES`, cache it but immediately prune older keys (or refuse to cache it and keep behavior consistent). + +--- + +### Acceptance criteria + +- Repeatedly loading/saving large persisted values does not cause unbounded `cache` growth (entries and bytes are capped) +- Values removed from `localStorage` by `evict()` are not returned later via the in-memory cache +- App remains functional when `localStorage` throws (fallback mode still returns cached values, subject to caps) + +--- + +### Validation plan + +- Manual: + - Open app, perform actions that write large state (image attachments, terminal output, long sessions) + - Use browser memory tools to confirm JS heap does not grow linearly with repeated writes + - Simulate quota eviction (force small storage quota / fill storage) and confirm `cache` does not retain evicted keys indefinitely diff --git a/specs/10-global-sync-message-part-cleanup.md b/specs/10-global-sync-message-part-cleanup.md new file mode 100644 index 00000000000..bb596dfb62f --- /dev/null +++ b/specs/10-global-sync-message-part-cleanup.md @@ -0,0 +1,106 @@ +## GlobalSync message/part cleanup + +Prevent stale message parts and per-session maps from accumulating + +--- + +### Summary + +`packages/app/src/context/global-sync.tsx` keeps per-directory child stores that include: + +- `message: { [sessionID]: Message[] }` +- `part: { [messageID]: Part[] }` + +Currently: + +- `message.removed` removes the message from `message[sessionID]` but does not delete `part[messageID]`. +- `session.deleted` / archived sessions remove the session from the list but do not clear `message[...]`, `part[...]`, `session_diff[...]`, `todo[...]`, etc. + +This can retain large arrays long after they are no longer reachable from UI. + +This spec adds explicit cleanup on the relevant events without changing cache strategy or introducing new cross-module eviction utilities. + +--- + +### Scoped files (parallel-safe) + +- `packages/app/src/context/global-sync.tsx` + +--- + +### Goals + +- Delete `part[messageID]` when a message is removed +- Clear per-session maps when a session is deleted or archived +- Keep changes limited to event handling in `global-sync.tsx` + +--- + +### Non-goals + +- Implementing LRU/TTL eviction across sessions/directories (separate work) +- Changing how `sync.tsx` or layout prefetch populates message caches + +--- + +### Current state + +- Message list and part map can diverge over time. +- Deleting/archiving sessions does not reclaim memory for message history, parts, diffs, todos, permissions, questions. + +--- + +### Proposed approach + +Add small helper functions inside `createGlobalSync()` to keep event handler readable: + +- `purgeMessageParts(setStore, messageID)` +- `purgeSessionData(store, setStore, sessionID)` + - delete `message[sessionID]` + - for each known message in that list, delete `part[messageID]` + - delete `session_diff[sessionID]`, `todo[sessionID]`, `permission[sessionID]`, `question[sessionID]`, `session_status[sessionID]` + +Then wire these into the existing event switch: + +- `message.removed`: after removing from `message[sessionID]`, also delete `part[messageID]` +- `session.updated` when `time.archived` is set: call `purgeSessionData(...)` +- `session.deleted`: call `purgeSessionData(...)` + +Notes: + +- Use `setStore(produce(...))` to `delete` object keys safely. +- Purge should be idempotent (safe if called when keys are missing). + +--- + +### Implementation steps + +1. Add purge helpers near the event listener in `global-sync.tsx` + +2. Update event handlers + +- `message.removed` +- `session.updated` (archived path) +- `session.deleted` + +3. (Optional) Tighten `message.part.removed` + +- If a part list becomes empty after removal, optionally delete `part[messageID]` as well. + +--- + +### Acceptance criteria + +- After a `message.removed` event, `store.part[messageID]` is `undefined` +- After `session.deleted` or archive, all per-session maps for that `sessionID` are removed +- No runtime errors when purging sessions/messages that were never hydrated + +--- + +### Validation plan + +- Manual: + - Load a session with many messages + - Trigger message delete/remove events (or simulate by calling handlers in dev) + - Confirm the associated `part` entries are removed + - Delete/archive a session and confirm globalSync store no longer holds its message/part data diff --git a/specs/11-layout-prefetch-memory-budget.md b/specs/11-layout-prefetch-memory-budget.md new file mode 100644 index 00000000000..6c35f515501 --- /dev/null +++ b/specs/11-layout-prefetch-memory-budget.md @@ -0,0 +1,103 @@ +## Layout prefetch memory budget + +Reduce sidebar hover prefetch from ballooning message caches + +--- + +### Summary + +`packages/app/src/pages/layout.tsx` prefetches message history into `globalSync.child(directory)`. + +On hover and navigation, the current implementation can prefetch many sessions (each up to `prefetchChunk = 600` messages + parts). Since the global sync store has no eviction today, scanning the sidebar can permanently grow memory. + +This spec limits how much we prefetch (chunk size + number of sessions) and adds debounced hover prefetch to avoid accidental flooding. + +--- + +### Scoped files (parallel-safe) + +- `packages/app/src/pages/layout.tsx` + +--- + +### Goals + +- Reduce the maximum amount of data prefetched per session +- Limit the number of sessions that can be prefetched per directory per app lifetime +- Avoid triggering prefetch for brief/accidental hovers +- Keep changes local to `layout.tsx` + +--- + +### Non-goals + +- Implementing eviction of already-prefetched data inside global sync (separate work) +- Changing server APIs + +--- + +### Current state + +- `prefetchChunk` is 600. +- Hovering many sessions can enqueue many prefetches over time. +- Once prefetched, message/part data remains in memory until reload. + +--- + +### Proposed approach + +1. Lower the prefetch page size + +- Change `prefetchChunk` from 600 to a smaller value (e.g. 200). +- Rationale: prefetch is for fast first render; the session page can load more as needed. + +2. Add a per-directory prefetch budget + +- Track a small LRU of prefetched session IDs per directory (Map insertion order). +- Add `PREFETCH_MAX_SESSIONS_PER_DIR` (e.g. 8-12). +- Before queueing a new prefetch: + - if already cached in `store.message[sessionID]`, allow + - else if budget exceeded and priority is not `high`, skip + - else allow and record in LRU + +3. Debounce hover-triggered prefetch + +- For sidebar session entries that call `prefetchSession(..., "high")` on hover: + - schedule after ~150-250ms + - cancel if pointer leaves before the timer fires + +--- + +### Implementation steps + +1. Update constants in `layout.tsx` + +- `prefetchChunk` +- add `prefetchMaxSessionsPerDir` + +2. Add `prefetchedByDir: Map>` (or similar) + +- Helper: `markPrefetched(directory, sessionID)` with LRU behavior and max size. +- Helper: `canPrefetch(directory, sessionID, priority)`. + +3. Integrate checks into `prefetchSession(...)` + +4. Debounce hover prefetch in the sidebar session item component (still in `layout.tsx`) + +--- + +### Acceptance criteria + +- Prefetch requests never fetch more than the new `prefetchChunk` messages per session +- For a given directory, total prefetched sessions does not exceed the configured budget (except current/adjacent high-priority navigations if explicitly allowed) +- Rapid mouse movement over the session list does not trigger a prefetch storm + +--- + +### Validation plan + +- Manual: + - Open sidebar with many sessions + - Move cursor over session list quickly; confirm few/no prefetch requests + - Hover intentionally; confirm prefetch happens after debounce + - Confirm the number of prefetched sessions per directory stays capped (via dev logging or inspecting store) diff --git a/specs/12-terminal-unmount-race-cleanup.md b/specs/12-terminal-unmount-race-cleanup.md new file mode 100644 index 00000000000..53fd2afb9f2 --- /dev/null +++ b/specs/12-terminal-unmount-race-cleanup.md @@ -0,0 +1,92 @@ +## Terminal unmount race cleanup + +Prevent Ghostty Terminal/WebSocket leaks when unmounting mid-init + +--- + +### Summary + +`packages/app/src/components/terminal.tsx` initializes Ghostty in `onMount` via async steps (`import("ghostty-web")`, `Ghostty.load()`, WebSocket creation, terminal creation, listeners). If the component unmounts while awaits are pending, `onCleanup` runs before `ws`/`term` exist. The async init can then continue and create resources that never get disposed. + +This spec makes initialization abortable and ensures resources created after unmount are immediately cleaned up. + +--- + +### Scoped files (parallel-safe) + +- `packages/app/src/components/terminal.tsx` + +--- + +### Goals + +- Never leave a WebSocket open after the terminal component unmounts +- Never leave window/container/textarea event listeners attached after unmount +- Avoid creating terminal resources if `disposed` is already true + +--- + +### Non-goals + +- Reworking terminal buffering/persistence format +- Changing PTY server protocol + +--- + +### Current state + +- `disposed` is checked in some WebSocket event handlers, but not during async init. +- `onCleanup` closes/disposes only the resources already assigned at cleanup time. + +--- + +### Proposed approach + +1. Guard async init steps + +- After each `await`, check `disposed` and return early. + +2. Register cleanups as resources are created + +- Maintain an array of cleanup callbacks (`cleanups: VoidFunction[]`). +- When creating `socket`, `term`, adding event listeners, etc., push the corresponding cleanup. +- In `onCleanup`, run all registered cleanups exactly once. + +3. Avoid mutating shared vars until safe + +- Prefer local variables inside `run()` and assign to outer `ws`/`term` only after confirming not disposed. + +--- + +### Implementation steps + +1. Add `const cleanups: VoidFunction[] = []` and `const cleanup = () => { ... }` in component scope + +2. In `onCleanup`, set `disposed = true` and call `cleanup()` + +3. In `run()`: + +- `await import(...)` -> if disposed return +- `await Ghostty.load()` -> if disposed return +- create WebSocket -> if disposed, close it and return +- create Terminal -> if disposed, dispose + close socket and return +- when adding listeners, register removers in `cleanups` + +4. Ensure `cleanup()` is idempotent + +--- + +### Acceptance criteria + +- Rapidly mounting/unmounting terminal components does not leave open WebSockets +- No `resize` listeners remain after unmount +- No errors are thrown if unmount occurs mid-initialization + +--- + +### Validation plan + +- Manual: + - Open a session and rapidly switch sessions/tabs to force terminal unmount/mount + - Verify via devtools that no orphan WebSocket connections remain + - Verify that terminal continues to work normally when kept mounted diff --git a/specs/13-speech-recognition-timeout-cleanup.md b/specs/13-speech-recognition-timeout-cleanup.md new file mode 100644 index 00000000000..701bd17364e --- /dev/null +++ b/specs/13-speech-recognition-timeout-cleanup.md @@ -0,0 +1,73 @@ +## Speech recognition timeout cleanup + +Stop stray restart timers from keeping recognition alive + +--- + +### Summary + +`packages/app/src/utils/speech.ts` schedules 150ms `setTimeout` restarts in `recognition.onerror` and `recognition.onend` when `shouldContinue` is true. These timers are not tracked or cleared, so they can fire after `stop()`/cleanup and call `recognition.start()`, keeping recognition + closures alive unexpectedly. + +This spec tracks restart timers explicitly and clears them on stop/cleanup. + +--- + +### Scoped files (parallel-safe) + +- `packages/app/src/utils/speech.ts` + +--- + +### Goals + +- Ensure no restart timers remain scheduled after `stop()` or `onCleanup` +- Prevent `recognition.start()` from being called after cleanup +- Keep behavior identical in the normal recording flow + +--- + +### Non-goals + +- Changing the recognition UX/state machine beyond timer tracking + +--- + +### Proposed approach + +- Add `let restartTimer: number | undefined`. +- Add helpers: + - `clearRestart()` + - `scheduleRestart()` (guards `shouldContinue` + `recognition`) +- Replace both raw `setTimeout(..., 150)` uses with `window.setTimeout` stored in `restartTimer`. +- Call `clearRestart()` in: + - `start()` + - `stop()` + - `onCleanup(...)` + - `recognition.onstart` (reset state) + - any path that exits recording due to error + +--- + +### Implementation steps + +1. Introduce `restartTimer` and helpers + +2. Replace `setTimeout(() => recognition?.start(), 150)` occurrences + +3. Clear the timer in all stop/cleanup paths + +--- + +### Acceptance criteria + +- After calling `stop()` or disposing the creator, there are no delayed restarts +- No unexpected `recognition.start()` calls occur after recording is stopped + +--- + +### Validation plan + +- Manual: + - Start/stop recording repeatedly + - Trigger a `no-speech` error and confirm restarts only happen while recording is active + - Navigate away/unmount the component using `createSpeechRecognition` and confirm no restarts happen afterward diff --git a/specs/14-prompt-worktree-timeout-cleanup.md b/specs/14-prompt-worktree-timeout-cleanup.md new file mode 100644 index 00000000000..4abe884f91b --- /dev/null +++ b/specs/14-prompt-worktree-timeout-cleanup.md @@ -0,0 +1,71 @@ +## Prompt worktree timeout cleanup + +Clear `waitForWorktree()` timers to avoid retaining closures for 5 minutes + +--- + +### Summary + +`packages/app/src/components/prompt-input.tsx` creates a 5-minute `setTimeout` inside `waitForWorktree()` as part of a `Promise.race`. If the worktree becomes ready quickly, the timeout still stays scheduled until it fires, retaining its closure (which can capture session and UI state) for up to 5 minutes. Repeated sends can accumulate many concurrent long-lived timers. + +This spec makes the timeout cancelable and clears it when the race finishes. + +--- + +### Scoped files (parallel-safe) + +- `packages/app/src/components/prompt-input.tsx` + +--- + +### Goals + +- Ensure the 5-minute timeout is cleared as soon as `Promise.race` resolves +- Avoid retaining large closures unnecessarily +- Keep behavior identical for real timeouts + +--- + +### Non-goals + +- Changing the worktree wait UX +- Changing the WorktreeState API + +--- + +### Proposed approach + +- Track the timeout handle explicitly: + - `let timeoutId: number | undefined` + - `timeoutId = window.setTimeout(...)` + +- After `Promise.race(...)` resolves (success, abort, or timeout), call `clearTimeout(timeoutId)` when set. + +- Keep the existing 5-minute duration and result handling. + +--- + +### Implementation steps + +1. In `waitForWorktree()` create the timeout promise with an outer `timeoutId` variable + +2. After awaiting the race, clear the timeout if it exists + +3. Ensure `pending.delete(session.id)` and UI cleanup behavior remains unchanged + +--- + +### Acceptance criteria + +- When the worktree becomes ready quickly, no 5-minute timeout remains scheduled +- When the worktree truly times out, behavior is unchanged (same error shown, same cleanup) + +--- + +### Validation plan + +- Manual: + - Trigger prompt send in a directory that is already ready; confirm no long timers remain (devtools) + - Trigger a worktree pending state and confirm: + - timeout fires at ~5 minutes + - cleanup runs diff --git a/specs/15-file-content-cache-bounds.md b/specs/15-file-content-cache-bounds.md new file mode 100644 index 00000000000..e44dd5d9e9b --- /dev/null +++ b/specs/15-file-content-cache-bounds.md @@ -0,0 +1,96 @@ +## File content cache bounds + +Add explicit caps for loaded file contents in `FileProvider` + +--- + +### Summary + +`packages/app/src/context/file.tsx` caches file contents in-memory (`store.file[path].content`) for every loaded file within the current directory scope. Over a long session (reviewing many diffs/files), this can grow without bound. + +This spec adds an in-module LRU + size cap for file _contents_ only, keeping metadata entries while evicting the heavy payload. + +--- + +### Scoped files (parallel-safe) + +- `packages/app/src/context/file.tsx` + +--- + +### Goals + +- Cap the number of in-memory file contents retained per directory +- Optionally cap total approximate bytes across loaded contents +- Avoid evicting content that is actively being used/rendered +- Keep changes localized to `file.tsx` + +--- + +### Non-goals + +- Changing persisted file-view state (`file-view`) limits (already pruned separately) +- Introducing a shared cache utility used elsewhere + +--- + +### Proposed approach + +1. Track content entries in an LRU Map + +- `const contentLru = new Map()` where value is approximate bytes. +- On successful `load(path)` completion, call `touchContent(path, bytes)`. +- In `get(path)`, if the file has `content`, call `touchContent(path)` to keep active files hot. + +2. Evict least-recently-used contents when over cap + +- Add constants: + - `MAX_FILE_CONTENT_ENTRIES` (e.g. 30-50) + - `MAX_FILE_CONTENT_BYTES` (optional; e.g. 10-25MB) +- When evicting a path: + - remove it from `contentLru` + - clear `store.file[path].content` + - set `store.file[path].loaded = false` (or keep loaded but ensure UI can reload) + +3. Reset LRU on directory scope change + +- The existing scope reset already clears `store.file`; also clear the LRU map. + +--- + +### Implementation steps + +1. Add LRU state + helper functions + +- `approxBytes(fileContent)` (prefer `content.length * 2`) +- `touchContent(path, bytes?)` +- `evictContent(keep?: Set)` + +2. Touch on content load + +- After setting `draft.content = x.data`, compute bytes and touch + +3. Touch on `get()` usage + +- If `store.file[path]?.content` exists, touch + +4. Evict on every content set + +- After `touchContent`, run eviction until within caps + +--- + +### Acceptance criteria + +- Loading hundreds of files does not grow memory linearly; content retention plateaus +- Actively viewed file content is not evicted under normal use +- Evicted files can be reloaded correctly when accessed again + +--- + +### Validation plan + +- Manual: + - Load many distinct files (e.g. via review tab) + - Confirm only the latest N files retain `content` + - Switch back to an older file; confirm it reloads without UI errors diff --git a/specs/16-worktree-waiter-dedup-and-prune.md b/specs/16-worktree-waiter-dedup-and-prune.md new file mode 100644 index 00000000000..eaab062e7de --- /dev/null +++ b/specs/16-worktree-waiter-dedup-and-prune.md @@ -0,0 +1,90 @@ +## Worktree waiter dedup + pruning + +Prevent `Worktree.wait()` from accumulating resolver closures + +--- + +### Summary + +`packages/app/src/utils/worktree.ts` stores `waiters` as `Map void>>`. If multiple callers call `wait()` while a directory is pending (or when callers stop awaiting due to their own timeouts), resolver closures can accumulate until a `ready()`/`failed()` event arrives. + +In this app, `Worktree.wait()` is used inside a `Promise.race` with a timeout, so it is possible to create many resolvers that remain stored for a long time. + +This spec changes `wait()` to share a single promise per directory key (dedup), eliminating unbounded waiter arrays. Optionally, it prunes resolved state entries to keep the map small. + +--- + +### Scoped files (parallel-safe) + +- `packages/app/src/utils/worktree.ts` + +--- + +### Goals + +- Ensure there is at most one pending promise per directory key +- Avoid accumulating arrays of resolver closures +- Keep current API surface for callers (`Worktree.wait(directory)`) + +--- + +### Non-goals + +- Adding abort/cancel APIs that require callsite changes +- Changing UI behavior around worktree readiness + +--- + +### Proposed approach + +1. Replace `waiters` with a single in-flight promise per key + +- Change: + - from: `Map void>>` + - to: `Map; resolve: (state: State) => void }>` + +2. Implement `wait()` dedup + +- If state is present and not pending: return `Promise.resolve(state)`. +- Else if there is an in-flight waiter entry: return its `promise`. +- Else create and store a new `{ promise, resolve }`. + +3. Resolve and clear on `ready()` / `failed()` + +- When setting state to ready/failed: + - look up waiter entry + - delete it + - call `resolve(state)` + +4. (Optional) prune resolved state entries + +- Keep pending states. +- Drop old ready/failed entries if `state.size` exceeds a small cap. + +--- + +### Implementation steps + +1. Refactor `waiters` representation + +2. Update `Worktree.wait`, `Worktree.ready`, `Worktree.failed` + +3. Add small inline comments describing dedup semantics + +--- + +### Acceptance criteria + +- Calling `Worktree.wait(directory)` repeatedly while pending does not grow `waiters` unbounded +- `ready()` and `failed()` still resolve any in-flight waiter promise +- Existing callsites continue to work without modification + +--- + +### Validation plan + +- Manual (or small ad-hoc dev snippet): + - call `Worktree.pending(dir)` + - call `Worktree.wait(dir)` many times + - confirm only one waiter entry exists + - call `Worktree.ready(dir)` and confirm all awaiting callers resolve diff --git a/specs/17-permission-responded-bounds.md b/specs/17-permission-responded-bounds.md new file mode 100644 index 00000000000..e19f5e1e109 --- /dev/null +++ b/specs/17-permission-responded-bounds.md @@ -0,0 +1,71 @@ +## Permission responded bounds + +Bound the in-memory `responded` set in PermissionProvider + +--- + +### Summary + +`packages/app/src/context/permission.tsx` uses a module-local `responded = new Set()` to prevent duplicate auto-responses for the same permission request ID. Entries are never cleared on success, so the set can grow without bound over a long-lived app session. + +This spec caps the size of this structure while preserving its purpose (dedupe in-flight/recent IDs). + +--- + +### Scoped files (parallel-safe) + +- `packages/app/src/context/permission.tsx` + +--- + +### Goals + +- Prevent unbounded growth of `responded` +- Keep dedupe behavior for recent/in-flight permission IDs +- Avoid touching other modules + +--- + +### Non-goals + +- Changing permission auto-accept rules +- Adding persistence for responded IDs + +--- + +### Proposed approach + +- Replace `Set` with an insertion-ordered `Map` (timestamp) or keep `Set` but prune using insertion order by re-creating. +- Add a cap constant, e.g. `MAX_RESPONDED = 1000`. +- On `respondOnce(...)`: + - insert/update the ID (refresh recency) + - if size exceeds cap, delete oldest entries until within cap +- Keep the existing `.catch(() => responded.delete(id))` behavior for request failures. + +Optional: add TTL pruning (e.g. drop entries older than 1 hour) when inserting. + +--- + +### Implementation steps + +1. Introduce `MAX_RESPONDED` and a small `pruneResponded()` helper + +2. Update `respondOnce(...)` to refresh recency and prune + +3. Keep failure rollback behavior + +--- + +### Acceptance criteria + +- `responded` never grows beyond `MAX_RESPONDED` +- Auto-respond dedupe still works for repeated events for the same permission ID in a short window + +--- + +### Validation plan + +- Manual: + - Simulate many permission requests (or mock by calling `respondOnce` in dev) + - Confirm the structure size stays capped + - Confirm duplicate events for the same permission ID do not send multiple responses From acf0df1e985b7a740aeef7a9ba2309e308be0d5d Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:53:26 -0600 Subject: [PATCH 16/38] chore: cleanup --- specs/09-persist-cache-memory-bounds.md | 108 ------------------ specs/10-global-sync-message-part-cleanup.md | 106 ----------------- specs/11-layout-prefetch-memory-budget.md | 103 ----------------- specs/12-terminal-unmount-race-cleanup.md | 92 --------------- .../13-speech-recognition-timeout-cleanup.md | 73 ------------ specs/14-prompt-worktree-timeout-cleanup.md | 71 ------------ specs/15-file-content-cache-bounds.md | 96 ---------------- specs/16-worktree-waiter-dedup-and-prune.md | 90 --------------- specs/17-permission-responded-bounds.md | 71 ------------ 9 files changed, 810 deletions(-) delete mode 100644 specs/09-persist-cache-memory-bounds.md delete mode 100644 specs/10-global-sync-message-part-cleanup.md delete mode 100644 specs/11-layout-prefetch-memory-budget.md delete mode 100644 specs/12-terminal-unmount-race-cleanup.md delete mode 100644 specs/13-speech-recognition-timeout-cleanup.md delete mode 100644 specs/14-prompt-worktree-timeout-cleanup.md delete mode 100644 specs/15-file-content-cache-bounds.md delete mode 100644 specs/16-worktree-waiter-dedup-and-prune.md delete mode 100644 specs/17-permission-responded-bounds.md diff --git a/specs/09-persist-cache-memory-bounds.md b/specs/09-persist-cache-memory-bounds.md deleted file mode 100644 index e96dec780f4..00000000000 --- a/specs/09-persist-cache-memory-bounds.md +++ /dev/null @@ -1,108 +0,0 @@ -## Persist in-memory cache bounds - -Fix unbounded `persist.ts` string cache growth - ---- - -### Summary - -`packages/app/src/utils/persist.ts` maintains a module-level `cache: Map()` that mirrors values written to storage. This cache can retain very large JSON strings (prompt history, image dataUrls, terminal buffers) indefinitely, even after the underlying `localStorage` keys are evicted. Over long sessions this can become an in-process memory leak. - -This spec adds explicit bounds (entries + approximate bytes) and makes eviction/removal paths delete from the in-memory cache. - ---- - -### Scoped files (parallel-safe) - -- `packages/app/src/utils/persist.ts` - ---- - -### Goals - -- Prevent unbounded memory growth from the module-level persist cache -- Ensure keys removed/evicted from storage are also removed from the in-memory cache -- Preserve current semantics when `localStorage` access throws (fallback mode) -- Keep changes self-contained to `persist.ts` - ---- - -### Non-goals - -- Changing persisted schemas or moving payloads out of KV storage (covered elsewhere) -- Introducing a shared cache utility used by multiple modules - ---- - -### Current state - -- `cache` stores raw strings for every key read/written. -- `evict()` removes items from `localStorage` but does not clear the corresponding entries from `cache`. -- When `localStorage` is unavailable (throws), reads fall back to `cache`, so the cache is also a functional in-memory persistence layer. - ---- - -### Proposed approach - -1. Replace `cache: Map` with a bounded LRU-like map - -- Store `{ value: string, bytes: number }` per key. -- Maintain a running `totalBytes`. -- Enforce caps: - - `CACHE_MAX_ENTRIES` (e.g. 500) - - `CACHE_MAX_BYTES` (e.g. 8 _ 1024 _ 1024) -- Use Map insertion order as LRU: - - On `get`, re-insert the key to the end. - - On `set`, insert/update then evict oldest until within bounds. -- Approximate bytes as `value.length * 2` (UTF-16) to avoid `TextEncoder` allocations. - -2. Ensure all removal paths clear the in-memory cache - -- In `localStorageDirect().removeItem` and `localStorageWithPrefix().removeItem`: already calls `cache.delete(name)`; keep. -- In `write(...)` failure recovery where it calls `storage.removeItem(key)`: also `cache.delete(key)`. -- In `evict(...)` loop where it removes large keys: also `cache.delete(item.key)`. - -3. Add a small dev-only diagnostic (optional) - -- In dev, expose a lightweight `cacheStats()` helper (entries, totalBytes) for debugging memory reports. - ---- - -### Implementation steps - -1. Introduce constants and cache helpers in `persist.ts` - -- `const CACHE_MAX_ENTRIES = ...` -- `const CACHE_MAX_BYTES = ...` -- `function cacheSet(key: string, value: string)` -- `function cacheGet(key: string): string | undefined` -- `function cacheDelete(key: string)` -- `function cachePrune()` - -2. Route all existing `cache.set/get/delete` calls through helpers - -- `localStorageDirect()` -- `localStorageWithPrefix()` - -3. Update `evict()` and `write()` to delete from cache when deleting from storage - -4. (Optional) Add dev logging guardrails - -- If a single value exceeds `CACHE_MAX_BYTES`, cache it but immediately prune older keys (or refuse to cache it and keep behavior consistent). - ---- - -### Acceptance criteria - -- Repeatedly loading/saving large persisted values does not cause unbounded `cache` growth (entries and bytes are capped) -- Values removed from `localStorage` by `evict()` are not returned later via the in-memory cache -- App remains functional when `localStorage` throws (fallback mode still returns cached values, subject to caps) - ---- - -### Validation plan - -- Manual: - - Open app, perform actions that write large state (image attachments, terminal output, long sessions) - - Use browser memory tools to confirm JS heap does not grow linearly with repeated writes - - Simulate quota eviction (force small storage quota / fill storage) and confirm `cache` does not retain evicted keys indefinitely diff --git a/specs/10-global-sync-message-part-cleanup.md b/specs/10-global-sync-message-part-cleanup.md deleted file mode 100644 index bb596dfb62f..00000000000 --- a/specs/10-global-sync-message-part-cleanup.md +++ /dev/null @@ -1,106 +0,0 @@ -## GlobalSync message/part cleanup - -Prevent stale message parts and per-session maps from accumulating - ---- - -### Summary - -`packages/app/src/context/global-sync.tsx` keeps per-directory child stores that include: - -- `message: { [sessionID]: Message[] }` -- `part: { [messageID]: Part[] }` - -Currently: - -- `message.removed` removes the message from `message[sessionID]` but does not delete `part[messageID]`. -- `session.deleted` / archived sessions remove the session from the list but do not clear `message[...]`, `part[...]`, `session_diff[...]`, `todo[...]`, etc. - -This can retain large arrays long after they are no longer reachable from UI. - -This spec adds explicit cleanup on the relevant events without changing cache strategy or introducing new cross-module eviction utilities. - ---- - -### Scoped files (parallel-safe) - -- `packages/app/src/context/global-sync.tsx` - ---- - -### Goals - -- Delete `part[messageID]` when a message is removed -- Clear per-session maps when a session is deleted or archived -- Keep changes limited to event handling in `global-sync.tsx` - ---- - -### Non-goals - -- Implementing LRU/TTL eviction across sessions/directories (separate work) -- Changing how `sync.tsx` or layout prefetch populates message caches - ---- - -### Current state - -- Message list and part map can diverge over time. -- Deleting/archiving sessions does not reclaim memory for message history, parts, diffs, todos, permissions, questions. - ---- - -### Proposed approach - -Add small helper functions inside `createGlobalSync()` to keep event handler readable: - -- `purgeMessageParts(setStore, messageID)` -- `purgeSessionData(store, setStore, sessionID)` - - delete `message[sessionID]` - - for each known message in that list, delete `part[messageID]` - - delete `session_diff[sessionID]`, `todo[sessionID]`, `permission[sessionID]`, `question[sessionID]`, `session_status[sessionID]` - -Then wire these into the existing event switch: - -- `message.removed`: after removing from `message[sessionID]`, also delete `part[messageID]` -- `session.updated` when `time.archived` is set: call `purgeSessionData(...)` -- `session.deleted`: call `purgeSessionData(...)` - -Notes: - -- Use `setStore(produce(...))` to `delete` object keys safely. -- Purge should be idempotent (safe if called when keys are missing). - ---- - -### Implementation steps - -1. Add purge helpers near the event listener in `global-sync.tsx` - -2. Update event handlers - -- `message.removed` -- `session.updated` (archived path) -- `session.deleted` - -3. (Optional) Tighten `message.part.removed` - -- If a part list becomes empty after removal, optionally delete `part[messageID]` as well. - ---- - -### Acceptance criteria - -- After a `message.removed` event, `store.part[messageID]` is `undefined` -- After `session.deleted` or archive, all per-session maps for that `sessionID` are removed -- No runtime errors when purging sessions/messages that were never hydrated - ---- - -### Validation plan - -- Manual: - - Load a session with many messages - - Trigger message delete/remove events (or simulate by calling handlers in dev) - - Confirm the associated `part` entries are removed - - Delete/archive a session and confirm globalSync store no longer holds its message/part data diff --git a/specs/11-layout-prefetch-memory-budget.md b/specs/11-layout-prefetch-memory-budget.md deleted file mode 100644 index 6c35f515501..00000000000 --- a/specs/11-layout-prefetch-memory-budget.md +++ /dev/null @@ -1,103 +0,0 @@ -## Layout prefetch memory budget - -Reduce sidebar hover prefetch from ballooning message caches - ---- - -### Summary - -`packages/app/src/pages/layout.tsx` prefetches message history into `globalSync.child(directory)`. - -On hover and navigation, the current implementation can prefetch many sessions (each up to `prefetchChunk = 600` messages + parts). Since the global sync store has no eviction today, scanning the sidebar can permanently grow memory. - -This spec limits how much we prefetch (chunk size + number of sessions) and adds debounced hover prefetch to avoid accidental flooding. - ---- - -### Scoped files (parallel-safe) - -- `packages/app/src/pages/layout.tsx` - ---- - -### Goals - -- Reduce the maximum amount of data prefetched per session -- Limit the number of sessions that can be prefetched per directory per app lifetime -- Avoid triggering prefetch for brief/accidental hovers -- Keep changes local to `layout.tsx` - ---- - -### Non-goals - -- Implementing eviction of already-prefetched data inside global sync (separate work) -- Changing server APIs - ---- - -### Current state - -- `prefetchChunk` is 600. -- Hovering many sessions can enqueue many prefetches over time. -- Once prefetched, message/part data remains in memory until reload. - ---- - -### Proposed approach - -1. Lower the prefetch page size - -- Change `prefetchChunk` from 600 to a smaller value (e.g. 200). -- Rationale: prefetch is for fast first render; the session page can load more as needed. - -2. Add a per-directory prefetch budget - -- Track a small LRU of prefetched session IDs per directory (Map insertion order). -- Add `PREFETCH_MAX_SESSIONS_PER_DIR` (e.g. 8-12). -- Before queueing a new prefetch: - - if already cached in `store.message[sessionID]`, allow - - else if budget exceeded and priority is not `high`, skip - - else allow and record in LRU - -3. Debounce hover-triggered prefetch - -- For sidebar session entries that call `prefetchSession(..., "high")` on hover: - - schedule after ~150-250ms - - cancel if pointer leaves before the timer fires - ---- - -### Implementation steps - -1. Update constants in `layout.tsx` - -- `prefetchChunk` -- add `prefetchMaxSessionsPerDir` - -2. Add `prefetchedByDir: Map>` (or similar) - -- Helper: `markPrefetched(directory, sessionID)` with LRU behavior and max size. -- Helper: `canPrefetch(directory, sessionID, priority)`. - -3. Integrate checks into `prefetchSession(...)` - -4. Debounce hover prefetch in the sidebar session item component (still in `layout.tsx`) - ---- - -### Acceptance criteria - -- Prefetch requests never fetch more than the new `prefetchChunk` messages per session -- For a given directory, total prefetched sessions does not exceed the configured budget (except current/adjacent high-priority navigations if explicitly allowed) -- Rapid mouse movement over the session list does not trigger a prefetch storm - ---- - -### Validation plan - -- Manual: - - Open sidebar with many sessions - - Move cursor over session list quickly; confirm few/no prefetch requests - - Hover intentionally; confirm prefetch happens after debounce - - Confirm the number of prefetched sessions per directory stays capped (via dev logging or inspecting store) diff --git a/specs/12-terminal-unmount-race-cleanup.md b/specs/12-terminal-unmount-race-cleanup.md deleted file mode 100644 index 53fd2afb9f2..00000000000 --- a/specs/12-terminal-unmount-race-cleanup.md +++ /dev/null @@ -1,92 +0,0 @@ -## Terminal unmount race cleanup - -Prevent Ghostty Terminal/WebSocket leaks when unmounting mid-init - ---- - -### Summary - -`packages/app/src/components/terminal.tsx` initializes Ghostty in `onMount` via async steps (`import("ghostty-web")`, `Ghostty.load()`, WebSocket creation, terminal creation, listeners). If the component unmounts while awaits are pending, `onCleanup` runs before `ws`/`term` exist. The async init can then continue and create resources that never get disposed. - -This spec makes initialization abortable and ensures resources created after unmount are immediately cleaned up. - ---- - -### Scoped files (parallel-safe) - -- `packages/app/src/components/terminal.tsx` - ---- - -### Goals - -- Never leave a WebSocket open after the terminal component unmounts -- Never leave window/container/textarea event listeners attached after unmount -- Avoid creating terminal resources if `disposed` is already true - ---- - -### Non-goals - -- Reworking terminal buffering/persistence format -- Changing PTY server protocol - ---- - -### Current state - -- `disposed` is checked in some WebSocket event handlers, but not during async init. -- `onCleanup` closes/disposes only the resources already assigned at cleanup time. - ---- - -### Proposed approach - -1. Guard async init steps - -- After each `await`, check `disposed` and return early. - -2. Register cleanups as resources are created - -- Maintain an array of cleanup callbacks (`cleanups: VoidFunction[]`). -- When creating `socket`, `term`, adding event listeners, etc., push the corresponding cleanup. -- In `onCleanup`, run all registered cleanups exactly once. - -3. Avoid mutating shared vars until safe - -- Prefer local variables inside `run()` and assign to outer `ws`/`term` only after confirming not disposed. - ---- - -### Implementation steps - -1. Add `const cleanups: VoidFunction[] = []` and `const cleanup = () => { ... }` in component scope - -2. In `onCleanup`, set `disposed = true` and call `cleanup()` - -3. In `run()`: - -- `await import(...)` -> if disposed return -- `await Ghostty.load()` -> if disposed return -- create WebSocket -> if disposed, close it and return -- create Terminal -> if disposed, dispose + close socket and return -- when adding listeners, register removers in `cleanups` - -4. Ensure `cleanup()` is idempotent - ---- - -### Acceptance criteria - -- Rapidly mounting/unmounting terminal components does not leave open WebSockets -- No `resize` listeners remain after unmount -- No errors are thrown if unmount occurs mid-initialization - ---- - -### Validation plan - -- Manual: - - Open a session and rapidly switch sessions/tabs to force terminal unmount/mount - - Verify via devtools that no orphan WebSocket connections remain - - Verify that terminal continues to work normally when kept mounted diff --git a/specs/13-speech-recognition-timeout-cleanup.md b/specs/13-speech-recognition-timeout-cleanup.md deleted file mode 100644 index 701bd17364e..00000000000 --- a/specs/13-speech-recognition-timeout-cleanup.md +++ /dev/null @@ -1,73 +0,0 @@ -## Speech recognition timeout cleanup - -Stop stray restart timers from keeping recognition alive - ---- - -### Summary - -`packages/app/src/utils/speech.ts` schedules 150ms `setTimeout` restarts in `recognition.onerror` and `recognition.onend` when `shouldContinue` is true. These timers are not tracked or cleared, so they can fire after `stop()`/cleanup and call `recognition.start()`, keeping recognition + closures alive unexpectedly. - -This spec tracks restart timers explicitly and clears them on stop/cleanup. - ---- - -### Scoped files (parallel-safe) - -- `packages/app/src/utils/speech.ts` - ---- - -### Goals - -- Ensure no restart timers remain scheduled after `stop()` or `onCleanup` -- Prevent `recognition.start()` from being called after cleanup -- Keep behavior identical in the normal recording flow - ---- - -### Non-goals - -- Changing the recognition UX/state machine beyond timer tracking - ---- - -### Proposed approach - -- Add `let restartTimer: number | undefined`. -- Add helpers: - - `clearRestart()` - - `scheduleRestart()` (guards `shouldContinue` + `recognition`) -- Replace both raw `setTimeout(..., 150)` uses with `window.setTimeout` stored in `restartTimer`. -- Call `clearRestart()` in: - - `start()` - - `stop()` - - `onCleanup(...)` - - `recognition.onstart` (reset state) - - any path that exits recording due to error - ---- - -### Implementation steps - -1. Introduce `restartTimer` and helpers - -2. Replace `setTimeout(() => recognition?.start(), 150)` occurrences - -3. Clear the timer in all stop/cleanup paths - ---- - -### Acceptance criteria - -- After calling `stop()` or disposing the creator, there are no delayed restarts -- No unexpected `recognition.start()` calls occur after recording is stopped - ---- - -### Validation plan - -- Manual: - - Start/stop recording repeatedly - - Trigger a `no-speech` error and confirm restarts only happen while recording is active - - Navigate away/unmount the component using `createSpeechRecognition` and confirm no restarts happen afterward diff --git a/specs/14-prompt-worktree-timeout-cleanup.md b/specs/14-prompt-worktree-timeout-cleanup.md deleted file mode 100644 index 4abe884f91b..00000000000 --- a/specs/14-prompt-worktree-timeout-cleanup.md +++ /dev/null @@ -1,71 +0,0 @@ -## Prompt worktree timeout cleanup - -Clear `waitForWorktree()` timers to avoid retaining closures for 5 minutes - ---- - -### Summary - -`packages/app/src/components/prompt-input.tsx` creates a 5-minute `setTimeout` inside `waitForWorktree()` as part of a `Promise.race`. If the worktree becomes ready quickly, the timeout still stays scheduled until it fires, retaining its closure (which can capture session and UI state) for up to 5 minutes. Repeated sends can accumulate many concurrent long-lived timers. - -This spec makes the timeout cancelable and clears it when the race finishes. - ---- - -### Scoped files (parallel-safe) - -- `packages/app/src/components/prompt-input.tsx` - ---- - -### Goals - -- Ensure the 5-minute timeout is cleared as soon as `Promise.race` resolves -- Avoid retaining large closures unnecessarily -- Keep behavior identical for real timeouts - ---- - -### Non-goals - -- Changing the worktree wait UX -- Changing the WorktreeState API - ---- - -### Proposed approach - -- Track the timeout handle explicitly: - - `let timeoutId: number | undefined` - - `timeoutId = window.setTimeout(...)` - -- After `Promise.race(...)` resolves (success, abort, or timeout), call `clearTimeout(timeoutId)` when set. - -- Keep the existing 5-minute duration and result handling. - ---- - -### Implementation steps - -1. In `waitForWorktree()` create the timeout promise with an outer `timeoutId` variable - -2. After awaiting the race, clear the timeout if it exists - -3. Ensure `pending.delete(session.id)` and UI cleanup behavior remains unchanged - ---- - -### Acceptance criteria - -- When the worktree becomes ready quickly, no 5-minute timeout remains scheduled -- When the worktree truly times out, behavior is unchanged (same error shown, same cleanup) - ---- - -### Validation plan - -- Manual: - - Trigger prompt send in a directory that is already ready; confirm no long timers remain (devtools) - - Trigger a worktree pending state and confirm: - - timeout fires at ~5 minutes - - cleanup runs diff --git a/specs/15-file-content-cache-bounds.md b/specs/15-file-content-cache-bounds.md deleted file mode 100644 index e44dd5d9e9b..00000000000 --- a/specs/15-file-content-cache-bounds.md +++ /dev/null @@ -1,96 +0,0 @@ -## File content cache bounds - -Add explicit caps for loaded file contents in `FileProvider` - ---- - -### Summary - -`packages/app/src/context/file.tsx` caches file contents in-memory (`store.file[path].content`) for every loaded file within the current directory scope. Over a long session (reviewing many diffs/files), this can grow without bound. - -This spec adds an in-module LRU + size cap for file _contents_ only, keeping metadata entries while evicting the heavy payload. - ---- - -### Scoped files (parallel-safe) - -- `packages/app/src/context/file.tsx` - ---- - -### Goals - -- Cap the number of in-memory file contents retained per directory -- Optionally cap total approximate bytes across loaded contents -- Avoid evicting content that is actively being used/rendered -- Keep changes localized to `file.tsx` - ---- - -### Non-goals - -- Changing persisted file-view state (`file-view`) limits (already pruned separately) -- Introducing a shared cache utility used elsewhere - ---- - -### Proposed approach - -1. Track content entries in an LRU Map - -- `const contentLru = new Map()` where value is approximate bytes. -- On successful `load(path)` completion, call `touchContent(path, bytes)`. -- In `get(path)`, if the file has `content`, call `touchContent(path)` to keep active files hot. - -2. Evict least-recently-used contents when over cap - -- Add constants: - - `MAX_FILE_CONTENT_ENTRIES` (e.g. 30-50) - - `MAX_FILE_CONTENT_BYTES` (optional; e.g. 10-25MB) -- When evicting a path: - - remove it from `contentLru` - - clear `store.file[path].content` - - set `store.file[path].loaded = false` (or keep loaded but ensure UI can reload) - -3. Reset LRU on directory scope change - -- The existing scope reset already clears `store.file`; also clear the LRU map. - ---- - -### Implementation steps - -1. Add LRU state + helper functions - -- `approxBytes(fileContent)` (prefer `content.length * 2`) -- `touchContent(path, bytes?)` -- `evictContent(keep?: Set)` - -2. Touch on content load - -- After setting `draft.content = x.data`, compute bytes and touch - -3. Touch on `get()` usage - -- If `store.file[path]?.content` exists, touch - -4. Evict on every content set - -- After `touchContent`, run eviction until within caps - ---- - -### Acceptance criteria - -- Loading hundreds of files does not grow memory linearly; content retention plateaus -- Actively viewed file content is not evicted under normal use -- Evicted files can be reloaded correctly when accessed again - ---- - -### Validation plan - -- Manual: - - Load many distinct files (e.g. via review tab) - - Confirm only the latest N files retain `content` - - Switch back to an older file; confirm it reloads without UI errors diff --git a/specs/16-worktree-waiter-dedup-and-prune.md b/specs/16-worktree-waiter-dedup-and-prune.md deleted file mode 100644 index eaab062e7de..00000000000 --- a/specs/16-worktree-waiter-dedup-and-prune.md +++ /dev/null @@ -1,90 +0,0 @@ -## Worktree waiter dedup + pruning - -Prevent `Worktree.wait()` from accumulating resolver closures - ---- - -### Summary - -`packages/app/src/utils/worktree.ts` stores `waiters` as `Map void>>`. If multiple callers call `wait()` while a directory is pending (or when callers stop awaiting due to their own timeouts), resolver closures can accumulate until a `ready()`/`failed()` event arrives. - -In this app, `Worktree.wait()` is used inside a `Promise.race` with a timeout, so it is possible to create many resolvers that remain stored for a long time. - -This spec changes `wait()` to share a single promise per directory key (dedup), eliminating unbounded waiter arrays. Optionally, it prunes resolved state entries to keep the map small. - ---- - -### Scoped files (parallel-safe) - -- `packages/app/src/utils/worktree.ts` - ---- - -### Goals - -- Ensure there is at most one pending promise per directory key -- Avoid accumulating arrays of resolver closures -- Keep current API surface for callers (`Worktree.wait(directory)`) - ---- - -### Non-goals - -- Adding abort/cancel APIs that require callsite changes -- Changing UI behavior around worktree readiness - ---- - -### Proposed approach - -1. Replace `waiters` with a single in-flight promise per key - -- Change: - - from: `Map void>>` - - to: `Map; resolve: (state: State) => void }>` - -2. Implement `wait()` dedup - -- If state is present and not pending: return `Promise.resolve(state)`. -- Else if there is an in-flight waiter entry: return its `promise`. -- Else create and store a new `{ promise, resolve }`. - -3. Resolve and clear on `ready()` / `failed()` - -- When setting state to ready/failed: - - look up waiter entry - - delete it - - call `resolve(state)` - -4. (Optional) prune resolved state entries - -- Keep pending states. -- Drop old ready/failed entries if `state.size` exceeds a small cap. - ---- - -### Implementation steps - -1. Refactor `waiters` representation - -2. Update `Worktree.wait`, `Worktree.ready`, `Worktree.failed` - -3. Add small inline comments describing dedup semantics - ---- - -### Acceptance criteria - -- Calling `Worktree.wait(directory)` repeatedly while pending does not grow `waiters` unbounded -- `ready()` and `failed()` still resolve any in-flight waiter promise -- Existing callsites continue to work without modification - ---- - -### Validation plan - -- Manual (or small ad-hoc dev snippet): - - call `Worktree.pending(dir)` - - call `Worktree.wait(dir)` many times - - confirm only one waiter entry exists - - call `Worktree.ready(dir)` and confirm all awaiting callers resolve diff --git a/specs/17-permission-responded-bounds.md b/specs/17-permission-responded-bounds.md deleted file mode 100644 index e19f5e1e109..00000000000 --- a/specs/17-permission-responded-bounds.md +++ /dev/null @@ -1,71 +0,0 @@ -## Permission responded bounds - -Bound the in-memory `responded` set in PermissionProvider - ---- - -### Summary - -`packages/app/src/context/permission.tsx` uses a module-local `responded = new Set()` to prevent duplicate auto-responses for the same permission request ID. Entries are never cleared on success, so the set can grow without bound over a long-lived app session. - -This spec caps the size of this structure while preserving its purpose (dedupe in-flight/recent IDs). - ---- - -### Scoped files (parallel-safe) - -- `packages/app/src/context/permission.tsx` - ---- - -### Goals - -- Prevent unbounded growth of `responded` -- Keep dedupe behavior for recent/in-flight permission IDs -- Avoid touching other modules - ---- - -### Non-goals - -- Changing permission auto-accept rules -- Adding persistence for responded IDs - ---- - -### Proposed approach - -- Replace `Set` with an insertion-ordered `Map` (timestamp) or keep `Set` but prune using insertion order by re-creating. -- Add a cap constant, e.g. `MAX_RESPONDED = 1000`. -- On `respondOnce(...)`: - - insert/update the ID (refresh recency) - - if size exceeds cap, delete oldest entries until within cap -- Keep the existing `.catch(() => responded.delete(id))` behavior for request failures. - -Optional: add TTL pruning (e.g. drop entries older than 1 hour) when inserting. - ---- - -### Implementation steps - -1. Introduce `MAX_RESPONDED` and a small `pruneResponded()` helper - -2. Update `respondOnce(...)` to refresh recency and prune - -3. Keep failure rollback behavior - ---- - -### Acceptance criteria - -- `responded` never grows beyond `MAX_RESPONDED` -- Auto-respond dedupe still works for repeated events for the same permission ID in a short window - ---- - -### Validation plan - -- Manual: - - Simulate many permission requests (or mock by calling `respondOnce` in dev) - - Confirm the structure size stays capped - - Confirm duplicate events for the same permission ID do not send multiple responses From 51edf68606618505ee65c53d085ebf384df10a0c Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:00:17 -0600 Subject: [PATCH 17/38] feat(desktop): i18n for tauri side --- bun.lock | 1 + packages/desktop/package.json | 1 + packages/desktop/src/cli.ts | 10 ++- packages/desktop/src/i18n/en.ts | 31 +++++++ packages/desktop/src/i18n/index.ts | 134 +++++++++++++++++++++++++++++ packages/desktop/src/index.tsx | 22 ++--- packages/desktop/src/menu.ts | 11 ++- packages/desktop/src/updater.ts | 22 +++-- 8 files changed, 204 insertions(+), 28 deletions(-) create mode 100644 packages/desktop/src/i18n/en.ts create mode 100644 packages/desktop/src/i18n/index.ts diff --git a/bun.lock b/bun.lock index 13647ffa151..d02afd42d3e 100644 --- a/bun.lock +++ b/bun.lock @@ -186,6 +186,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cc6b3af99f7..49e032339ca 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,6 +15,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", diff --git a/packages/desktop/src/cli.ts b/packages/desktop/src/cli.ts index 965ed6ddc00..5a8875cf893 100644 --- a/packages/desktop/src/cli.ts +++ b/packages/desktop/src/cli.ts @@ -1,13 +1,15 @@ import { invoke } from "@tauri-apps/api/core" import { message } from "@tauri-apps/plugin-dialog" +import { initI18n, t } from "./i18n" + export async function installCli(): Promise { + await initI18n() + try { const path = await invoke("install_cli") - await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, { - title: "CLI Installed", - }) + await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") }) } catch (e) { - await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" }) + await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") }) } } diff --git a/packages/desktop/src/i18n/en.ts b/packages/desktop/src/i18n/en.ts new file mode 100644 index 00000000000..4008efca5f9 --- /dev/null +++ b/packages/desktop/src/i18n/en.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Check for Updates...", + "desktop.menu.installCli": "Install CLI...", + "desktop.menu.reloadWebview": "Reload Webview", + "desktop.menu.restart": "Restart", + + "desktop.dialog.chooseFolder": "Choose a folder", + "desktop.dialog.chooseFile": "Choose a file", + "desktop.dialog.saveFile": "Save file", + + "desktop.updater.checkFailed.title": "Update Check Failed", + "desktop.updater.checkFailed.message": "Failed to check for updates", + "desktop.updater.none.title": "No Update Available", + "desktop.updater.none.message": "You are already using the latest version of OpenCode", + "desktop.updater.downloadFailed.title": "Update Failed", + "desktop.updater.downloadFailed.message": "Failed to download update", + "desktop.updater.downloaded.title": "Update Downloaded", + "desktop.updater.downloaded.prompt": + "Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?", + "desktop.updater.installFailed.title": "Update Failed", + "desktop.updater.installFailed.message": "Failed to install update", + + "desktop.cli.installed.title": "CLI Installed", + "desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.", + "desktop.cli.failed.title": "Installation Failed", + "desktop.cli.failed.message": "Failed to install CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode failed to start", + "desktop.error.serverStartFailed.description": + "The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy) and try again.", +} as const diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts new file mode 100644 index 00000000000..34a427c7a67 --- /dev/null +++ b/packages/desktop/src/i18n/index.ts @@ -0,0 +1,134 @@ +import * as i18n from "@solid-primitives/i18n" +import { Store } from "@tauri-apps/plugin-store" + +import { dict as desktopEn } from "./en" + +import { dict as appEn } from "../../../app/src/i18n/en" +import { dict as appZh } from "../../../app/src/i18n/zh" +import { dict as appZht } from "../../../app/src/i18n/zht" +import { dict as appKo } from "../../../app/src/i18n/ko" +import { dict as appDe } from "../../../app/src/i18n/de" +import { dict as appEs } from "../../../app/src/i18n/es" +import { dict as appFr } from "../../../app/src/i18n/fr" +import { dict as appDa } from "../../../app/src/i18n/da" +import { dict as appJa } from "../../../app/src/i18n/ja" +import { dict as appPl } from "../../../app/src/i18n/pl" +import { dict as appRu } from "../../../app/src/i18n/ru" +import { dict as appAr } from "../../../app/src/i18n/ar" +import { dict as appNo } from "../../../app/src/i18n/no" +import { dict as appBr } from "../../../app/src/i18n/br" + +export type Locale = "en" | "zh" | "zht" | "ko" | "de" | "es" | "fr" | "da" | "ja" | "pl" | "ru" | "ar" | "no" | "br" + +type RawDictionary = typeof appEn & typeof desktopEn +type Dictionary = i18n.Flatten + +const LOCALES: readonly Locale[] = ["en", "zh", "zht", "ko", "de", "es", "fr", "da", "ja", "pl", "ru", "ar", "no", "br"] + +function detectLocale(): Locale { + if (typeof navigator !== "object") return "en" + + const languages = navigator.languages?.length ? navigator.languages : [navigator.language] + for (const language of languages) { + if (!language) continue + if (language.toLowerCase().startsWith("zh")) { + if (language.toLowerCase().includes("hant")) return "zht" + return "zh" + } + if (language.toLowerCase().startsWith("ko")) return "ko" + if (language.toLowerCase().startsWith("de")) return "de" + if (language.toLowerCase().startsWith("es")) return "es" + if (language.toLowerCase().startsWith("fr")) return "fr" + if (language.toLowerCase().startsWith("da")) return "da" + if (language.toLowerCase().startsWith("ja")) return "ja" + if (language.toLowerCase().startsWith("pl")) return "pl" + if (language.toLowerCase().startsWith("ru")) return "ru" + if (language.toLowerCase().startsWith("ar")) return "ar" + if ( + language.toLowerCase().startsWith("no") || + language.toLowerCase().startsWith("nb") || + language.toLowerCase().startsWith("nn") + ) + return "no" + if (language.toLowerCase().startsWith("pt")) return "br" + } + + return "en" +} + +function parseLocale(value: unknown): Locale | null { + if (!value) return null + if (typeof value !== "string") return null + if ((LOCALES as readonly string[]).includes(value)) return value as Locale + return null +} + +function parseRecord(value: unknown) { + if (!value || typeof value !== "object") return null + if (Array.isArray(value)) return null + return value as Record +} + +function pickLocale(value: unknown): Locale | null { + const direct = parseLocale(value) + if (direct) return direct + + const record = parseRecord(value) + if (!record) return null + + return parseLocale(record.locale) +} + +const base = i18n.flatten({ ...appEn, ...desktopEn }) + +function build(locale: Locale): Dictionary { + if (locale === "en") return base + if (locale === "zh") return { ...base, ...i18n.flatten(appZh) } + if (locale === "zht") return { ...base, ...i18n.flatten(appZht) } + if (locale === "de") return { ...base, ...i18n.flatten(appDe) } + if (locale === "es") return { ...base, ...i18n.flatten(appEs) } + if (locale === "fr") return { ...base, ...i18n.flatten(appFr) } + if (locale === "da") return { ...base, ...i18n.flatten(appDa) } + if (locale === "ja") return { ...base, ...i18n.flatten(appJa) } + if (locale === "pl") return { ...base, ...i18n.flatten(appPl) } + if (locale === "ru") return { ...base, ...i18n.flatten(appRu) } + if (locale === "ar") return { ...base, ...i18n.flatten(appAr) } + if (locale === "no") return { ...base, ...i18n.flatten(appNo) } + if (locale === "br") return { ...base, ...i18n.flatten(appBr) } + return { ...base, ...i18n.flatten(appKo) } +} + +const state = { + locale: detectLocale(), + dict: base as Dictionary, + init: undefined as Promise | undefined, +} + +state.dict = build(state.locale) + +const translate = i18n.translator(() => state.dict, i18n.resolveTemplate) + +export function t(key: keyof Dictionary, params?: Record) { + return translate(key, params) +} + +export function initI18n(): Promise { + const cached = state.init + if (cached) return cached + + const promise = (async () => { + const store = await Store.load("opencode.global.dat").catch(() => null) + if (!store) return state.locale + + const raw = await store.get("language").catch(() => null) + const value = typeof raw === "string" ? JSON.parse(raw) : raw + const next = pickLocale(value) ?? state.locale + + state.locale = next + state.dict = build(next) + return next + })().catch(() => state.locale) + + state.init = promise + return promise +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index b19adfeda5a..344c6be8d9c 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -18,16 +18,17 @@ import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } import { UPDATER_ENABLED } from "./updater" import { createMenu } from "./menu" +import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { - throw new Error( - "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", - ) + throw new Error(t("error.dev.rootNotFound")) } +void initI18n() + // Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements). // This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows. const originalGetComputedStyle = window.getComputedStyle @@ -54,7 +55,7 @@ const createPlatform = (password: Accessor): Platform => ({ const result = await open({ directory: true, multiple: opts?.multiple ?? false, - title: opts?.title ?? "Choose a folder", + title: opts?.title ?? t("desktop.dialog.chooseFolder"), }) return result }, @@ -63,14 +64,14 @@ const createPlatform = (password: Accessor): Platform => ({ const result = await open({ directory: false, multiple: opts?.multiple ?? false, - title: opts?.title ?? "Choose a file", + title: opts?.title ?? t("desktop.dialog.chooseFile"), }) return result }, async saveFilePickerDialog(opts) { const result = await save({ - title: opts?.title ?? "Save file", + title: opts?.title ?? t("desktop.dialog.saveFile"), defaultPath: opts?.defaultPath, }) return result @@ -380,7 +381,7 @@ function ServerGate(props: { children: (data: Accessor) => JSX. const errorMessage = () => { const error = serverData.error - if (!error) return "Unknown error" + if (!error) return t("error.chain.unknown") if (typeof error === "string") return error if (error instanceof Error) return error.message return String(error) @@ -410,16 +411,15 @@ function ServerGate(props: { children: (data: Accessor) => JSX. } >
-
OpenCode failed to start
+
{t("desktop.error.serverStartFailed.title")}
- The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy) - and try again. + {t("desktop.error.serverStartFailed.description")}
{errorMessage()}
diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 1b4c611353a..2edeff42b25 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -5,10 +5,13 @@ import { relaunch } from "@tauri-apps/plugin-process" import { runUpdater, UPDATER_ENABLED } from "./updater" import { installCli } from "./cli" +import { initI18n, t } from "./i18n" export async function createMenu() { if (ostype() !== "macos") return + await initI18n() + const menu = await Menu.new({ items: [ await Submenu.new({ @@ -20,22 +23,22 @@ export async function createMenu() { await MenuItem.new({ enabled: UPDATER_ENABLED, action: () => runUpdater({ alertOnFail: true }), - text: "Check For Updates...", + text: t("desktop.menu.checkForUpdates"), }), await MenuItem.new({ action: () => installCli(), - text: "Install CLI...", + text: t("desktop.menu.installCli"), }), await MenuItem.new({ action: async () => window.location.reload(), - text: "Reload Webview", + text: t("desktop.menu.reloadWebview"), }), await MenuItem.new({ action: async () => { await invoke("kill_sidecar").catch(() => undefined) await relaunch().catch(() => undefined) }, - text: "Restart", + text: t("desktop.menu.restart"), }), await PredefinedMenuItem.new({ item: "Separator", diff --git a/packages/desktop/src/updater.ts b/packages/desktop/src/updater.ts index 4753ee66390..b48bb6be025 100644 --- a/packages/desktop/src/updater.ts +++ b/packages/desktop/src/updater.ts @@ -4,41 +4,45 @@ import { ask, message } from "@tauri-apps/plugin-dialog" import { invoke } from "@tauri-apps/api/core" import { type as ostype } from "@tauri-apps/plugin-os" +import { initI18n, t } from "./i18n" + export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { + await initI18n() + let update try { update = await check() } catch { - if (alertOnFail) await message("Failed to check for updates", { title: "Update Check Failed" }) + if (alertOnFail) + await message(t("desktop.updater.checkFailed.message"), { title: t("desktop.updater.checkFailed.title") }) return } if (!update) { - if (alertOnFail) - await message("You are already using the latest version of OpenCode", { title: "No Update Available" }) + if (alertOnFail) await message(t("desktop.updater.none.message"), { title: t("desktop.updater.none.title") }) return } try { await update.download() } catch { - if (alertOnFail) await message("Failed to download update", { title: "Update Failed" }) + if (alertOnFail) + await message(t("desktop.updater.downloadFailed.message"), { title: t("desktop.updater.downloadFailed.title") }) return } - const shouldUpdate = await ask( - `Version ${update.version} of OpenCode has been downloaded, would you like to install it and relaunch?`, - { title: "Update Downloaded" }, - ) + const shouldUpdate = await ask(t("desktop.updater.downloaded.prompt", { version: update.version }), { + title: t("desktop.updater.downloaded.title"), + }) if (!shouldUpdate) return try { if (ostype() === "windows") await invoke("kill_sidecar") await update.install() } catch { - await message("Failed to install update", { title: "Update Failed" }) + await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") }) return } From e5b18674f91eefd4ee304455729274b5bcc792c2 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:21:02 -0600 Subject: [PATCH 18/38] feat(desktop): tauri locales --- packages/desktop/src/i18n/ar.ts | 30 +++++++++++++++++++++++ packages/desktop/src/i18n/br.ts | 31 ++++++++++++++++++++++++ packages/desktop/src/i18n/da.ts | 32 ++++++++++++++++++++++++ packages/desktop/src/i18n/de.ts | 32 ++++++++++++++++++++++++ packages/desktop/src/i18n/en.ts | 2 +- packages/desktop/src/i18n/es.ts | 31 ++++++++++++++++++++++++ packages/desktop/src/i18n/fr.ts | 32 ++++++++++++++++++++++++ packages/desktop/src/i18n/index.ts | 39 ++++++++++++++++++++---------- packages/desktop/src/i18n/ja.ts | 32 ++++++++++++++++++++++++ packages/desktop/src/i18n/ko.ts | 31 ++++++++++++++++++++++++ packages/desktop/src/i18n/no.ts | 32 ++++++++++++++++++++++++ packages/desktop/src/i18n/pl.ts | 32 ++++++++++++++++++++++++ packages/desktop/src/i18n/ru.ts | 31 ++++++++++++++++++++++++ packages/desktop/src/i18n/zh.ts | 30 +++++++++++++++++++++++ packages/desktop/src/i18n/zht.ts | 30 +++++++++++++++++++++++ 15 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 packages/desktop/src/i18n/ar.ts create mode 100644 packages/desktop/src/i18n/br.ts create mode 100644 packages/desktop/src/i18n/da.ts create mode 100644 packages/desktop/src/i18n/de.ts create mode 100644 packages/desktop/src/i18n/es.ts create mode 100644 packages/desktop/src/i18n/fr.ts create mode 100644 packages/desktop/src/i18n/ja.ts create mode 100644 packages/desktop/src/i18n/ko.ts create mode 100644 packages/desktop/src/i18n/no.ts create mode 100644 packages/desktop/src/i18n/pl.ts create mode 100644 packages/desktop/src/i18n/ru.ts create mode 100644 packages/desktop/src/i18n/zh.ts create mode 100644 packages/desktop/src/i18n/zht.ts diff --git a/packages/desktop/src/i18n/ar.ts b/packages/desktop/src/i18n/ar.ts new file mode 100644 index 00000000000..c3205cb85e7 --- /dev/null +++ b/packages/desktop/src/i18n/ar.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...", + "desktop.menu.installCli": "تثبيت CLI...", + "desktop.menu.reloadWebview": "إعادة تحميل Webview", + "desktop.menu.restart": "إعادة تشغيل", + + "desktop.dialog.chooseFolder": "اختر مجلدًا", + "desktop.dialog.chooseFile": "اختر ملفًا", + "desktop.dialog.saveFile": "حفظ ملف", + + "desktop.updater.checkFailed.title": "فشل التحقق من التحديثات", + "desktop.updater.checkFailed.message": "فشل التحقق من وجود تحديثات", + "desktop.updater.none.title": "لا توجد تحديثات متاحة", + "desktop.updater.none.message": "أنت تستخدم بالفعل أحدث إصدار من OpenCode", + "desktop.updater.downloadFailed.title": "فشل التحديث", + "desktop.updater.downloadFailed.message": "فشل تنزيل التحديث", + "desktop.updater.downloaded.title": "تم تنزيل التحديث", + "desktop.updater.downloaded.prompt": "تم تنزيل إصدار {{version}} من OpenCode، هل ترغب في تثبيته وإعادة تشغيله؟", + "desktop.updater.installFailed.title": "فشل التحديث", + "desktop.updater.installFailed.message": "فشل تثبيت التحديث", + + "desktop.cli.installed.title": "تم تثبيت CLI", + "desktop.cli.installed.message": "تم تثبيت CLI في {{path}}\n\nأعد تشغيل الطرفية لاستخدام الأمر 'opencode'.", + "desktop.cli.failed.title": "فشل التثبيت", + "desktop.cli.failed.message": "فشل تثبيت CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "فشل تشغيل OpenCode", + "desktop.error.serverStartFailed.description": + "تعذر بدء تشغيل خادم OpenCode المحلي. أعد تشغيل التطبيق، أو تحقق من إعدادات الشبكة (VPN/proxy) وحاول مرة أخرى.", +} diff --git a/packages/desktop/src/i18n/br.ts b/packages/desktop/src/i18n/br.ts new file mode 100644 index 00000000000..8b5a58756d5 --- /dev/null +++ b/packages/desktop/src/i18n/br.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Verificar atualizações...", + "desktop.menu.installCli": "Instalar CLI...", + "desktop.menu.reloadWebview": "Recarregar Webview", + "desktop.menu.restart": "Reiniciar", + + "desktop.dialog.chooseFolder": "Escolher uma pasta", + "desktop.dialog.chooseFile": "Escolher um arquivo", + "desktop.dialog.saveFile": "Salvar arquivo", + + "desktop.updater.checkFailed.title": "Falha ao verificar atualizações", + "desktop.updater.checkFailed.message": "Falha ao verificar atualizações", + "desktop.updater.none.title": "Nenhuma atualização disponível", + "desktop.updater.none.message": "Você já está usando a versão mais recente do OpenCode", + "desktop.updater.downloadFailed.title": "Falha na atualização", + "desktop.updater.downloadFailed.message": "Falha ao baixar a atualização", + "desktop.updater.downloaded.title": "Atualização baixada", + "desktop.updater.downloaded.prompt": + "A versão {{version}} do OpenCode foi baixada. Você gostaria de instalá-la e reiniciar?", + "desktop.updater.installFailed.title": "Falha na atualização", + "desktop.updater.installFailed.message": "Falha ao instalar a atualização", + + "desktop.cli.installed.title": "CLI instalada", + "desktop.cli.installed.message": "CLI instalada em {{path}}\n\nReinicie seu terminal para usar o comando 'opencode'.", + "desktop.cli.failed.title": "Falha na instalação", + "desktop.cli.failed.message": "Falha ao instalar a CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "Falha ao iniciar o OpenCode", + "desktop.error.serverStartFailed.description": + "Não foi possível iniciar o servidor local do OpenCode. Reinicie o aplicativo ou verifique suas configurações de rede (VPN/proxy) e tente novamente.", +} diff --git a/packages/desktop/src/i18n/da.ts b/packages/desktop/src/i18n/da.ts new file mode 100644 index 00000000000..73d47db303b --- /dev/null +++ b/packages/desktop/src/i18n/da.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Tjek for opdateringer...", + "desktop.menu.installCli": "Installer CLI...", + "desktop.menu.reloadWebview": "Genindlæs Webview", + "desktop.menu.restart": "Genstart", + + "desktop.dialog.chooseFolder": "Vælg en mappe", + "desktop.dialog.chooseFile": "Vælg en fil", + "desktop.dialog.saveFile": "Gem fil", + + "desktop.updater.checkFailed.title": "Opdateringstjek mislykkedes", + "desktop.updater.checkFailed.message": "Kunne ikke tjekke for opdateringer", + "desktop.updater.none.title": "Ingen opdatering tilgængelig", + "desktop.updater.none.message": "Du bruger allerede den nyeste version af OpenCode", + "desktop.updater.downloadFailed.title": "Opdatering mislykkedes", + "desktop.updater.downloadFailed.message": "Kunne ikke downloade opdateringen", + "desktop.updater.downloaded.title": "Opdatering downloadet", + "desktop.updater.downloaded.prompt": + "Version {{version}} af OpenCode er blevet downloadet. Vil du installere den og genstarte?", + "desktop.updater.installFailed.title": "Opdatering mislykkedes", + "desktop.updater.installFailed.message": "Kunne ikke installere opdateringen", + + "desktop.cli.installed.title": "CLI installeret", + "desktop.cli.installed.message": + "CLI installeret i {{path}}\n\nGenstart din terminal for at bruge 'opencode'-kommandoen.", + "desktop.cli.failed.title": "Installation mislykkedes", + "desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode kunne ikke starte", + "desktop.error.serverStartFailed.description": + "Den lokale OpenCode-server kunne ikke startes. Genstart appen, eller tjek dine netværksindstillinger (VPN/proxy) og prøv igen.", +} diff --git a/packages/desktop/src/i18n/de.ts b/packages/desktop/src/i18n/de.ts new file mode 100644 index 00000000000..2559d981e20 --- /dev/null +++ b/packages/desktop/src/i18n/de.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Nach Updates suchen...", + "desktop.menu.installCli": "CLI installieren...", + "desktop.menu.reloadWebview": "Webview neu laden", + "desktop.menu.restart": "Neustart", + + "desktop.dialog.chooseFolder": "Ordner auswählen", + "desktop.dialog.chooseFile": "Datei auswählen", + "desktop.dialog.saveFile": "Datei speichern", + + "desktop.updater.checkFailed.title": "Updateprüfung fehlgeschlagen", + "desktop.updater.checkFailed.message": "Updates konnten nicht geprüft werden", + "desktop.updater.none.title": "Kein Update verfügbar", + "desktop.updater.none.message": "Sie verwenden bereits die neueste Version von OpenCode", + "desktop.updater.downloadFailed.title": "Update fehlgeschlagen", + "desktop.updater.downloadFailed.message": "Update konnte nicht heruntergeladen werden", + "desktop.updater.downloaded.title": "Update heruntergeladen", + "desktop.updater.downloaded.prompt": + "Version {{version}} von OpenCode wurde heruntergeladen. Möchten Sie sie installieren und neu starten?", + "desktop.updater.installFailed.title": "Update fehlgeschlagen", + "desktop.updater.installFailed.message": "Update konnte nicht installiert werden", + + "desktop.cli.installed.title": "CLI installiert", + "desktop.cli.installed.message": + "CLI wurde in {{path}} installiert\n\nStarten Sie Ihr Terminal neu, um den Befehl 'opencode' zu verwenden.", + "desktop.cli.failed.title": "Installation fehlgeschlagen", + "desktop.cli.failed.message": "CLI konnte nicht installiert werden: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode konnte nicht gestartet werden", + "desktop.error.serverStartFailed.description": + "Der lokale OpenCode-Server konnte nicht gestartet werden. Starten Sie die App neu oder überprüfen Sie Ihre Netzwerkeinstellungen (VPN/Proxy) und versuchen Sie es erneut.", +} diff --git a/packages/desktop/src/i18n/en.ts b/packages/desktop/src/i18n/en.ts index 4008efca5f9..c2981f519d8 100644 --- a/packages/desktop/src/i18n/en.ts +++ b/packages/desktop/src/i18n/en.ts @@ -28,4 +28,4 @@ export const dict = { "desktop.error.serverStartFailed.title": "OpenCode failed to start", "desktop.error.serverStartFailed.description": "The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy) and try again.", -} as const +} diff --git a/packages/desktop/src/i18n/es.ts b/packages/desktop/src/i18n/es.ts new file mode 100644 index 00000000000..d1045a90cf3 --- /dev/null +++ b/packages/desktop/src/i18n/es.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Buscar actualizaciones...", + "desktop.menu.installCli": "Instalar CLI...", + "desktop.menu.reloadWebview": "Recargar Webview", + "desktop.menu.restart": "Reiniciar", + + "desktop.dialog.chooseFolder": "Elegir una carpeta", + "desktop.dialog.chooseFile": "Elegir un archivo", + "desktop.dialog.saveFile": "Guardar archivo", + + "desktop.updater.checkFailed.title": "Comprobación de actualizaciones fallida", + "desktop.updater.checkFailed.message": "No se pudieron buscar actualizaciones", + "desktop.updater.none.title": "No hay actualizaciones disponibles", + "desktop.updater.none.message": "Ya estás usando la versión más reciente de OpenCode", + "desktop.updater.downloadFailed.title": "Actualización fallida", + "desktop.updater.downloadFailed.message": "No se pudo descargar la actualización", + "desktop.updater.downloaded.title": "Actualización descargada", + "desktop.updater.downloaded.prompt": + "Se ha descargado la versión {{version}} de OpenCode. ¿Quieres instalarla y reiniciar?", + "desktop.updater.installFailed.title": "Actualización fallida", + "desktop.updater.installFailed.message": "No se pudo instalar la actualización", + + "desktop.cli.installed.title": "CLI instalada", + "desktop.cli.installed.message": "CLI instalada en {{path}}\n\nReinicia tu terminal para usar el comando 'opencode'.", + "desktop.cli.failed.title": "Instalación fallida", + "desktop.cli.failed.message": "No se pudo instalar la CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode no pudo iniciarse", + "desktop.error.serverStartFailed.description": + "No se pudo iniciar el servidor local de OpenCode. Reinicia la aplicación o revisa tu configuración de red (VPN/proxy) y vuelve a intentarlo.", +} diff --git a/packages/desktop/src/i18n/fr.ts b/packages/desktop/src/i18n/fr.ts new file mode 100644 index 00000000000..5c574edf53c --- /dev/null +++ b/packages/desktop/src/i18n/fr.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Vérifier les mises à jour...", + "desktop.menu.installCli": "Installer la CLI...", + "desktop.menu.reloadWebview": "Recharger la Webview", + "desktop.menu.restart": "Redémarrer", + + "desktop.dialog.chooseFolder": "Choisir un dossier", + "desktop.dialog.chooseFile": "Choisir un fichier", + "desktop.dialog.saveFile": "Enregistrer le fichier", + + "desktop.updater.checkFailed.title": "Échec de la vérification des mises à jour", + "desktop.updater.checkFailed.message": "Impossible de vérifier les mises à jour", + "desktop.updater.none.title": "Aucune mise à jour disponible", + "desktop.updater.none.message": "Vous utilisez déjà la dernière version d'OpenCode", + "desktop.updater.downloadFailed.title": "Échec de la mise à jour", + "desktop.updater.downloadFailed.message": "Impossible de télécharger la mise à jour", + "desktop.updater.downloaded.title": "Mise à jour téléchargée", + "desktop.updater.downloaded.prompt": + "La version {{version}} d'OpenCode a été téléchargée. Voulez-vous l'installer et redémarrer ?", + "desktop.updater.installFailed.title": "Échec de la mise à jour", + "desktop.updater.installFailed.message": "Impossible d'installer la mise à jour", + + "desktop.cli.installed.title": "CLI installée", + "desktop.cli.installed.message": + "CLI installée dans {{path}}\n\nRedémarrez votre terminal pour utiliser la commande 'opencode'.", + "desktop.cli.failed.title": "Échec de l'installation", + "desktop.cli.failed.message": "Impossible d'installer la CLI : {{error}}", + + "desktop.error.serverStartFailed.title": "Échec du démarrage d'OpenCode", + "desktop.error.serverStartFailed.description": + "Impossible de démarrer le serveur OpenCode local. Redémarrez l'application ou vérifiez vos paramètres réseau (VPN/proxy) et réessayez.", +} diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts index 34a427c7a67..f2496346fcb 100644 --- a/packages/desktop/src/i18n/index.ts +++ b/packages/desktop/src/i18n/index.ts @@ -2,6 +2,19 @@ import * as i18n from "@solid-primitives/i18n" import { Store } from "@tauri-apps/plugin-store" import { dict as desktopEn } from "./en" +import { dict as desktopZh } from "./zh" +import { dict as desktopZht } from "./zht" +import { dict as desktopKo } from "./ko" +import { dict as desktopDe } from "./de" +import { dict as desktopEs } from "./es" +import { dict as desktopFr } from "./fr" +import { dict as desktopDa } from "./da" +import { dict as desktopJa } from "./ja" +import { dict as desktopPl } from "./pl" +import { dict as desktopRu } from "./ru" +import { dict as desktopAr } from "./ar" +import { dict as desktopNo } from "./no" +import { dict as desktopBr } from "./br" import { dict as appEn } from "../../../app/src/i18n/en" import { dict as appZh } from "../../../app/src/i18n/zh" @@ -83,19 +96,19 @@ const base = i18n.flatten({ ...appEn, ...desktopEn }) function build(locale: Locale): Dictionary { if (locale === "en") return base - if (locale === "zh") return { ...base, ...i18n.flatten(appZh) } - if (locale === "zht") return { ...base, ...i18n.flatten(appZht) } - if (locale === "de") return { ...base, ...i18n.flatten(appDe) } - if (locale === "es") return { ...base, ...i18n.flatten(appEs) } - if (locale === "fr") return { ...base, ...i18n.flatten(appFr) } - if (locale === "da") return { ...base, ...i18n.flatten(appDa) } - if (locale === "ja") return { ...base, ...i18n.flatten(appJa) } - if (locale === "pl") return { ...base, ...i18n.flatten(appPl) } - if (locale === "ru") return { ...base, ...i18n.flatten(appRu) } - if (locale === "ar") return { ...base, ...i18n.flatten(appAr) } - if (locale === "no") return { ...base, ...i18n.flatten(appNo) } - if (locale === "br") return { ...base, ...i18n.flatten(appBr) } - return { ...base, ...i18n.flatten(appKo) } + if (locale === "zh") return { ...base, ...i18n.flatten(appZh), ...i18n.flatten(desktopZh) } + if (locale === "zht") return { ...base, ...i18n.flatten(appZht), ...i18n.flatten(desktopZht) } + if (locale === "de") return { ...base, ...i18n.flatten(appDe), ...i18n.flatten(desktopDe) } + if (locale === "es") return { ...base, ...i18n.flatten(appEs), ...i18n.flatten(desktopEs) } + if (locale === "fr") return { ...base, ...i18n.flatten(appFr), ...i18n.flatten(desktopFr) } + if (locale === "da") return { ...base, ...i18n.flatten(appDa), ...i18n.flatten(desktopDa) } + if (locale === "ja") return { ...base, ...i18n.flatten(appJa), ...i18n.flatten(desktopJa) } + if (locale === "pl") return { ...base, ...i18n.flatten(appPl), ...i18n.flatten(desktopPl) } + if (locale === "ru") return { ...base, ...i18n.flatten(appRu), ...i18n.flatten(desktopRu) } + if (locale === "ar") return { ...base, ...i18n.flatten(appAr), ...i18n.flatten(desktopAr) } + if (locale === "no") return { ...base, ...i18n.flatten(appNo), ...i18n.flatten(desktopNo) } + if (locale === "br") return { ...base, ...i18n.flatten(appBr), ...i18n.flatten(desktopBr) } + return { ...base, ...i18n.flatten(appKo), ...i18n.flatten(desktopKo) } } const state = { diff --git a/packages/desktop/src/i18n/ja.ts b/packages/desktop/src/i18n/ja.ts new file mode 100644 index 00000000000..94681ab0699 --- /dev/null +++ b/packages/desktop/src/i18n/ja.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "アップデートを確認...", + "desktop.menu.installCli": "CLI をインストール...", + "desktop.menu.reloadWebview": "Webview を再読み込み", + "desktop.menu.restart": "再起動", + + "desktop.dialog.chooseFolder": "フォルダーを選択", + "desktop.dialog.chooseFile": "ファイルを選択", + "desktop.dialog.saveFile": "ファイルを保存", + + "desktop.updater.checkFailed.title": "アップデートの確認に失敗しました", + "desktop.updater.checkFailed.message": "アップデートを確認できませんでした", + "desktop.updater.none.title": "利用可能なアップデートはありません", + "desktop.updater.none.message": "すでに最新バージョンの OpenCode を使用しています", + "desktop.updater.downloadFailed.title": "アップデートに失敗しました", + "desktop.updater.downloadFailed.message": "アップデートをダウンロードできませんでした", + "desktop.updater.downloaded.title": "アップデートをダウンロードしました", + "desktop.updater.downloaded.prompt": + "OpenCode のバージョン {{version}} がダウンロードされました。インストールして再起動しますか?", + "desktop.updater.installFailed.title": "アップデートに失敗しました", + "desktop.updater.installFailed.message": "アップデートをインストールできませんでした", + + "desktop.cli.installed.title": "CLI をインストールしました", + "desktop.cli.installed.message": + "CLI を {{path}} にインストールしました\n\nターミナルを再起動して 'opencode' コマンドを使用してください。", + "desktop.cli.failed.title": "インストールに失敗しました", + "desktop.cli.failed.message": "CLI のインストールに失敗しました: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode の起動に失敗しました", + "desktop.error.serverStartFailed.description": + "ローカルの OpenCode サーバーを起動できませんでした。アプリを再起動するか、ネットワーク設定 (VPN/proxy) を確認して再試行してください。", +} diff --git a/packages/desktop/src/i18n/ko.ts b/packages/desktop/src/i18n/ko.ts new file mode 100644 index 00000000000..93136f2ddd0 --- /dev/null +++ b/packages/desktop/src/i18n/ko.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "업데이트 확인...", + "desktop.menu.installCli": "CLI 설치...", + "desktop.menu.reloadWebview": "Webview 새로고침", + "desktop.menu.restart": "다시 시작", + + "desktop.dialog.chooseFolder": "폴더 선택", + "desktop.dialog.chooseFile": "파일 선택", + "desktop.dialog.saveFile": "파일 저장", + + "desktop.updater.checkFailed.title": "업데이트 확인 실패", + "desktop.updater.checkFailed.message": "업데이트를 확인하지 못했습니다", + "desktop.updater.none.title": "사용 가능한 업데이트 없음", + "desktop.updater.none.message": "이미 최신 버전의 OpenCode를 사용하고 있습니다", + "desktop.updater.downloadFailed.title": "업데이트 실패", + "desktop.updater.downloadFailed.message": "업데이트를 다운로드하지 못했습니다", + "desktop.updater.downloaded.title": "업데이트 다운로드 완료", + "desktop.updater.downloaded.prompt": "OpenCode {{version}} 버전을 다운로드했습니다. 설치하고 다시 실행할까요?", + "desktop.updater.installFailed.title": "업데이트 실패", + "desktop.updater.installFailed.message": "업데이트를 설치하지 못했습니다", + + "desktop.cli.installed.title": "CLI 설치됨", + "desktop.cli.installed.message": + "CLI가 {{path}}에 설치되었습니다\n\n터미널을 다시 시작하여 'opencode' 명령을 사용하세요.", + "desktop.cli.failed.title": "설치 실패", + "desktop.cli.failed.message": "CLI 설치 실패: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode 시작 실패", + "desktop.error.serverStartFailed.description": + "로컬 OpenCode 서버를 시작할 수 없습니다. 앱을 다시 시작하거나 네트워크 설정(VPN/proxy)을 확인한 후 다시 시도하세요.", +} diff --git a/packages/desktop/src/i18n/no.ts b/packages/desktop/src/i18n/no.ts new file mode 100644 index 00000000000..7deb74687d3 --- /dev/null +++ b/packages/desktop/src/i18n/no.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Se etter oppdateringer...", + "desktop.menu.installCli": "Installer CLI...", + "desktop.menu.reloadWebview": "Last inn Webview på nytt", + "desktop.menu.restart": "Start på nytt", + + "desktop.dialog.chooseFolder": "Velg en mappe", + "desktop.dialog.chooseFile": "Velg en fil", + "desktop.dialog.saveFile": "Lagre fil", + + "desktop.updater.checkFailed.title": "Oppdateringssjekk mislyktes", + "desktop.updater.checkFailed.message": "Kunne ikke se etter oppdateringer", + "desktop.updater.none.title": "Ingen oppdatering tilgjengelig", + "desktop.updater.none.message": "Du bruker allerede den nyeste versjonen av OpenCode", + "desktop.updater.downloadFailed.title": "Oppdatering mislyktes", + "desktop.updater.downloadFailed.message": "Kunne ikke laste ned oppdateringen", + "desktop.updater.downloaded.title": "Oppdatering lastet ned", + "desktop.updater.downloaded.prompt": + "Versjon {{version}} av OpenCode er lastet ned. Vil du installere den og starte på nytt?", + "desktop.updater.installFailed.title": "Oppdatering mislyktes", + "desktop.updater.installFailed.message": "Kunne ikke installere oppdateringen", + + "desktop.cli.installed.title": "CLI installert", + "desktop.cli.installed.message": + "CLI installert til {{path}}\n\nStart terminalen på nytt for å bruke 'opencode'-kommandoen.", + "desktop.cli.failed.title": "Installasjon mislyktes", + "desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode kunne ikke starte", + "desktop.error.serverStartFailed.description": + "Den lokale OpenCode-serveren kunne ikke startes. Start appen på nytt, eller sjekk nettverksinnstillingene dine (VPN/proxy) og prøv igjen.", +} diff --git a/packages/desktop/src/i18n/pl.ts b/packages/desktop/src/i18n/pl.ts new file mode 100644 index 00000000000..dac2992ba47 --- /dev/null +++ b/packages/desktop/src/i18n/pl.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Sprawdź aktualizacje...", + "desktop.menu.installCli": "Zainstaluj CLI...", + "desktop.menu.reloadWebview": "Przeładuj Webview", + "desktop.menu.restart": "Restartuj", + + "desktop.dialog.chooseFolder": "Wybierz folder", + "desktop.dialog.chooseFile": "Wybierz plik", + "desktop.dialog.saveFile": "Zapisz plik", + + "desktop.updater.checkFailed.title": "Nie udało się sprawdzić aktualizacji", + "desktop.updater.checkFailed.message": "Nie udało się sprawdzić aktualizacji", + "desktop.updater.none.title": "Brak dostępnych aktualizacji", + "desktop.updater.none.message": "Korzystasz już z najnowszej wersji OpenCode", + "desktop.updater.downloadFailed.title": "Aktualizacja nie powiodła się", + "desktop.updater.downloadFailed.message": "Nie udało się pobrać aktualizacji", + "desktop.updater.downloaded.title": "Aktualizacja pobrana", + "desktop.updater.downloaded.prompt": + "Pobrano wersję {{version}} OpenCode. Czy chcesz ją zainstalować i uruchomić ponownie?", + "desktop.updater.installFailed.title": "Aktualizacja nie powiodła się", + "desktop.updater.installFailed.message": "Nie udało się zainstalować aktualizacji", + + "desktop.cli.installed.title": "CLI zainstalowane", + "desktop.cli.installed.message": + "CLI zainstalowane w {{path}}\n\nUruchom ponownie terminal, aby użyć polecenia 'opencode'.", + "desktop.cli.failed.title": "Instalacja nie powiodła się", + "desktop.cli.failed.message": "Nie udało się zainstalować CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "Nie udało się uruchomić OpenCode", + "desktop.error.serverStartFailed.description": + "Nie udało się uruchomić lokalnego serwera OpenCode. Uruchom ponownie aplikację lub sprawdź ustawienia sieciowe (VPN/proxy) i spróbuj ponownie.", +} diff --git a/packages/desktop/src/i18n/ru.ts b/packages/desktop/src/i18n/ru.ts new file mode 100644 index 00000000000..6e34e1aa7da --- /dev/null +++ b/packages/desktop/src/i18n/ru.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Проверить обновления...", + "desktop.menu.installCli": "Установить CLI...", + "desktop.menu.reloadWebview": "Перезагрузить Webview", + "desktop.menu.restart": "Перезапустить", + + "desktop.dialog.chooseFolder": "Выберите папку", + "desktop.dialog.chooseFile": "Выберите файл", + "desktop.dialog.saveFile": "Сохранить файл", + + "desktop.updater.checkFailed.title": "Не удалось проверить обновления", + "desktop.updater.checkFailed.message": "Не удалось проверить обновления", + "desktop.updater.none.title": "Обновлений нет", + "desktop.updater.none.message": "Вы уже используете последнюю версию OpenCode", + "desktop.updater.downloadFailed.title": "Обновление не удалось", + "desktop.updater.downloadFailed.message": "Не удалось скачать обновление", + "desktop.updater.downloaded.title": "Обновление загружено", + "desktop.updater.downloaded.prompt": "Версия OpenCode {{version}} загружена. Хотите установить и перезапустить?", + "desktop.updater.installFailed.title": "Обновление не удалось", + "desktop.updater.installFailed.message": "Не удалось установить обновление", + + "desktop.cli.installed.title": "CLI установлен", + "desktop.cli.installed.message": + "CLI установлен в {{path}}\n\nПерезапустите терминал, чтобы использовать команду 'opencode'.", + "desktop.cli.failed.title": "Ошибка установки", + "desktop.cli.failed.message": "Не удалось установить CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "Не удалось запустить OpenCode", + "desktop.error.serverStartFailed.description": + "Не удалось запустить локальный сервер OpenCode. Перезапустите приложение или проверьте настройки сети (VPN/proxy) и попробуйте снова.", +} diff --git a/packages/desktop/src/i18n/zh.ts b/packages/desktop/src/i18n/zh.ts new file mode 100644 index 00000000000..3f5fe59d40d --- /dev/null +++ b/packages/desktop/src/i18n/zh.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "检查更新...", + "desktop.menu.installCli": "安装 CLI...", + "desktop.menu.reloadWebview": "重新加载 Webview", + "desktop.menu.restart": "重启", + + "desktop.dialog.chooseFolder": "选择文件夹", + "desktop.dialog.chooseFile": "选择文件", + "desktop.dialog.saveFile": "保存文件", + + "desktop.updater.checkFailed.title": "检查更新失败", + "desktop.updater.checkFailed.message": "无法检查更新", + "desktop.updater.none.title": "没有可用更新", + "desktop.updater.none.message": "你已经在使用最新版本的 OpenCode", + "desktop.updater.downloadFailed.title": "更新失败", + "desktop.updater.downloadFailed.message": "无法下载更新", + "desktop.updater.downloaded.title": "更新已下载", + "desktop.updater.downloaded.prompt": "已下载 OpenCode {{version}} 版本,是否安装并重启?", + "desktop.updater.installFailed.title": "更新失败", + "desktop.updater.installFailed.message": "无法安装更新", + + "desktop.cli.installed.title": "CLI 已安装", + "desktop.cli.installed.message": "CLI 已安装到 {{path}}\n\n重启终端以使用 'opencode' 命令。", + "desktop.cli.failed.title": "安装失败", + "desktop.cli.failed.message": "无法安装 CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode 启动失败", + "desktop.error.serverStartFailed.description": + "无法启动本地 OpenCode 服务器。请重启应用,或检查网络设置 (VPN/proxy) 后重试。", +} diff --git a/packages/desktop/src/i18n/zht.ts b/packages/desktop/src/i18n/zht.ts new file mode 100644 index 00000000000..b09bff742c7 --- /dev/null +++ b/packages/desktop/src/i18n/zht.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "檢查更新...", + "desktop.menu.installCli": "安裝 CLI...", + "desktop.menu.reloadWebview": "重新載入 Webview", + "desktop.menu.restart": "重新啟動", + + "desktop.dialog.chooseFolder": "選擇資料夾", + "desktop.dialog.chooseFile": "選擇檔案", + "desktop.dialog.saveFile": "儲存檔案", + + "desktop.updater.checkFailed.title": "檢查更新失敗", + "desktop.updater.checkFailed.message": "無法檢查更新", + "desktop.updater.none.title": "沒有可用更新", + "desktop.updater.none.message": "你已在使用最新版的 OpenCode", + "desktop.updater.downloadFailed.title": "更新失敗", + "desktop.updater.downloadFailed.message": "無法下載更新", + "desktop.updater.downloaded.title": "更新已下載", + "desktop.updater.downloaded.prompt": "已下載 OpenCode {{version}} 版本,是否安裝並重新啟動?", + "desktop.updater.installFailed.title": "更新失敗", + "desktop.updater.installFailed.message": "無法安裝更新", + + "desktop.cli.installed.title": "CLI 已安裝", + "desktop.cli.installed.message": "CLI 已安裝到 {{path}}\n\n重新啟動終端機以使用 'opencode' 命令。", + "desktop.cli.failed.title": "安裝失敗", + "desktop.cli.failed.message": "無法安裝 CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode 啟動失敗", + "desktop.error.serverStartFailed.description": + "無法啟動本地 OpenCode 伺服器。請重新啟動應用程式,或檢查網路設定 (VPN/proxy) 後再試一次。", +} From 1d5ee3e587e39e8d9df77798c0a1cc5da7cb025b Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:22:47 -0600 Subject: [PATCH 19/38] fix(app): not auto-navigating to last project --- packages/app/src/pages/layout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 1328b96bebd..afef14c84a2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -576,7 +576,6 @@ export default function Layout(props: ParentProps) { openProject(next.worktree, false) navigateToProject(next.worktree) }, - { defer: true }, ), ) From 95632d893b226f26836c577c533631f914cdc3ee Mon Sep 17 00:00:00 2001 From: Github Action Date: Tue, 27 Jan 2026 21:28:23 +0000 Subject: [PATCH 20/38] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index da2f2eaf1fc..0b735b35d6e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-AkI3guNjnE+bLZQVfzm0z14UENOECv2QBqMo5Lzkvt8=", - "aarch64-linux": "sha256-dBfdyVTqW+fBZKCxC9Ld+1m3cP+nIbS6UDo0tUfPOSk=", - "aarch64-darwin": "sha256-tOw31AMnHkW2cEDi+iqT3P93lU3SiMve26TEIqPz97k=", - "x86_64-darwin": "sha256-wL/DmdZmxCmh+r4dsS1XGXuj8VPwR4pUqy5VIA76jl0=" + "x86_64-linux": "sha256-9oI1gekRbjY6L8VwlkLdPty/9rCxC20EJlESkazEX8Y=", + "aarch64-linux": "sha256-vn+eCVanOSNfjyqHRJn4VdqbpdMoBFm49REuIkByAio=", + "aarch64-darwin": "sha256-0dMP5WbqDq3qdLRrKfmCjXz2kUDjTttGTqD3v6PDbkg=", + "x86_64-darwin": "sha256-9dEWluRXY7RTPdSEhhPsDJeGo+qa3V8dqh6n6WsLeGw=" } } From b8e726521dbf8dd3af7fb58d3aea3f0c4ec056d7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 27 Jan 2026 16:29:01 -0500 Subject: [PATCH 21/38] fix(tui): handle 4-5 codes too in c to copy logic --- packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 4e1171a4201..dc3f337370a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -127,7 +127,7 @@ function AutoMethod(props: AutoMethodProps) { useKeyboard((evt) => { if (evt.name === "c" && !evt.ctrl && !evt.meta) { - const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.url + const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url Clipboard.copy(code) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) From 33d400c567a48fc730b2e95f307b9edd99132cc0 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:31:32 -0600 Subject: [PATCH 22/38] fix(app): spinner color --- packages/ui/src/components/spinner.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/spinner.css b/packages/ui/src/components/spinner.css index 6b432d045da..2ca474dc3c3 100644 --- a/packages/ui/src/components/spinner.css +++ b/packages/ui/src/components/spinner.css @@ -1,5 +1,5 @@ [data-component="spinner"] { - color: var(--text-base); + color: inherit; flex-shrink: 0; width: 18px; aspect-ratio: 1; From 605e5335588adeb96bc9549553e9976b84338a8d Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:55:26 -0600 Subject: [PATCH 23/38] fix(app): file tree not always loading --- packages/app/src/components/file-tree.tsx | 24 ++++++++++++++++++- packages/app/src/pages/session.tsx | 28 ++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index d43310b195c..bd989f755d5 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -8,6 +8,7 @@ import { createMemo, For, Match, + onCleanup, Show, splitProps, Switch, @@ -123,7 +124,28 @@ export default function FileTree(props: { createEffect(() => { const path = props.path - untrack(() => void file.tree.list(path)) + const state = { cancelled: false, timer: undefined as number | undefined } + + const load = (attempt: number) => { + if (state.cancelled) return + if (file.tree.state(path)?.loaded) return + + void untrack(() => file.tree.list(path)).finally(() => { + if (state.cancelled) return + if (file.tree.state(path)?.loaded) return + if (attempt >= 2) return + + const wait = Math.min(2000, 250 * 2 ** attempt) + state.timer = window.setTimeout(() => load(attempt + 1), wait) + }) + } + + load(0) + + onCleanup(() => { + state.cancelled = true + if (state.timer !== undefined) clearTimeout(state.timer) + }) }) const nodes = createMemo(() => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7257cdc1193..a4e6e24b11e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1256,7 +1256,33 @@ export default function Page() { if (!wants) return if (sync.data.session_diff[id] !== undefined) return - sync.session.diff(id) + const state = { + cancelled: false, + attempt: 0, + timer: undefined as number | undefined, + } + + const load = () => { + if (state.cancelled) return + const pending = sync.session.diff(id) + if (!pending) return + pending.catch(() => { + if (state.cancelled) return + const attempt = state.attempt + 1 + state.attempt = attempt + if (attempt > 5) return + if (state.timer !== undefined) clearTimeout(state.timer) + const wait = Math.min(10000, 250 * 2 ** (attempt - 1)) + state.timer = window.setTimeout(load, wait) + }) + } + + load() + + onCleanup(() => { + state.cancelled = true + if (state.timer !== undefined) clearTimeout(state.timer) + }) }) const autoScroll = createAutoScroll({ From 13b2587e96060a95da8396ec2da630d2a02f20dd Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:58:57 -0600 Subject: [PATCH 24/38] test(app): fix outdated e2e test --- packages/app/e2e/file-tree.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/file-tree.spec.ts b/packages/app/e2e/file-tree.spec.ts index 12ea7a081fc..c22a810f4f0 100644 --- a/packages/app/e2e/file-tree.spec.ts +++ b/packages/app/e2e/file-tree.spec.ts @@ -3,9 +3,10 @@ import { test, expect } from "./fixtures" test("file tree can expand folders and open a file", async ({ page, gotoSession }) => { await gotoSession() - await page.getByRole("button", { name: "Toggle file tree" }).click() - + const toggle = page.getByRole("button", { name: "Toggle file tree" }) const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]') + + if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click() await expect(treeTabs).toBeVisible() await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click() From 5c8580a187d437f2813a9cc315ab04db5acf7ad1 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:11:58 -0600 Subject: [PATCH 25/38] test(app): fix outdated e2e test --- packages/app/e2e/titlebar-history.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/titlebar-history.spec.ts b/packages/app/e2e/titlebar-history.spec.ts index b8141b98295..d4aa605e6dd 100644 --- a/packages/app/e2e/titlebar-history.spec.ts +++ b/packages/app/e2e/titlebar-history.spec.ts @@ -29,8 +29,8 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) await expect(page.locator(promptSelector)).toBeVisible() - const back = page.getByRole("button", { name: "Go back" }) - const forward = page.getByRole("button", { name: "Go forward" }) + const back = page.getByRole("button", { name: "Back" }) + const forward = page.getByRole("button", { name: "Forward" }) await expect(back).toBeVisible() await expect(back).toBeEnabled() From d17ba84ee1e55093ff33f0ac512cbb00030c21e7 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:13:00 -0600 Subject: [PATCH 26/38] fix(app): file tree not always loading --- packages/app/src/components/file-tree.tsx | 24 +--------------- packages/app/src/pages/session.tsx | 34 ++++++----------------- 2 files changed, 10 insertions(+), 48 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index bd989f755d5..d43310b195c 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -8,7 +8,6 @@ import { createMemo, For, Match, - onCleanup, Show, splitProps, Switch, @@ -124,28 +123,7 @@ export default function FileTree(props: { createEffect(() => { const path = props.path - const state = { cancelled: false, timer: undefined as number | undefined } - - const load = (attempt: number) => { - if (state.cancelled) return - if (file.tree.state(path)?.loaded) return - - void untrack(() => file.tree.list(path)).finally(() => { - if (state.cancelled) return - if (file.tree.state(path)?.loaded) return - if (attempt >= 2) return - - const wait = Math.min(2000, 250 * 2 ** attempt) - state.timer = window.setTimeout(() => load(attempt + 1), wait) - }) - } - - load(0) - - onCleanup(() => { - state.cancelled = true - if (state.timer !== undefined) clearTimeout(state.timer) - }) + untrack(() => void file.tree.list(path)) }) const nodes = createMemo(() => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a4e6e24b11e..eda15a5819b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1255,34 +1255,18 @@ export default function Page() { const wants = isDesktop() ? fileTreeTab() === "changes" : store.mobileTab === "changes" if (!wants) return if (sync.data.session_diff[id] !== undefined) return + if (sync.status === "loading") return - const state = { - cancelled: false, - attempt: 0, - timer: undefined as number | undefined, - } - - const load = () => { - if (state.cancelled) return - const pending = sync.session.diff(id) - if (!pending) return - pending.catch(() => { - if (state.cancelled) return - const attempt = state.attempt + 1 - state.attempt = attempt - if (attempt > 5) return - if (state.timer !== undefined) clearTimeout(state.timer) - const wait = Math.min(10000, 250 * 2 ** (attempt - 1)) - state.timer = window.setTimeout(load, wait) - }) - } + void sync.session.diff(id) + }) - load() + createEffect(() => { + if (!isDesktop()) return + if (!layout.fileTree.opened()) return + if (sync.status === "loading") return - onCleanup(() => { - state.cancelled = true - if (state.timer !== undefined) clearTimeout(state.timer) - }) + fileTreeTab() + void file.tree.list("") }) const autoScroll = createAutoScroll({ From df7f9ae3f41a70eac865451942c1410f6c4281d8 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:51:41 -0600 Subject: [PATCH 27/38] fix(app): terminal corruption --- packages/app/src/context/terminal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 439b196c632..e01b8bc4de9 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -155,8 +155,9 @@ function createTerminalSession(sdk: ReturnType, dir: string, sess batch(() => { setStore("all", index, { - ...pty, - ...clone.data, + id: clone.data.id, + title: clone.data.title ?? pty.title, + titleNumber: pty.titleNumber, }) if (active) { setStore("active", clone.data.id) From 15ffd3cba1d3bd7d4d84c6911623a9c1d19e6647 Mon Sep 17 00:00:00 2001 From: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com> Date: Wed, 28 Jan 2026 07:26:15 +0800 Subject: [PATCH 28/38] feat(app): add 'connect provider' button to the manage models dialog (#10887) --- .../src/components/dialog-manage-models.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index 1ecefa2cbbf..9ee48736ca0 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -1,16 +1,33 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" +import { Button } from "@opencode-ai/ui/button" import type { Component } from "solid-js" import { useLocal } from "@/context/local" import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectProvider } from "./dialog-select-provider" export const DialogManageModels: Component = () => { const local = useLocal() const language = useLanguage() + const dialog = useDialog() + + const handleConnectProvider = () => { + dialog.show(() => ) + } + return ( - + + {language.t("command.provider.connect")} + + } + > Date: Tue, 27 Jan 2026 17:48:21 -0600 Subject: [PATCH 29/38] fix(app): auto-scroll --- packages/app/src/pages/session.tsx | 125 ++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 22 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index eda15a5819b..a845d3a6510 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -478,6 +478,12 @@ export default function Page() { const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset if (targetIndex < 0 || targetIndex >= msgs.length) return + if (targetIndex === msgs.length - 1) { + resumeScroll() + return + } + + autoScroll.pause() scrollToMessage(msgs[targetIndex], "auto") } @@ -524,14 +530,7 @@ export default function Page() { const scrollGestureWindowMs = 250 - const scrollIgnoreWindowMs = 250 - let scrollIgnore = 0 - - const markScrollIgnore = () => { - scrollIgnore = Date.now() - } - - const hasScrollIgnore = () => Date.now() - scrollIgnore < scrollIgnoreWindowMs + let touchGesture: number | undefined const markScrollGesture = (target?: EventTarget | null) => { const root = scroller @@ -1274,9 +1273,15 @@ export default function Page() { overflowAnchor: "dynamic", }) + const clearMessageHash = () => { + if (!window.location.hash) return + window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) + } + const resumeScroll = () => { setStore("messageId", undefined) autoScroll.forceScrollToBottom() + clearMessageHash() } // When the user returns to the bottom, treat the active message as "latest". @@ -1286,6 +1291,7 @@ export default function Page() { (scrolled) => { if (scrolled) return setStore("messageId", undefined) + clearMessageHash() }, { defer: true }, ), @@ -1361,7 +1367,6 @@ export default function Page() { requestAnimationFrame(() => { const delta = el.scrollHeight - beforeHeight if (!delta) return - markScrollIgnore() el.scrollTop = beforeTop + delta }) @@ -1399,7 +1404,6 @@ export default function Page() { if (stick && el) { requestAnimationFrame(() => { - markScrollIgnore() el.scrollTo({ top: el.scrollHeight, behavior: "auto" }) }) } @@ -1494,6 +1498,7 @@ export default function Page() { const match = hash.match(/^message-(.+)$/) if (match) { + autoScroll.pause() const msg = visibleUserMessages().find((m) => m.id === match[1]) if (msg) { scrollToMessage(msg, behavior) @@ -1507,6 +1512,7 @@ export default function Page() { const target = document.getElementById(hash) if (target) { + autoScroll.pause() scrollToElement(target, behavior) return } @@ -1603,6 +1609,7 @@ export default function Page() { const msg = visibleUserMessages().find((m) => m.id === targetId) if (!msg) return if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined) + autoScroll.pause() requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) @@ -1783,28 +1790,102 @@ export default function Page() { >
markScrollGesture(e.target)} - onTouchMove={(e) => markScrollGesture(e.target)} + onWheel={(e) => { + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + markScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + markScrollGesture(root) + return + } + + const max = nested.scrollHeight - nested.clientHeight + if (max <= 1) { + markScrollGesture(root) + return + } + + const delta = + e.deltaMode === 1 + ? e.deltaY * 40 + : e.deltaMode === 2 + ? e.deltaY * root.clientHeight + : e.deltaY + if (!delta) return + + if (delta < 0) { + if (nested.scrollTop + delta <= 0) markScrollGesture(root) + return + } + + const remaining = max - nested.scrollTop + if (delta > remaining) markScrollGesture(root) + }} + onTouchStart={(e) => { + touchGesture = e.touches[0]?.clientY + }} + onTouchMove={(e) => { + const next = e.touches[0]?.clientY + const prev = touchGesture + touchGesture = next + if (next === undefined || prev === undefined) return + + const delta = prev - next + if (!delta) return + + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + markScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + markScrollGesture(root) + return + } + + const max = nested.scrollHeight - nested.clientHeight + if (max <= 1) { + markScrollGesture(root) + return + } + + if (delta < 0) { + if (nested.scrollTop + delta <= 0) markScrollGesture(root) + return + } + + const remaining = max - nested.scrollTop + if (delta > remaining) markScrollGesture(root) + }} + onTouchEnd={() => { + touchGesture = undefined + }} + onTouchCancel={() => { + touchGesture = undefined + }} onPointerDown={(e) => { if (e.target !== e.currentTarget) return - markScrollGesture(e.target) + markScrollGesture(e.currentTarget) }} onScroll={(e) => { - const gesture = hasScrollGesture() - if (!hasScrollIgnore() || gesture) autoScroll.handleScroll() - if (!gesture) return - markScrollGesture(e.target) + if (!hasScrollGesture()) return + autoScroll.handleScroll() + markScrollGesture(e.currentTarget) if (isDesktop()) scheduleScrollSpy(e.currentTarget) }} onClick={autoScroll.handleInteraction} From 898118bafbf8a12c0ef9d6673d36305472991906 Mon Sep 17 00:00:00 2001 From: Rohan Godha Date: Tue, 27 Jan 2026 19:05:52 -0500 Subject: [PATCH 30/38] feat: support headless authentication for chatgpt/codex (#10890) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline --- packages/opencode/src/plugin/codex.ts | 86 ++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 198e8ce25d7..b6f1a96a9f5 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -10,6 +10,7 @@ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 interface PkceCodes { verifier: string @@ -461,7 +462,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { }, methods: [ { - label: "ChatGPT Pro/Plus", + label: "ChatGPT Pro/Plus (browser)", type: "oauth", authorize: async () => { const { redirectUri } = await startOAuthServer() @@ -490,6 +491,89 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { } }, }, + { + label: "ChatGPT Pro/Plus (headless)", + type: "oauth", + authorize: async () => { + const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ client_id: CLIENT_ID }), + }) + + if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization") + + const deviceData = (await deviceResponse.json()) as { + device_auth_id: string + user_code: string + interval: string + } + const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000 + + return { + url: `${ISSUER}/codex/device`, + instructions: `Enter code: ${deviceData.user_code}`, + method: "auto" as const, + async callback() { + while (true) { + const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ + device_auth_id: deviceData.device_auth_id, + user_code: deviceData.user_code, + }), + }) + + if (response.ok) { + const data = (await response.json()) as { + authorization_code: string + code_verifier: string + } + + const tokenResponse = await fetch(`${ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: data.authorization_code, + redirect_uri: `${ISSUER}/deviceauth/callback`, + client_id: CLIENT_ID, + code_verifier: data.code_verifier, + }).toString(), + }) + + if (!tokenResponse.ok) { + throw new Error(`Token exchange failed: ${tokenResponse.status}`) + } + + const tokens: TokenResponse = await tokenResponse.json() + + return { + type: "success" as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + accountId: extractAccountId(tokens), + } + } + + if (response.status !== 403 && response.status !== 404) { + return { type: "failed" as const } + } + + await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS) + } + }, + } + }, + }, { label: "Manually enter API Key", type: "api", From d9741866c52472469c8858b83f44ad9c5a5f4925 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:24:23 -0600 Subject: [PATCH 31/38] fix(app): reintroduce review tab --- packages/app/src/components/prompt-input.tsx | 1 + packages/app/src/context/layout.tsx | 13 +- packages/app/src/pages/session.tsx | 142 +++++++++++++------ 3 files changed, 108 insertions(+), 48 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9f038b6e83b..d46ce095af9 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -190,6 +190,7 @@ export const PromptInput: Component = (props) => { const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) if (wantsReview) { layout.fileTree.setTab("changes") + if (!layout.fileTree.opened()) tabs().open("review") requestAnimationFrame(() => comments.setFocus(focus)) return } diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index d30fd11cfb7..2ea5f043570 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -624,11 +624,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) return { tabs, - active: createMemo(() => (tabs().active === "review" ? undefined : tabs().active)), + active: createMemo(() => tabs().active), all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), setActive(tab: string | undefined) { const session = key() - if (tab === "review") return if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: [], active: tab }) } else { @@ -645,10 +644,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } }, async open(tab: string) { - if (tab === "review") return const session = key() const current = store.sessionTabs[session] ?? { all: [] } + if (tab === "review") { + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: current.all, active: tab }) + return + } + setStore("sessionTabs", session, "active", tab) + return + } + if (tab === "context") { const all = [tab, ...current.all.filter((x) => x !== tab)] if (!store.sessionTabs[session]) { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a845d3a6510..7b4f31c50df 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -23,6 +23,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" @@ -1096,11 +1097,12 @@ export default function Page() { }, 0) } + const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened()) const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) const openedTabs = createMemo(() => tabs() .all() - .filter((tab) => tab !== "context"), + .filter((tab) => tab !== "context" && tab !== "review"), ) const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") @@ -1126,6 +1128,46 @@ export default function Page() { setFileTreeTab("all") } + const reviewPanel = () => ( +
+
+ + + {language.t("session.review.loadingChanges")}
} + > + addCommentToContext({ ...comment, origin: "review" })} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={(path) => { + showAllFiles() + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} + /> + + + +
+ +
{language.t("session.review.empty")}
+
+
+ +
+
+ ) + createEffect( on( () => tabs().active(), @@ -1229,29 +1271,56 @@ export default function Page() { const activeTab = createMemo(() => { const active = tabs().active() if (active === "context") return "context" + if (active === "review" && reviewTab()) return "review" if (active && file.pathFromTab(active)) return normalizeTab(active) const first = openedTabs()[0] if (first) return first if (contextOpen()) return "context" + if (reviewTab() && hasReview()) return "review" return "empty" }) createEffect(() => { if (!layout.ready()) return if (tabs().active()) return - if (openedTabs().length === 0 && !contextOpen()) return + if (openedTabs().length === 0 && !contextOpen() && !(reviewTab() && hasReview())) return const next = activeTab() if (next === "empty") return tabs().setActive(next) }) + createEffect( + on( + () => layout.fileTree.opened(), + (opened, prev) => { + if (prev === undefined) return + if (!isDesktop()) return + + if (opened) { + const active = tabs().active() + const tab = active === "review" || (!active && hasReview()) ? "changes" : "all" + layout.fileTree.setTab(tab) + return + } + + if (fileTreeTab() !== "changes") return + tabs().setActive("review") + }, + { defer: true }, + ), + ) + createEffect(() => { const id = params.id if (!id) return - const wants = isDesktop() ? fileTreeTab() === "changes" : store.mobileTab === "changes" + const wants = isDesktop() + ? layout.fileTree.opened() + ? fileTreeTab() === "changes" + : activeTab() === "review" + : store.mobileTab === "changes" if (!wants) return if (sync.data.session_diff[id] !== undefined) return if (sync.status === "loading") return @@ -2114,7 +2183,7 @@ export default function Page() { >
+ + +
+ + + +
+
{language.t("session.tab.review")}
+ +
+ {reviewCount()} +
+
+
+
+
+
+ + + {reviewPanel()} + + +
@@ -2710,47 +2802,7 @@ export default function Page() { } > -
-
- - - {language.t("session.review.loadingChanges")}
- } - > - addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> - - - -
- -
- {language.t("session.review.empty")} -
-
-
- -
-
+ {reviewPanel()}
From e3be4c9f23a157c437aae56f6841db1c7ec723de Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 28 Jan 2026 02:35:38 +0000 Subject: [PATCH 32/38] release: v1.1.37 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 4 ++-- packages/sdk/js/package.json | 4 ++-- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index d02afd42d3e..16157835459 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -212,7 +212,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -241,7 +241,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -257,7 +257,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.36", + "version": "1.1.37", "bin": { "opencode": "./bin/opencode", }, @@ -361,7 +361,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -381,7 +381,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.36", + "version": "1.1.37", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -392,7 +392,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -405,7 +405,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -447,7 +447,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "zod": "catalog:", }, @@ -458,7 +458,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 8284234c5cb..dc1dbc09b2e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.36", + "version": "1.1.37", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 169f9a7e897..c19c597df19 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 95f019b1cd9..a355b2cf586 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.36", + "version": "1.1.37", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 7202c4cfaef..b98859d0be4 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.36", + "version": "1.1.37", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index fc980b6fbfe..0badbf49643 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 49e032339ca..28a5aa56cb9 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 1a216c88f9e..9940d5cf351 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.36", + "version": "1.1.37", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7c43d144b9a..f0bdce2c4dd 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.36" +version = "1.1.37" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 90abb354281..0ee81b74f72 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.36", + "version": "1.1.37", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 68be07e0c14..1168b607f8f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.36", + "version": "1.1.37", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7e8628de7a4..66e26440b45 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index e6d968ed626..c9626cab39a 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} +} \ No newline at end of file diff --git a/packages/slack/package.json b/packages/slack/package.json index 731c2d15d52..81ebec8fe15 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 4c663b1b1c0..27ac2f5cf58 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 6b9a8d26d54..c59ed182dfd 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.36", + "version": "1.1.37", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 12b3c689169..3725bbec53b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.36", + "version": "1.1.37", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 9812f6bf3a4..7ea0b8979db 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.1.36", + "version": "1.1.37", "publisher": "sst-dev", "repository": { "type": "git", From 7988f52231b7f8b3188831bbd9efd48797036099 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Wed, 28 Jan 2026 04:06:09 +0100 Subject: [PATCH 33/38] feat(app): use opentui markdown component behind experimental flag (#10900) --- .../src/cli/cmd/tui/routes/session/index.tsx | 30 +++++++++++++------ packages/opencode/src/flag/flag.ts | 1 + 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5f47562d2e3..737ec417583 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -58,6 +58,7 @@ import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" +import { Flag } from "@/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import { Clipboard } from "../../util/clipboard" @@ -1338,15 +1339,26 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess return ( - + + + + + + + + ) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 0274dcc82b0..551435e72d0 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -46,6 +46,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK") export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") + export const OPENCODE_EXPERIMENTAL_MARKDOWN = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_MARKDOWN") export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] function number(key: string) { From 5f2a7c630b726b571672ded335cb31d5d4343449 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 28 Jan 2026 03:06:58 +0000 Subject: [PATCH 34/38] chore: generate --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 6 +----- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 737ec417583..04a8c642256 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1341,11 +1341,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess - + Date: Tue, 27 Jan 2026 19:09:55 -0800 Subject: [PATCH 35/38] docs: add Daytona OpenCode plugin to ecosystem (#10917) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index fed64bf77ba..f65177cd199 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -17,6 +17,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | Name | Description | | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| [opencode-daytona](https://github.com/jamesmurdza/daytona/tree/main/libs/opencode-plugin) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews | | [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | | [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | | [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | From 6da9fb8fb964df0c49f652fe7627e76374e59b37 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 28 Jan 2026 03:10:48 +0000 Subject: [PATCH 36/38] chore: generate --- packages/web/src/content/docs/ecosystem.mdx | 64 ++++++++++----------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index f65177cd199..07110dc1b5e 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -15,38 +15,38 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Plugins -| Name | Description | -| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| [opencode-daytona](https://github.com/jamesmurdza/daytona/tree/main/libs/opencode-plugin) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews | -| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | -| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | -| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | -| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | -| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | -| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports | -| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling | -| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | -| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | -| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | -| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations | -| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | -| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | -| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers | -| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | -| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | -| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events | -| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context | -| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection | -| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory | -| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing | -| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control | -| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax | -| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity | -| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms | -| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence | -| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete | -| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install | -| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | +| Name | Description | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| [opencode-daytona](https://github.com/jamesmurdza/daytona/tree/main/libs/opencode-plugin) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | +| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | +| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports | +| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling | +| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | +| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | +| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | +| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | +| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers | +| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | +| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | +| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events | +| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context | +| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection | +| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory | +| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing | +| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control | +| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax | +| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity | +| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms | +| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence | +| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete | +| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install | +| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | --- From aedd7601419a0626c964fcb554a076a2e842e619 Mon Sep 17 00:00:00 2001 From: Tito Date: Wed, 28 Jan 2026 16:24:02 +1300 Subject: [PATCH 37/38] fix(cli): restore brand integrity of CLI wordmark (#10912) --- .../src/cli/cmd/tui/component/logo.tsx | 11 ++-- packages/opencode/src/cli/logo.ts | 6 ++ packages/opencode/src/cli/ui.ts | 57 ++++++++++++++----- 3 files changed, 53 insertions(+), 21 deletions(-) create mode 100644 packages/opencode/src/cli/logo.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 771962b75d1..8e6208b140b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,16 +1,13 @@ import { TextAttributes, RGBA } from "@opentui/core" import { For, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" +import { logo, marks } from "@/cli/logo" // Shadow markers (rendered chars in parens): // _ = full shadow cell (space with bg=shadow) // ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow) // ~ = shadow top only (▀ with fg=shadow) -const SHADOW_MARKER = /[_^~]/ - -const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█__█ █__█ █^^^ █__█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀`] - -const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█___ █__█ █__█ █^^^`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`] +const SHADOW_MARKER = new RegExp(`[${marks}]`) export function Logo() { const { theme } = useTheme() @@ -75,11 +72,11 @@ export function Logo() { return ( - + {(line, index) => ( {renderLine(line, theme.textMuted, false)} - {renderLine(LOGO_RIGHT[index()], theme.text, true)} + {renderLine(logo.right[index()], theme.text, true)} )} diff --git a/packages/opencode/src/cli/logo.ts b/packages/opencode/src/cli/logo.ts new file mode 100644 index 00000000000..44fb93c15b3 --- /dev/null +++ b/packages/opencode/src/cli/logo.ts @@ -0,0 +1,6 @@ +export const logo = { + left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"], + right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"], +} + +export const marks = "_^~" diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index acd1383a070..9df1f4ac550 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,15 +1,9 @@ import z from "zod" import { EOL } from "os" import { NamedError } from "@opencode-ai/util/error" +import { logo as glyphs } from "./logo" export namespace UI { - const LOGO = [ - [`  `, ` ▄ `], - [`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`], - [`█░░█ █░░█ █▀▀▀ █░░█ `, `█░░░ █░░█ █░░█ █▀▀▀`], - [`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ `, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`], - ] - export const CancelledError = NamedError.create("UICancelledError", z.void()) export const Style = { @@ -47,15 +41,50 @@ export namespace UI { } export function logo(pad?: string) { - const result = [] - for (const row of LOGO) { + const result: string[] = [] + const reset = "\x1b[0m" + const left = { + fg: Bun.color("gray", "ansi") ?? "", + shadow: "\x1b[38;5;235m", + bg: "\x1b[48;5;235m", + } + const right = { + fg: reset, + shadow: "\x1b[38;5;238m", + bg: "\x1b[48;5;238m", + } + const gap = " " + const draw = (line: string, fg: string, shadow: string, bg: string) => { + const parts: string[] = [] + for (const char of line) { + if (char === "_") { + parts.push(bg, " ", reset) + continue + } + if (char === "^") { + parts.push(fg, bg, "▀", reset) + continue + } + if (char === "~") { + parts.push(shadow, "▀", reset) + continue + } + if (char === " ") { + parts.push(" ") + continue + } + parts.push(fg, char, reset) + } + return parts.join("") + } + glyphs.left.forEach((row, index) => { if (pad) result.push(pad) - result.push(Bun.color("gray", "ansi")) - result.push(row[0]) - result.push("\x1b[0m") - result.push(row[1]) + result.push(draw(row, left.fg, left.shadow, left.bg)) + result.push(gap) + const other = glyphs.right[index] ?? "" + result.push(draw(other, right.fg, right.shadow, right.bg)) result.push(EOL) - } + }) return result.join("").trimEnd() } From 36b83db7fdc5deea65fe51155b89410fc7fb1f06 Mon Sep 17 00:00:00 2001 From: ops Date: Sun, 18 Jan 2026 04:37:02 +0100 Subject: [PATCH 38/38] feat: add keybinds from ctrl+p menu and mcp --- packages/opencode/src/cli/cmd/tui/app.tsx | 43 +++++++++++++++++++ .../src/cli/cmd/tui/routes/session/index.tsx | 15 +++++++ packages/opencode/src/config/config.ts | 16 +++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 24 +++++++++++ 4 files changed, 98 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 10d7a25f88f..f8893a058cc 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -389,10 +389,52 @@ function App() { slash: { name: "mcps", }, + keybind: "mcp_toggle", onSelect: () => { dialog.replace(() => ) }, }, + ...Array.from({ length: 10 }, (_, i) => { + const slot = i + 1 + return { + title: `Toggle MCP slot ${slot}`, + value: `mcp.toggle.${slot}`, + category: "Agent", + keybind: `mcp_toggle_${slot}` as keyof typeof sync.data.config.keybinds, + hidden: true, + onSelect: async () => { + const mcpNames = Object.keys(sync.data.mcp).sort() + const mcpName = mcpNames[i] + if (!mcpName) { + toast.show({ + variant: "warning", + message: `No MCP configured in slot ${slot}`, + duration: 2000, + }) + return + } + try { + await local.mcp.toggle(mcpName) + const status = await sdk.client.mcp.status() + if (status.data) { + sync.set("mcp", status.data) + } + const isEnabled = local.mcp.isEnabled(mcpName) + toast.show({ + variant: "info", + message: `${mcpName}: ${isEnabled ? "enabled" : "disabled"}`, + duration: 2000, + }) + } catch (error) { + toast.show({ + variant: "error", + message: `Failed to toggle ${mcpName}: ${error}`, + duration: 3000, + }) + } + }, + } + }), { title: "Agent cycle", value: "agent.cycle", @@ -511,6 +553,7 @@ function App() { title: "Toggle console", category: "System", value: "app.console", + keybind: "console_toggle", onSelect: (dialog) => { renderer.console.toggle() dialog.clear() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 04a8c642256..24994aaaee1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -521,6 +521,7 @@ export function Session() { name: "timestamps", aliases: ["toggle-timestamps"], }, + keybind: "timestamps_toggle", onSelect: (dialog) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() @@ -534,11 +535,25 @@ export function Session() { name: "thinking", aliases: ["toggle-thinking"], }, + keybind: "thinking_toggle", onSelect: (dialog) => { setShowThinking((prev) => !prev) dialog.clear() }, }, + { + title: "Toggle diff wrapping", + value: "session.toggle.diffwrap", + category: "Session", + slash: { + name: "diffwrap", + }, + keybind: "diffwrap_toggle", + onSelect: (dialog) => { + setDiffWrapMode((prev) => (prev === "word" ? "none" : "word")) + dialog.clear() + }, + }, { title: showDetails() ? "Hide tool details" : "Show tool details", value: "session.toggle.actions", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba8..a70f11e6385 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -650,6 +650,21 @@ export namespace Config { sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), + console_toggle: z.string().optional().default("none").describe("Toggle console"), + mcp_toggle: z.string().optional().default("none").describe("Toggle MCP dialog"), + mcp_toggle_1: z.string().optional().default("none").describe("Toggle MCP slot 1"), + mcp_toggle_2: z.string().optional().default("none").describe("Toggle MCP slot 2"), + mcp_toggle_3: z.string().optional().default("none").describe("Toggle MCP slot 3"), + mcp_toggle_4: z.string().optional().default("none").describe("Toggle MCP slot 4"), + mcp_toggle_5: z.string().optional().default("none").describe("Toggle MCP slot 5"), + mcp_toggle_6: z.string().optional().default("none").describe("Toggle MCP slot 6"), + mcp_toggle_7: z.string().optional().default("none").describe("Toggle MCP slot 7"), + mcp_toggle_8: z.string().optional().default("none").describe("Toggle MCP slot 8"), + mcp_toggle_9: z.string().optional().default("none").describe("Toggle MCP slot 9"), + mcp_toggle_10: z.string().optional().default("none").describe("Toggle MCP slot 10"), + timestamps_toggle: z.string().optional().default("none").describe("Toggle timestamps visibility"), + thinking_toggle: z.string().optional().default("none").describe("Toggle thinking visibility"), + diffwrap_toggle: z.string().optional().default("none").describe("Toggle diff wrapping"), status_view: z.string().optional().default("s").describe("View status"), session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), @@ -685,6 +700,7 @@ export namespace Config { messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), messages_copy: z.string().optional().default("y").describe("Copy message"), + session_copy: z.string().optional().default("none").describe("Copy session transcript"), messages_undo: z.string().optional().default("u").describe("Undo message"), messages_redo: z.string().optional().default("r").describe("Redo message"), messages_toggle_conceal: z diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2a63d721215..27f5ccad05b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -977,6 +977,26 @@ export type KeybindsConfig = { * Toggle username visibility */ username_toggle?: string + /** + * Toggle console + */ + console_toggle?: string + /** + * Toggle MCP dialog + */ + mcp_toggle?: string + /** + * Toggle timestamps visibility + */ + timestamps_toggle?: string + /** + * Toggle thinking visibility + */ + thinking_toggle?: string + /** + * Toggle diff wrapping + */ + diffwrap_toggle?: string /** * View status */ @@ -1085,6 +1105,10 @@ export type KeybindsConfig = { * Copy message */ messages_copy?: string + /** + * Copy session transcript + */ + session_copy?: string /** * Undo message */