From ae5eceb696fae437e980f7c7adbd3b3fc13036f8 Mon Sep 17 00:00:00 2001 From: Daniel Saldarriaga Date: Sat, 3 Oct 2026 08:27:40 +0200 Subject: [PATCH] fix: refresh goal sidebar from persisted state --- README.md | 2 +- dist/server.js | 20 +++--- package.json | 1 + src/state-path.ts | 10 +++ src/state.ts | 17 ++---- src/tui.ts | 146 ++++++++++++++++++++++++++++++++++---------- test/tui-v2.test.ts | 116 ++++++++++++++++++++++++++++++++++- test/tui.test.ts | 83 ++++++++++++++++++++++++- 8 files changed, 338 insertions(+), 57 deletions(-) create mode 100644 src/state-path.ts diff --git a/README.md b/README.md index 6dcea61..648110e 100644 --- a/README.md +++ b/README.md @@ -317,4 +317,4 @@ OpenCode plugin modules are target-specific. This package exports separate modul Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn, bounded by the `max_task_block_seconds` ceiling so an unobservable child cannot stall a goal indefinitely. During compaction on OpenCode 1, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative; on OpenCode 2, compaction runs inside the session's execution flow, so the plugin instead injects the goal snapshot through the `session.compaction` hook where the host provides it. -The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. Closed goals remain visible briefly through the latest tool state as achieved or unmet. +The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. It checks the shared goal state file every second so usage and checkpoints stay current during a long run. Closed goals remain visible briefly through the latest tool state as achieved or unmet. diff --git a/dist/server.js b/dist/server.js index c0d4ae2..b5f3ac8 100644 --- a/dist/server.js +++ b/dist/server.js @@ -5,8 +5,7 @@ import { z } from "zod"; // src/state.ts import { randomUUID as randomUUID2 } from "crypto"; import { mkdir, readFile } from "fs/promises"; -import { homedir } from "os"; -import { dirname as dirname2, join } from "path"; +import { dirname as dirname2 } from "path"; import { Data, Effect, Schema } from "effect"; // src/atomic-write.ts @@ -117,6 +116,16 @@ async function atomicWriteFile(file, data, ops = defaultAtomicWriteOps) { } } +// src/state-path.ts +import { homedir } from "os"; +import { join } from "path"; +function statePath() { + if (process.env.OPENCODE_GOAL_STATE_PATH) + return process.env.OPENCODE_GOAL_STATE_PATH; + const dataHome = process.env.XDG_DATA_HOME || (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share")); + return join(dataHome, "opencode-goal-plugin", "goals.json"); +} + // src/state.ts class StateReadError extends Data.TaggedError("StateReadError") { } @@ -232,13 +241,6 @@ var StateSchema = Schema.Struct({ }) }); var PersistedStateSchema = Schema.Union(LegacyStateSchema, StateSchema); -function defaultStateFile() { - const dataHome = process.env.XDG_DATA_HOME || (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share")); - return join(dataHome, "opencode-goal-plugin", "goals.json"); -} -function statePath() { - return process.env.OPENCODE_GOAL_STATE_PATH || defaultStateFile(); -} function nowSeconds() { return Math.floor(Date.now() / 1000); } diff --git a/package.json b/package.json index d0c7d91..70364c0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dist", "src/tui.ts", "src/i18n.ts", + "src/state-path.ts", "LICENSE", "README.md" ], diff --git a/src/state-path.ts b/src/state-path.ts new file mode 100644 index 0000000..43a70af --- /dev/null +++ b/src/state-path.ts @@ -0,0 +1,10 @@ +import { homedir } from "node:os" +import { join } from "node:path" + +export function statePath() { + if (process.env.OPENCODE_GOAL_STATE_PATH) return process.env.OPENCODE_GOAL_STATE_PATH + const dataHome = + process.env.XDG_DATA_HOME || + (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share")) + return join(dataHome, "opencode-goal-plugin", "goals.json") +} diff --git a/src/state.ts b/src/state.ts index a3b1afc..8440f25 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,10 +1,12 @@ import { randomUUID } from "node:crypto" import { readFileSync } from "node:fs" import { mkdir, readFile } from "node:fs/promises" -import { homedir } from "node:os" -import { dirname, join } from "node:path" +import { dirname } from "node:path" import { Data, Effect, Schema } from "effect" import { atomicWriteFile } from "./atomic-write" +import { statePath } from "./state-path" + +export { statePath } from "./state-path" export type GoalStatus = "active" | "paused" | "budgetLimited" | "usageLimited" | "complete" | "unmet" | "cancelled" export type MutableGoalStatus = "active" | "paused" @@ -320,17 +322,6 @@ export type GoalListItem = Pick< | "stopReason" > & { remainingTokens: number | null } -function defaultStateFile() { - const dataHome = - process.env.XDG_DATA_HOME || - (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share")) - return join(dataHome, "opencode-goal-plugin", "goals.json") -} - -export function statePath() { - return process.env.OPENCODE_GOAL_STATE_PATH || defaultStateFile() -} - function nowSeconds() { return Math.floor(Date.now() / 1000) } diff --git a/src/tui.ts b/src/tui.ts index 8fa6335..5bd0686 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -3,8 +3,10 @@ import type { Plugin as TuiPluginV2 } from "@opencode/plugin/tui" import type { SessionMessageInfo } from "@opencode/client" import { createElement, insert, setProp } from "@opentui/solid" import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { readFile, stat } from "node:fs/promises" import type { GoalMessages } from "./i18n" import { messagesFor, presentGoalLastStatus, presentGoalStatus, presentGoalStopReason, resolveLocale } from "./i18n" +import { statePath } from "./state-path" type GoalCheckpoint = { summary: string @@ -413,6 +415,66 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot { return true } +/** Read only the current session from the server's atomic state file. */ +export async function readPersistedGoal(sessionID: string): Promise { + try { + const state: unknown = JSON.parse(await readFile(statePath(), "utf8")) + if (!isRecord(state) || (state.version !== 1 && state.version !== 2) || !isRecord(state.goals)) return undefined + const stored = state.goals[sessionID] + if (stored === undefined) return null + if (!isRecord(stored) || stored.sessionID !== sessionID) return undefined + const sampledAt = currentEpochSeconds() + const timeUsedSeconds = typeof stored.timeUsedSeconds === "number" ? stored.timeUsedSeconds : 0 + const activeSeconds = stored.status === "active" && typeof stored.lastAccountedAt === "number" + ? Math.max(0, sampledAt - stored.lastAccountedAt) + : 0 + const tokenBudget = stored.tokenBudget + const tokensUsed = stored.tokensUsed + const snapshot = { + ...stored, + timeUsedSeconds: timeUsedSeconds + activeSeconds, + sampledAt, + remainingTokens: typeof tokenBudget === "number" && typeof tokensUsed === "number" + ? Math.max(0, tokenBudget - tokensUsed) + : null, + } + return isGoalSnapshot(snapshot) ? snapshot : undefined + } catch { + // Keep the last valid snapshot when the file is unavailable or incomplete. + return undefined + } +} + +function usePersistedGoal(sessionID: string) { + const [persisted, setPersisted] = createSignal(undefined) + let disposed = false + let reading = false + let lastFile: { ino: number; mtimeMs: number; size: number } | undefined + const refresh = async () => { + if (reading) return + reading = true + try { + const file = await stat(statePath()) + if (lastFile && lastFile.ino === file.ino && lastFile.mtimeMs === file.mtimeMs && lastFile.size === file.size) return + const next = await readPersistedGoal(sessionID) + if (next === undefined) return + lastFile = { ino: file.ino, mtimeMs: file.mtimeMs, size: file.size } + if (!disposed && (next !== null || persisted() !== undefined)) setPersisted(() => next) + } catch { + // Missing or unreadable state leaves the last valid view in place. + } finally { + reading = false + } + } + void refresh() + const timer = setInterval(() => void refresh(), 1000) + onCleanup(() => { + disposed = true + clearInterval(timer) + }) + return persisted +} + function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefined { if (part.type !== "tool") return undefined if (!GOAL_TOOL_NAMES.includes(part.tool ?? "")) return undefined @@ -474,39 +536,50 @@ export function formatGoal(goal: GoalSnapshot | null, messages: GoalMessages, lo function GoalSidebar(api: TuiPluginApi, messages: GoalMessages, locale: ReturnType, sessionID: string) { const theme = api.theme.current const state = goalStateFromSession(api, sessionID) - const goal = state.goal - if (!goal) return null - if (goal.status === "complete" || goal.status === "unmet") { - const elapsed = liveTimeUsedSeconds(goal) - const label = goal.status === "complete" ? messages.tui.achieved : messages.tui.unmet - return text({ fg: goal.status === "complete" ? theme.primary : theme.textMuted }, [ - `${label} (${formatDurationBadge(elapsed)})`, - ]) - } + if (!state.goal) return null + const persisted = usePersistedGoal(sessionID) + const goal = createMemo(() => { + const live = persisted() + if (live === undefined) return state.goal + if (live && state.goal && (state.goal.createdAt > live.createdAt || state.goal.updatedAt > live.updatedAt)) return state.goal + return live + }) const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds()) - if (goal.status === "active") { + const active = createMemo(() => goal()?.status === "active") + createEffect(() => { + if (!active()) return const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000) onCleanup(() => clearInterval(timer)) - } - return box({}, [ - text({ fg: theme.text }, [messages.tui.title]), - text({ fg: theme.textMuted }, [`${messages.tui.status}: ${presentGoalStatus(goal.status, locale)}`]), - text({ fg: theme.textMuted }, [() => `${messages.tui.time}: ${formatDuration(liveTimeUsedSeconds(goal, nowSeconds()))}`]), - text({ fg: theme.textMuted }, [ - `${messages.tui.tokens}: ${goal.tokensUsed}${goal.tokenBudget == null ? "" : `/${goal.tokenBudget}`}`, - ]), - text({ fg: theme.textMuted }, [ - `${messages.tui.autoContinues}: ${goal.autoTurns}${goal.maxAutoTurns == null ? "" : `/${goal.maxAutoTurns}`}`, - ]), - ...(goal.lastCheckpoint - ? [text({ fg: theme.textMuted }, [`${messages.tui.checkpoint}: ${goal.lastCheckpoint.summary}`])] - : []), - ...(goal.stopReason - ? [text({ fg: theme.textMuted }, [`${messages.tui.stop}: ${presentGoalStopReason(goal.stopReason, locale)}`])] - : []), - ...(goal.lastStatus ? [text({ fg: theme.textMuted }, [presentGoalLastStatus(goal.lastStatus, locale)])] : []), - text({ fg: theme.textMuted }, [goal.objective]), - ]) + }) + return box({}, [() => { + const snapshot = goal() + if (!snapshot) return null + if (snapshot.status === "complete" || snapshot.status === "unmet") { + const elapsed = liveTimeUsedSeconds(snapshot) + return text({ fg: snapshot.status === "complete" ? theme.primary : theme.textMuted }, [ + `${snapshot.status === "complete" ? messages.tui.achieved : messages.tui.unmet} (${formatDurationBadge(elapsed)})`, + ]) + } + return box({}, [ + text({ fg: theme.text }, [messages.tui.title]), + text({ fg: theme.textMuted }, [`${messages.tui.status}: ${presentGoalStatus(snapshot.status, locale)}`]), + text({ fg: theme.textMuted }, [`${messages.tui.time}: ${formatDuration(liveTimeUsedSeconds(snapshot, nowSeconds()))}`]), + text({ fg: theme.textMuted }, [ + `${messages.tui.tokens}: ${snapshot.tokensUsed}${snapshot.tokenBudget == null ? "" : `/${snapshot.tokenBudget}`}`, + ]), + text({ fg: theme.textMuted }, [ + `${messages.tui.autoContinues}: ${snapshot.autoTurns}${snapshot.maxAutoTurns == null ? "" : `/${snapshot.maxAutoTurns}`}`, + ]), + ...(snapshot.lastCheckpoint + ? [text({ fg: theme.textMuted }, [`${messages.tui.checkpoint}: ${snapshot.lastCheckpoint.summary}`])] + : []), + ...(snapshot.stopReason + ? [text({ fg: theme.textMuted }, [`${messages.tui.stop}: ${presentGoalStopReason(snapshot.stopReason, locale)}`])] + : []), + ...(snapshot.lastStatus ? [text({ fg: theme.textMuted }, [presentGoalLastStatus(snapshot.lastStatus, locale)])] : []), + text({ fg: theme.textMuted }, [snapshot.objective]), + ]) + }]) } function registerGoalCommand(api: TuiPluginApi, command: TuiCommand) { @@ -657,8 +730,16 @@ function GoalSidebarV2( const [cache, setCache] = api.storage.memory<{ goal: GoalSnapshot | null }>(`goal-mode.v2.${sessionID}`, { initial: { goal: null }, }) + const persisted = usePersistedGoal(sessionID) + const messageGoal = createMemo(() => goalFromV2Messages(api.data.session.message.list(sessionID))) const goal = createMemo(() => { - const found = goalFromV2Messages(api.data.session.message.list(sessionID)) + const found = messageGoal() + if (found === null) return null + const live = persisted() + if (live !== undefined) { + if (found && live && (found.createdAt > live.createdAt || found.updatedAt > live.updatedAt)) return found + return live + } return found === undefined ? cache.goal : found }) createEffect(() => @@ -668,8 +749,9 @@ function GoalSidebarV2( ) const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds()) + const active = createMemo(() => goal()?.status === "active") createEffect(() => { - if (goal()?.status !== "active") return + if (!active()) return const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000) onCleanup(() => clearInterval(timer)) }) diff --git a/test/tui-v2.test.ts b/test/tui-v2.test.ts index d3801b6..be82ec1 100644 --- a/test/tui-v2.test.ts +++ b/test/tui-v2.test.ts @@ -1,4 +1,8 @@ -import { expect, test } from "bun:test" +import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test" +import { mkdtempSync, rmSync, unlinkSync } from "node:fs" +import { rename, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" import { DEFAULT_THEME, resolveThemeDocument, type ResolvedTheme } from "@opencode/theme/tui" import { testRender } from "@opentui/solid" import { createSignal } from "solid-js" @@ -7,6 +11,7 @@ import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/ import plugin, { goalFromV2Messages, liveTimeUsedSeconds, + readPersistedGoal, registerSlotV2, setupTuiV2, themeColorV2, @@ -14,6 +19,34 @@ import plugin, { type GoalSnapshot = Parameters[0] +const previousStatePath = process.env.OPENCODE_GOAL_STATE_PATH +const isolatedStateDir = mkdtempSync(join(tmpdir(), "goal-tui-v2-")) +const isolatedStatePath = join(isolatedStateDir, "goals.json") + +async function writeGoalState(value: unknown) { + const temporary = `${isolatedStatePath}.tmp` + await writeFile(temporary, JSON.stringify(value)) + await rename(temporary, isolatedStatePath) +} + +beforeAll(() => { + process.env.OPENCODE_GOAL_STATE_PATH = isolatedStatePath +}) + +beforeEach(() => { + try { + unlinkSync(isolatedStatePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } +}) + +afterAll(() => { + if (previousStatePath === undefined) delete process.env.OPENCODE_GOAL_STATE_PATH + else process.env.OPENCODE_GOAL_STATE_PATH = previousStatePath + rmSync(isolatedStateDir, { recursive: true, force: true }) +}) + function goal(overrides: Partial = {}): GoalSnapshot { return { sessionID: "session", @@ -440,6 +473,87 @@ test("V2 sidebar renders the parsed goal from session messages", async () => { } }) +test("V2 sidebar refreshes persisted usage and checkpoint without a new goal tool result", async () => { + const initial = goal({ objective: "live counters" }) + const { mock, slots, setMessages } = makeMockContext() + const cleanup = setupTuiV2(mock as never) + const sidebar = slots.get("sidebar.content") + setMessages([assistantMessage("created", [goalTool("create_goal", JSON.stringify({ goal: initial }))])]) + await writeGoalState({ version: 2, goals: { session: { ...initial, lastAccountedAt: 100 } }, archives: {} }) + + const setup = await testRender(() => sidebar?.({ sessionID: "session" }) as never, { width: 80, height: 20 }) + try { + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain("Tokens: 0") + + const updated = goal({ + objective: "live counters", + tokensUsed: 42, + autoTurns: 2, + lastCheckpoint: { summary: "parser verified", timestamp: 101 }, + updatedAt: 101, + }) + await writeGoalState({ version: 2, goals: { session: { ...updated, lastAccountedAt: 101 } }, archives: {} }) + + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + await setup.flush() + const frame = setup.captureCharFrame() + if (frame.includes("Tokens: 42") && frame.includes("Auto-continues: 2") && frame.includes("parser verified")) break + await new Promise((resolve) => setTimeout(resolve, 25)) + } + const frame = setup.captureCharFrame() + expect(frame).toContain("Tokens: 42") + expect(frame).toContain("Auto-continues: 2") + expect(frame).toContain("parser verified") + } finally { + setup.renderer.destroy() + cleanup() + } +}) + +test("V2 sidebar removes a goal when the persisted session entry is cleared", async () => { + const initial = goal({ objective: "goal to clear" }) + const { mock, slots, setMessages } = makeMockContext() + const cleanup = setupTuiV2(mock as never) + setMessages([assistantMessage("created", [goalTool("create_goal", JSON.stringify({ goal: initial }))])]) + await writeGoalState({ version: 2, goals: { session: { ...initial, tokensUsed: 1, lastAccountedAt: 100 } }, archives: {} }) + + const setup = await testRender(() => slots.get("sidebar.content")?.({ sessionID: "session" }) as never, { width: 80, height: 20 }) + try { + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain("goal to clear") + const initialDeadline = Date.now() + 5000 + while (Date.now() < initialDeadline) { + await setup.flush() + if (setup.captureCharFrame().includes("Tokens: 1")) break + await new Promise((resolve) => setTimeout(resolve, 10)) + } + expect(setup.captureCharFrame()).toContain("Tokens: 1") + await writeGoalState({ version: 2, goals: {}, archives: {} }) + + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + await setup.flush() + if (!setup.captureCharFrame().includes("goal to clear")) break + await new Promise((resolve) => setTimeout(resolve, 25)) + } + expect(setup.captureCharFrame()).not.toContain("goal to clear") + } finally { + setup.renderer.destroy() + cleanup() + } +}) + +test("V2 persisted reader distinguishes cleared sessions from unreadable state", async () => { + await writeFile(isolatedStatePath, JSON.stringify({ version: 2, goals: {}, archives: {} })) + expect(await readPersistedGoal("session")).toBeNull() + await writeFile(isolatedStatePath, "{ broken json") + expect(await readPersistedGoal("session")).toBeUndefined() + await writeFile(isolatedStatePath, "\0\0\0") + expect(await readPersistedGoal("session")).toBeUndefined() +}) + test("V2 TUI uses zh-CN labels and palette text when locale is configured", async () => { const { mock, slots, layers, setMessages } = makeMockContext({ options: { locale: "zh-CN" } }) diff --git a/test/tui.test.ts b/test/tui.test.ts index 510548e..9b256a5 100644 --- a/test/tui.test.ts +++ b/test/tui.test.ts @@ -1,8 +1,40 @@ -import { expect, setSystemTime, spyOn, test } from "bun:test" +import { afterAll, beforeAll, beforeEach, expect, setSystemTime, spyOn, test } from "bun:test" +import { mkdtempSync, rmSync, unlinkSync } from "node:fs" +import { rename, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" import { testRender } from "@opentui/solid" import plugin, { formatDuration, formatGoal, goalStateFromSession, liveTimeUsedSeconds } from "../src/tui.ts" import { messagesFor } from "../src/i18n" +const previousStatePath = process.env.OPENCODE_GOAL_STATE_PATH +const isolatedStateDir = mkdtempSync(join(tmpdir(), "goal-tui-v1-")) +const isolatedStatePath = join(isolatedStateDir, "goals.json") + +async function writeGoalState(value: unknown) { + const temporary = `${isolatedStatePath}.tmp` + await writeFile(temporary, JSON.stringify(value)) + await rename(temporary, isolatedStatePath) +} + +beforeAll(() => { + process.env.OPENCODE_GOAL_STATE_PATH = isolatedStatePath +}) + +beforeEach(() => { + try { + unlinkSync(isolatedStatePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } +}) + +afterAll(() => { + if (previousStatePath === undefined) delete process.env.OPENCODE_GOAL_STATE_PATH + else process.env.OPENCODE_GOAL_STATE_PATH = previousStatePath + rmSync(isolatedStateDir, { recursive: true, force: true }) +}) + function goal(overrides: Partial[0]> = {}): Parameters[0] { return { sessionID: "session", @@ -200,6 +232,55 @@ test("active goal sidebar advances visible elapsed time after a timer tick", asy } }) +test("V1 sidebar refreshes persisted counters without another goal tool result", async () => { + let sidebar: ((ctx: unknown, props: { session_id: string }) => unknown) | undefined + const initial = goal({ objective: "live V1 goal" }) + const api = { + slots: { + register(input: { slots: { sidebar_content: (ctx: unknown, props: { session_id: string }) => unknown } }) { + sidebar = input.slots.sidebar_content + }, + }, + command: { register() {} }, + route: { current: { name: "session", params: { sessionID: "session" } } }, + ui: { toast() {}, dialog: { setSize() {}, replace() {}, clear() {} } }, + theme: { current: { text: "#ffffff", textMuted: "#888888", primary: "#00ff00" } }, + state: { + session: { messages: () => [{ id: "created" }] }, + part: () => [{ type: "tool", tool: "create_goal", state: { status: "completed", output: JSON.stringify({ goal: initial }) } }], + }, + } + await writeGoalState({ version: 2, goals: { session: { ...initial, lastAccountedAt: 100 } }, archives: {} }) + await plugin.tui(api as never, undefined, undefined as never) + const setup = await testRender(() => sidebar?.({}, { session_id: "session" }) as never, { width: 80, height: 20 }) + try { + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain("Tokens: 0") + + const updated = goal({ + objective: "live V1 goal", + tokensUsed: 73, + autoTurns: 3, + lastCheckpoint: { summary: "build verified", timestamp: 101 }, + updatedAt: 101, + }) + await writeGoalState({ version: 2, goals: { session: { ...updated, lastAccountedAt: 101 } }, archives: {} }) + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + await setup.flush() + const frame = setup.captureCharFrame() + if (frame.includes("Tokens: 73") && frame.includes("Auto-continues: 3") && frame.includes("build verified")) break + await new Promise((resolve) => setTimeout(resolve, 25)) + } + const frame = setup.captureCharFrame() + expect(frame).toContain("Tokens: 73") + expect(frame).toContain("Auto-continues: 3") + expect(frame).toContain("build verified") + } finally { + setup.renderer.destroy() + } +}) + test("formats goal durations for display", () => { expect(formatDuration(0)).toBe("0:00") expect(formatDuration(65)).toBe("1:05")