From ad70e2b45642d5c6637dd4086139b987740b6bcf Mon Sep 17 00:00:00 2001 From: tienbac2314 Date: Thu, 16 Jul 2026 10:08:54 +0000 Subject: [PATCH 1/2] fix: rewrite TUI sidebar to use @opentui/solid function API Replace src/tui.tsx (JSX-based, broken) with src/tui.ts (function-based, working). Uses same approach as oh-my-opencode-slim: createElement, insert, setProp from @opentui/solid with sidebar_content slot registration and setInterval + requestRender for live updates. Features ported from original: - Sidebar: status, time (live), tokens, auto-turns, checkpoints, objective - Command palette (Ctrl+P > Goal) - Goal action dialog (Refresh/History/Pause/Resume/Clear) - Toast notifications - All prompt functions Known limitation: tokens only update when goal tools run (pause/complete), not during regular agent work. --- README.md | 2 +- package.json | 4 +- src/{tui.tsx => tui.ts} | 108 ++++++++++++++++++---------------------- test/tui.test.ts | 2 +- 4 files changed, 52 insertions(+), 64 deletions(-) rename src/{tui.tsx => tui.ts} (83%) diff --git a/README.md b/README.md index 9c52d73..d0e3245 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ OpenCode plugin modules are target-specific. This package exports separate modul { "exports": { "./server": "./dist/server.js", - "./tui": "./src/tui.tsx" + "./tui": "./src/tui.ts" } } ``` diff --git a/package.json b/package.json index 4a48d72..5d8b8e8 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,12 @@ "import": "./dist/server.js" }, "./tui": { - "import": "./src/tui.tsx" + "import": "./src/tui.ts" } }, "files": [ "dist", - "src/tui.tsx", + "src/tui.ts", "LICENSE", "README.md" ], diff --git a/src/tui.tsx b/src/tui.ts similarity index 83% rename from src/tui.tsx rename to src/tui.ts index 71fe60d..89127c1 100644 --- a/src/tui.tsx +++ b/src/tui.ts @@ -1,6 +1,5 @@ -/** @jsxImportSource @opentui/solid */ import type { TuiCommand, TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" -import { createMemo, createSignal, onCleanup, Show } from "solid-js" +import { createElement, insert, setProp } from "@opentui/solid" type GoalCheckpoint = { summary: string @@ -63,6 +62,7 @@ type GoalSessionState = { goal: GoalSnapshot | null messageIndex: number } +type ElementChild = string | number | boolean | null | undefined | object type ModernTuiApi = TuiPluginApi & { keymap?: { @@ -78,10 +78,31 @@ type ModernTuiApi = TuiPluginApi & { bindings?: unknown[] }) => () => void } + renderer?: { + requestRender?: () => void + } + lifecycle?: { + onDispose?: (cleanup: () => void) => void + } } const goalCache = new Map() +function element(tag: string, props: Record, children: ElementChild[] = []) { + const node = createElement(tag) + for (const [key, value] of Object.entries(props)) if (value !== undefined) setProp(node, key, value) + for (const child of children) if (child !== null && child !== undefined && child !== false) insert(node, child) + return node +} + +function text(props: Record, children: ElementChild[]) { + return element("text", props, children) +} + +function box(props: Record, children: ElementChild[] = []) { + return element("box", props, children) +} + function goalSnapshotKey(sessionID: string) { return `goal-mode.snapshot.${sessionID}` } @@ -332,63 +353,26 @@ function formatGoal(goal: GoalSnapshot | null) { return lines.join("\n") } -function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) { - const theme = () => props.api.theme.current - const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds()) - const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000) - onCleanup(() => clearInterval(timer)) - const state = createMemo(() => { - props.api.state.session.messages(props.sessionID) - return goalStateFromSession(props.api, props.sessionID) - }) - const goal = createMemo(() => state().goal) - const elapsed = createMemo(() => { - const value = goal() - return value ? liveTimeUsedSeconds(value, nowSeconds()) : 0 - }) - const objective = createMemo(() => goal()?.objective ?? "") - - return ( - - {(value: () => GoalSnapshot) => ( - - - Goal - - Status: {value().status} - Time: {formatDuration(elapsed())} - - Tokens: {value().tokensUsed} - {(budget: () => number) => <>/{budget()}} - - - Auto-continues: {value().autoTurns} - {(budget: () => number) => <>/{budget()}} - - - {(checkpoint: () => GoalCheckpoint) => Checkpoint: {checkpoint().summary}} - - - {(reason: () => string) => Stop: {reason()}} - - - {(status: () => string) => {status()}} - - {objective()} - - } - > - - {value().status === "complete" ? "Goal achieved" : "Goal unmet"} ( - {formatDurationBadge(elapsed())}) - - - )} - - ) +function GoalSidebar(api: TuiPluginApi, sessionID: string) { + const theme = api.theme.current + const state = goalStateFromSession(api, sessionID) + const goal = state.goal + if (!goal) return null + const elapsed = liveTimeUsedSeconds(goal) + if (goal.status === "complete" || goal.status === "unmet") { + return text({ fg: goal.status === "complete" ? theme.primary : theme.textMuted }, [`${goal.status === "complete" ? "Goal achieved" : "Goal unmet"} (${formatDurationBadge(elapsed)})`]) + } + return box({}, [ + text({ fg: theme.text }, ["Goal"]), + text({ fg: theme.textMuted }, [`Status: ${goal.status}`]), + text({ fg: theme.textMuted }, [`Time: ${formatDuration(elapsed)}`]), + text({ fg: theme.textMuted }, [`Tokens: ${goal.tokensUsed}${goal.tokenBudget == null ? "" : `/${goal.tokenBudget}`}`]), + text({ fg: theme.textMuted }, [`Auto-continues: ${goal.autoTurns}${goal.maxAutoTurns == null ? "" : `/${goal.maxAutoTurns}`}`]), + ...(goal.lastCheckpoint ? [text({ fg: theme.textMuted }, [`Checkpoint: ${goal.lastCheckpoint.summary}`])] : []), + ...(goal.stopReason ? [text({ fg: theme.textMuted }, [`Stop: ${goal.stopReason}`])] : []), + ...(goal.lastStatus ? [text({ fg: theme.textMuted }, [goal.lastStatus])] : []), + text({ fg: theme.textMuted }, [goal.objective]), + ]) } function registerGoalCommand(api: TuiPluginApi, command: TuiCommand) { @@ -413,11 +397,15 @@ function registerGoalCommand(api: TuiPluginApi, command: TuiCommand) { } const tui: TuiPlugin = async (api) => { + const modern = api as ModernTuiApi + const renderTimer = setInterval(() => modern.renderer?.requestRender?.(), 1000) + modern.lifecycle?.onDispose?.(() => clearInterval(renderTimer)) + api.slots.register({ order: 125, slots: { sidebar_content(_ctx, props) { - return + return GoalSidebar(api, props.session_id) }, }, }) diff --git a/test/tui.test.ts b/test/tui.test.ts index 9a562a7..1059b01 100644 --- a/test/tui.test.ts +++ b/test/tui.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import plugin, { formatDuration, goalStateFromSession, liveTimeUsedSeconds } from "../src/tui.tsx" +import plugin, { formatDuration, goalStateFromSession, liveTimeUsedSeconds } from "../src/tui.ts" function goal(overrides: Partial[0]> = {}): Parameters[0] { return { From 4c3ce1ea515769de55cb9d6a087a08c4710c87ee Mon Sep 17 00:00:00 2001 From: Tien Bac Nguyen Date: Thu, 23 Jul 2026 10:19:22 +0700 Subject: [PATCH 2/2] fix: restore live goal sidebar timer Replace the repaint-only global interval with a Solid signal so the function-API elapsed text recomputes while an active goal is running. Scope the timer to active sidebar instances and clear it when the rendered goal tree is disposed. Add a render-level regression test that verifies elapsed time advances and the timer is cleaned up. --- src/tui.ts | 22 +++++-------- test/tui.test.ts | 86 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/tui.ts b/src/tui.ts index 89127c1..795f9b4 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,5 +1,6 @@ import type { TuiCommand, TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import { createElement, insert, setProp } from "@opentui/solid" +import { createSignal, onCleanup } from "solid-js" type GoalCheckpoint = { summary: string @@ -62,7 +63,7 @@ type GoalSessionState = { goal: GoalSnapshot | null messageIndex: number } -type ElementChild = string | number | boolean | null | undefined | object +type ElementChild = string | number | boolean | null | undefined | object | (() => string) type ModernTuiApi = TuiPluginApi & { keymap?: { @@ -78,12 +79,6 @@ type ModernTuiApi = TuiPluginApi & { bindings?: unknown[] }) => () => void } - renderer?: { - requestRender?: () => void - } - lifecycle?: { - onDispose?: (cleanup: () => void) => void - } } const goalCache = new Map() @@ -358,14 +353,19 @@ function GoalSidebar(api: TuiPluginApi, sessionID: string) { const state = goalStateFromSession(api, sessionID) const goal = state.goal if (!goal) return null - const elapsed = liveTimeUsedSeconds(goal) if (goal.status === "complete" || goal.status === "unmet") { + const elapsed = liveTimeUsedSeconds(goal) return text({ fg: goal.status === "complete" ? theme.primary : theme.textMuted }, [`${goal.status === "complete" ? "Goal achieved" : "Goal unmet"} (${formatDurationBadge(elapsed)})`]) } + const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds()) + if (goal.status === "active") { + const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000) + onCleanup(() => clearInterval(timer)) + } return box({}, [ text({ fg: theme.text }, ["Goal"]), text({ fg: theme.textMuted }, [`Status: ${goal.status}`]), - text({ fg: theme.textMuted }, [`Time: ${formatDuration(elapsed)}`]), + text({ fg: theme.textMuted }, [() => `Time: ${formatDuration(liveTimeUsedSeconds(goal, nowSeconds()))}`]), text({ fg: theme.textMuted }, [`Tokens: ${goal.tokensUsed}${goal.tokenBudget == null ? "" : `/${goal.tokenBudget}`}`]), text({ fg: theme.textMuted }, [`Auto-continues: ${goal.autoTurns}${goal.maxAutoTurns == null ? "" : `/${goal.maxAutoTurns}`}`]), ...(goal.lastCheckpoint ? [text({ fg: theme.textMuted }, [`Checkpoint: ${goal.lastCheckpoint.summary}`])] : []), @@ -397,10 +397,6 @@ function registerGoalCommand(api: TuiPluginApi, command: TuiCommand) { } const tui: TuiPlugin = async (api) => { - const modern = api as ModernTuiApi - const renderTimer = setInterval(() => modern.renderer?.requestRender?.(), 1000) - modern.lifecycle?.onDispose?.(() => clearInterval(renderTimer)) - api.slots.register({ order: 125, slots: { diff --git a/test/tui.test.ts b/test/tui.test.ts index 1059b01..dac7e5f 100644 --- a/test/tui.test.ts +++ b/test/tui.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from "bun:test" +import { expect, setSystemTime, spyOn, test } from "bun:test" +import { testRender } from "@opentui/solid" import plugin, { formatDuration, goalStateFromSession, liveTimeUsedSeconds } from "../src/tui.ts" function goal(overrides: Partial[0]> = {}): Parameters[0] { @@ -115,6 +116,89 @@ test("live goal time advances from the authoritative snapshot sample time", () = expect(liveTimeUsedSeconds(goal({ sampledAt: undefined }), 130)).toBe(10) }) +test("active goal sidebar advances visible elapsed time after a timer tick", async () => { + const intervalCallbacks: (() => void)[] = [] + const timer = 1 as unknown as ReturnType + const setIntervalSpy = spyOn(globalThis, "setInterval").mockImplementation(((callback: unknown, delay?: number) => { + if (delay === 1000 && typeof callback === "function") { + intervalCallbacks.push(() => callback()) + return timer + } + throw new Error(`Unexpected interval delay: ${delay}`) + }) as unknown as typeof setInterval) + const clearIntervalSpy = spyOn(globalThis, "clearInterval").mockImplementation(() => {}) + let sidebar: ((ctx: unknown, props: { session_id: string }) => unknown) | undefined + const snapshot = goal() + const api = { + slots: { + register(input: { slots: { sidebar_content: (ctx: unknown, props: { session_id: string }) => unknown } }) { + sidebar = input.slots.sidebar_content + return "slot-id" + }, + }, + command: { + register() { + return () => {} + }, + }, + route: { current: { name: "session", params: { sessionID: "session" } } }, + ui: { + toast() {}, + dialog: { + setSize() {}, + replace() {}, + clear() {}, + }, + }, + theme: { + current: { + text: "#ffffff", + textMuted: "#888888", + primary: "#00ff00", + }, + }, + state: { + session: { + messages() { + return [{ id: "active" }] + }, + }, + part() { + return [{ type: "tool", tool: "create_goal", state: { status: "completed", output: JSON.stringify({ goal: snapshot }) } }] + }, + }, + kv: { + get(_key: string, fallback: unknown) { + return fallback + }, + set() {}, + }, + } + + setSystemTime(new Date(100_000)) + await plugin.tui(api as never, undefined, undefined as never) + const setup = await testRender(() => sidebar?.({}, { session_id: "session" }) as never, { width: 80, height: 20 }) + let destroyed = false + try { + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain("Time: 0:10") + + setSystemTime(new Date(105_000)) + for (const callback of intervalCallbacks) callback() + await setup.flush() + + expect(setup.captureCharFrame()).toContain("Time: 0:15") + setup.renderer.destroy() + destroyed = true + expect(clearIntervalSpy).toHaveBeenCalledWith(timer) + } finally { + if (!destroyed) setup.renderer.destroy() + clearIntervalSpy.mockRestore() + setIntervalSpy.mockRestore() + setSystemTime() + } +}) + test("formats goal durations for display", () => { expect(formatDuration(0)).toBe("0:00") expect(formatDuration(65)).toBe("1:05")