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 {