diff --git a/src/tui.ts b/src/tui.ts index 9acfe59..2693cc6 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -111,6 +111,61 @@ function box(props: Record, children: ElementChild[] = []) { return element("box", props, children) } +type SlotRender = (props: { sessionID: string }) => unknown +type SlotDispose = () => void + +const noopDispose: SlotDispose = () => {} + +/** + * Registers a V2 TUI slot across both plugin-context generations. + * + * Early V2 previews exposed `ui.slot(name, render)`. Current previews expose a + * single options argument, `ui.slot({ append, render })`, and silently register + * nothing when handed the positional pair — which is how the goal sidebar and + * the palette keymap layer both disappeared. Branch on the callback arity so + * either host works, and tolerate hosts that return no disposer. + */ +export function registerSlotV2(context: TuiPluginV2.Context, name: string, render: SlotRender): SlotDispose { + const slot = context.ui.slot as unknown as (...args: unknown[]) => unknown + const dispose = slot.length <= 1 ? slot({ append: name, render }) : slot(name, render) + return typeof dispose === "function" ? (dispose as SlotDispose) : noopDispose +} + +/** + * Reads a theme color by trying each candidate path in order, descending into a + * `default` leaf when the resolved node is a color group. + * + * Current previews expose a nested theme (`text.default`, `text.subdued`, + * `text.feedback.success`), while earlier previews and the V1 TUI expose flat + * keys (`text`, `textMuted`, `primary`). Passing a color *group* as `fg` + * renders nothing useful, so resolve to a leaf before handing it to OpenTUI. + */ +export function themeColorV2(theme: unknown, ...paths: readonly (readonly string[])[]): unknown { + for (const path of paths) { + let cursor: unknown = theme + for (const key of path) { + if (cursor === null || typeof cursor !== "object") { + cursor = undefined + break + } + cursor = (cursor as Record)[key] + } + if (cursor !== null && typeof cursor === "object" && "default" in (cursor as Record)) { + cursor = (cursor as Record).default + } + if (cursor !== undefined && cursor !== null) return cursor + } + return undefined +} + +function goalColorsV2(theme: unknown) { + return { + text: themeColorV2(theme, ["text", "default"], ["text"]), + muted: themeColorV2(theme, ["text", "subdued"], ["textMuted"]), + achieved: themeColorV2(theme, ["text", "feedback", "success"], ["primary"], ["text", "default"], ["text"]), + } +} + function goalSnapshotKey(sessionID: string) { return `goal-mode.snapshot.${sessionID}` } @@ -498,7 +553,7 @@ async function showSummaryV2(api: TuiPluginV2.Context, sessionID: string, goal: } function GoalSidebarV2(api: TuiPluginV2.Context, sessionID: string) { - const theme = api.theme + const colors = goalColorsV2(api.theme) const [cache, setCache] = api.storage.memory<{ goal: GoalSnapshot | null }>(`goal-mode.v2.${sessionID}`, { initial: { goal: null }, }) @@ -523,26 +578,27 @@ function GoalSidebarV2(api: TuiPluginV2.Context, sessionID: string) { 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 }, [ + return text({ fg: snapshot.status === "complete" ? colors.achieved : colors.muted }, [ `${snapshot.status === "complete" ? "Goal achieved" : "Goal unmet"} (${formatDurationBadge(elapsed)})`, ]) } return box({}, [ - text({ fg: theme.text }, ["Goal"]), - text({ fg: theme.textMuted }, [`Status: ${snapshot.status}`]), - text({ fg: theme.textMuted }, [`Time: ${formatDuration(liveTimeUsedSeconds(snapshot, nowSeconds()))}`]), - text({ fg: theme.textMuted }, [`Tokens: ${snapshot.tokensUsed}${snapshot.tokenBudget == null ? "" : `/${snapshot.tokenBudget}`}`]), - text({ fg: theme.textMuted }, [`Auto-continues: ${snapshot.autoTurns}${snapshot.maxAutoTurns == null ? "" : `/${snapshot.maxAutoTurns}`}`]), - ...(snapshot.lastCheckpoint ? [text({ fg: theme.textMuted }, [`Checkpoint: ${snapshot.lastCheckpoint.summary}`])] : []), - ...(snapshot.stopReason ? [text({ fg: theme.textMuted }, [`Stop: ${snapshot.stopReason}`])] : []), - ...(snapshot.lastStatus ? [text({ fg: theme.textMuted }, [snapshot.lastStatus])] : []), - text({ fg: theme.textMuted }, [snapshot.objective]), + text({ fg: colors.text }, ["Goal"]), + text({ fg: colors.muted }, [`Status: ${snapshot.status}`]), + text({ fg: colors.muted }, [`Time: ${formatDuration(liveTimeUsedSeconds(snapshot, nowSeconds()))}`]), + text({ fg: colors.muted }, [`Tokens: ${snapshot.tokensUsed}${snapshot.tokenBudget == null ? "" : `/${snapshot.tokenBudget}`}`]), + text({ fg: colors.muted }, [`Auto-continues: ${snapshot.autoTurns}${snapshot.maxAutoTurns == null ? "" : `/${snapshot.maxAutoTurns}`}`]), + ...(snapshot.lastCheckpoint ? [text({ fg: colors.muted }, [`Checkpoint: ${snapshot.lastCheckpoint.summary}`])] : []), + ...(snapshot.stopReason ? [text({ fg: colors.muted }, [`Stop: ${snapshot.stopReason}`])] : []), + ...(snapshot.lastStatus ? [text({ fg: colors.muted }, [snapshot.lastStatus])] : []), + text({ fg: colors.muted }, [snapshot.objective]), ]) }]) } function GoalKeymapLayerV2(api: TuiPluginV2.Context) { api.keymap.layer(() => ({ + mode: "global", commands: [ { id: "goal.show", @@ -573,8 +629,8 @@ function GoalKeymapLayerV2(api: TuiPluginV2.Context) { * needs to dispose the two `ui.slot` registrations. */ export function setupTuiV2(context: TuiPluginV2.Context): TuiPluginV2.Cleanup { - const offSidebar = context.ui.slot("sidebar.content", (props) => GoalSidebarV2(context, props.sessionID)) - const offApp = context.ui.slot("app", () => GoalKeymapLayerV2(context)) + const offSidebar = registerSlotV2(context, "sidebar.content", (props) => GoalSidebarV2(context, props.sessionID)) + const offApp = registerSlotV2(context, "app", () => GoalKeymapLayerV2(context)) return () => { offSidebar() offApp() diff --git a/test/tui-v2.test.ts b/test/tui-v2.test.ts index 562e889..f3e52bd 100644 --- a/test/tui-v2.test.ts +++ b/test/tui-v2.test.ts @@ -3,7 +3,13 @@ import { testRender } from "@opentui/solid" import { createSignal } from "solid-js" import { createStore, type Store } from "solid-js/store" import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client" -import plugin, { goalFromV2Messages, liveTimeUsedSeconds, setupTuiV2 } from "../src/tui.ts" +import plugin, { + goalFromV2Messages, + liveTimeUsedSeconds, + registerSlotV2, + setupTuiV2, + themeColorV2, +} from "../src/tui.ts" type GoalSnapshot = Parameters[0] @@ -105,7 +111,13 @@ type MockContext = { } } attention: unknown - theme: { text: string; textMuted: string; primary: string } + theme: { + text: { + default: string + subdued: string + feedback: { success: { default: string } } + } + } markdown: unknown keymap: { layer: (input: () => MockKeymapLayer) => void @@ -136,7 +148,7 @@ type MockContext = { current: () => { type: string; sessionID?: string } } tabs: unknown - slot: (name: string, render: (props: { sessionID: string }) => unknown) => () => void + slot: (options: { append: string; render: (props: { sessionID: string }) => unknown }) => () => void } } @@ -185,7 +197,13 @@ function makeMockContext(overrides: Partial = {}): { }, }, attention: undefined, - theme: { text: "#ffffff", textMuted: "#888888", primary: "#00ff00" }, + theme: { + text: { + default: "#ffffff", + subdued: "#888888", + feedback: { success: { default: "#00ff00" } }, + }, + }, markdown: undefined, keymap: { layer(input) { @@ -234,10 +252,12 @@ function makeMockContext(overrides: Partial = {}): { current: () => route, }, tabs: undefined, - slot(name, render) { - slots.set(name, render) + // Current previews take a single options argument. Keep the arity at 1 so + // the plugin exercises the same call shape the real host uses. + slot(options) { + slots.set(options.append, options.render) return () => { - disposed.push(name) + disposed.push(options.append) } }, }, @@ -275,6 +295,68 @@ test("V2 TUI definition exposes id and a setup function", () => { expect(typeof plugin.tui).toBe("function") }) +test("registerSlotV2 uses the options argument when the host takes one parameter", () => { + const calls: Array<{ append: string; render: unknown }> = [] + const context = { + ui: { + slot: (options: { append: string; render: () => unknown }) => { + calls.push(options) + return () => {} + }, + }, + } + const render = () => null + + const dispose = registerSlotV2(context as never, "sidebar.content", render) + + expect(calls).toEqual([{ append: "sidebar.content", render }]) + expect(typeof dispose).toBe("function") +}) + +test("registerSlotV2 falls back to the positional form on earlier previews", () => { + const calls: Array<[string, unknown]> = [] + const context = { + ui: { + slot: (name: string, render: () => unknown) => { + calls.push([name, render]) + return () => {} + }, + }, + } + const render = () => null + + registerSlotV2(context as never, "app", render) + + expect(calls).toEqual([["app", render]]) +}) + +test("registerSlotV2 tolerates hosts that do not return a disposer", () => { + const context = { ui: { slot: (_options: { append: string }) => undefined } } + + const dispose = registerSlotV2(context as never, "sidebar.content", () => null) + + expect(typeof dispose).toBe("function") + expect(() => dispose()).not.toThrow() +}) + +test("themeColorV2 resolves nested, grouped, and legacy flat theme colors", () => { + const nested = { + text: { default: "#ffffff", subdued: "#888888", feedback: { success: { default: "#00ff00" } } }, + } + const flat = { text: "#eeeeee", textMuted: "#777777", primary: "#00cc00" } + + expect(themeColorV2(nested, ["text", "default"], ["text"])).toBe("#ffffff") + expect(themeColorV2(nested, ["text", "subdued"], ["textMuted"])).toBe("#888888") + // A color group resolves to its `default` leaf rather than the group object. + expect(themeColorV2(nested, ["text", "feedback", "success"], ["primary"])).toBe("#00ff00") + + expect(themeColorV2(flat, ["text", "default"], ["text"])).toBe("#eeeeee") + expect(themeColorV2(flat, ["text", "subdued"], ["textMuted"])).toBe("#777777") + expect(themeColorV2(flat, ["text", "feedback", "success"], ["primary"])).toBe("#00cc00") + + expect(themeColorV2({}, ["text", "default"], ["text"])).toBeUndefined() +}) + test("V2 setup registers sidebar.content and app slots and cleanup disposes them", () => { const { mock, slots, disposed } = makeMockContext() const cleanup = setupTuiV2(mock as never) @@ -441,6 +523,9 @@ test("V2 keymap layer registers the goal palette command when the app slot rende expect(command).toBeDefined() expect(command?.title).toBe("Goal") expect(command?.palette).toBe(true) + // Without an explicit global mode the host never surfaces the command in + // the palette, even while the layer itself is registered. + expect(layer?.mode).toBe("global") setup.renderer.destroy() destroyed = true } finally {