diff --git a/packages/cli/src/mini/footer.ts b/packages/cli/src/mini/footer.ts index 0a2510f167..fd758caf29 100644 --- a/packages/cli/src/mini/footer.ts +++ b/packages/cli/src/mini/footer.ts @@ -627,7 +627,6 @@ export class RunFooter implements FooterApi { this.themes.splice(index, 1) theme.block.syntax?.destroy() - theme.block.subtleSyntax?.destroy() } public close(): void { @@ -1023,7 +1022,6 @@ export class RunFooter implements FooterApi { void resolveRunTheme(this.renderer).then((theme) => { if (this.isGone) { theme.block.syntax?.destroy() - theme.block.subtleSyntax?.destroy() return } diff --git a/packages/cli/src/mini/scrollback.shared.ts b/packages/cli/src/mini/scrollback.shared.ts index fc040536e9..447d260c09 100644 --- a/packages/cli/src/mini/scrollback.shared.ts +++ b/packages/cli/src/mini/scrollback.shared.ts @@ -6,11 +6,7 @@ function syntax(style?: SyntaxStyle): SyntaxStyle { return style ?? SyntaxStyle.fromTheme([]) } -export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle { - if (commit.kind === "reasoning") { - return syntax(theme.block.subtleSyntax ?? theme.block.syntax) - } - +export function entrySyntax(theme: RunTheme): SyntaxStyle { return syntax(theme.block.syntax) } diff --git a/packages/cli/src/mini/scrollback.surface.ts b/packages/cli/src/mini/scrollback.surface.ts index c5e5c6dd7d..d9182219d2 100644 --- a/packages/cli/src/mini/scrollback.surface.ts +++ b/packages/cli/src/mini/scrollback.surface.ts @@ -143,7 +143,7 @@ export class RunScrollbackStream { } active.renderable.fg = entryColor(active.commit, theme) - active.renderable.syntaxStyle = entrySyntax(active.commit, theme) + active.renderable.syntaxStyle = entrySyntax(theme) } private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry { @@ -165,7 +165,7 @@ export class RunScrollbackStream { ? new CodeRenderable(surface.renderContext, { content: "", filetype: body.filetype, - syntaxStyle: entrySyntax(commit, this.theme), + syntaxStyle: entrySyntax(this.theme), width: "100%", wrapMode: "word", drawUnstyledText: false, @@ -175,7 +175,7 @@ export class RunScrollbackStream { }) : new MarkdownRenderable(surface.renderContext, { content: "", - syntaxStyle: entrySyntax(commit, this.theme), + syntaxStyle: entrySyntax(this.theme), width: "100%", streaming: true, internalBlockMode: "top-level", diff --git a/packages/cli/src/mini/scrollback.writer.tsx b/packages/cli/src/mini/scrollback.writer.tsx index 74f4a97adf..e6ab9bb408 100644 --- a/packages/cli/src/mini/scrollback.writer.tsx +++ b/packages/cli/src/mini/scrollback.writer.tsx @@ -84,7 +84,7 @@ export function RunEntryContent(props: { const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK) const body = createMemo(() => props.body ?? entryBody(props.commit)) const style = createMemo(() => entryLook(props.commit, theme().entry)) - const syntax = createMemo(() => entrySyntax(props.commit, theme())) + const syntax = createMemo(() => entrySyntax(theme())) const color = createMemo(() => entryColor(props.commit, theme())) const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true) const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color) diff --git a/packages/cli/src/mini/theme.ts b/packages/cli/src/mini/theme.ts index e4fb315426..6fe36883d1 100644 --- a/packages/cli/src/mini/theme.ts +++ b/packages/cli/src/mini/theme.ts @@ -47,7 +47,6 @@ export type RunBlockTheme = { text: ColorInput muted: ColorInput syntax?: SyntaxStyle - subtleSyntax?: SyntaxStyle diffAdded: ColorInput diffRemoved: ColorInput diffAddedBg: ColorInput @@ -172,42 +171,10 @@ function tint(base: RGBA, overlay: RGBA, value: number): RGBA { ) } -function blend(color: RGBA, bg: RGBA): RGBA { - if (color.a >= 1) { - return color - } - - return RGBA.fromValues( - bg.r + (color.r - bg.r) * color.a, - bg.g + (color.g - bg.g) * color.a, - bg.b + (color.b - bg.b) * color.a, - 1, - ) -} - function chroma(color: RGBA) { return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b) } -function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined { - if (!style) { - return undefined - } - - return SyntaxStyle.fromStyles( - Object.fromEntries( - [...style.getAllStyles()].map(([name, value]) => [ - name, - { - ...value, - fg: value.fg ? blend(value.fg, bg) : value.fg, - bg: value.bg ? blend(value.bg, bg) : value.bg, - }, - ]), - ), - ) -} - function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] { return Array.from({ length: size }, (_, index) => { const value = colors.palette[index] @@ -502,10 +469,7 @@ function map( scrollbackTheme: TuiThemeCurrent, splash: RunSplashTheme, syntax?: SyntaxStyle, - subtleSyntax?: SyntaxStyle, ): RunTheme { - const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background) - subtleSyntax?.destroy() const footerBackground = alpha(footerTheme.background, 1) const footerMode = mode(footerBackground) const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72) @@ -566,7 +530,6 @@ function map( text: scrollbackTheme.text, muted: scrollbackTheme.textMuted, syntax, - subtleSyntax: opaqueSubtleSyntax, diffAdded: scrollbackTheme.diffAdded, diffRemoved: scrollbackTheme.diffRemoved, diffAddedBg: transparent, @@ -677,13 +640,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise _hasSelectedListItemText: true, } const syntax = shared.generateSyntax(syntaxTheme) - return map( - footerTheme, - scrollbackTheme, - splashTheme(scrollbackTheme, indexed), - syntax, - shared.generateSubtleSyntax(syntaxTheme), - ) + return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax) } catch { return RUN_THEME_FALLBACK } diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 3c3dd45721..a337682666 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -35,9 +35,23 @@ test("migrates tui and kv config into cli.json", async () => { path.join(directory, "kv.json"), JSON.stringify({ theme_mode_lock: "light", + attention_sound_pack: "custom.pack", + diff_wrap_mode: "none", + diff_viewer_show_file_tree: false, + diff_viewer_single_patch: true, + diff_viewer_view: "split", + terminal_title_enabled: false, + file_context_enabled: false, paste_summary_enabled: false, + sidebar: "hide", + scrollbar_visible: true, + thinking_mode: "show", exploration_grouping: false, tips_hidden: true, + dismissed_getting_started: true, + animations_enabled: false, + skipped_version: "9.9.9", + which_key_layout: "overlay", }), ) @@ -56,12 +70,17 @@ test("migrates tui and kv config into cli.json", async () => { plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"], leader: { timeout: 500 }, scroll: { speed: 2, acceleration: true }, - diffs: { view: "unified" }, - prompt: { paste: "full" }, - session: { grouping: "none" }, - hints: { tips: false }, + attention: { sound_pack: "custom.pack" }, + diffs: { wrap: "none", tree: false, single: true, view: "split" }, + terminal: { title: false }, + prompt: { editor: false, paste: "full" }, + session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" }, + hints: { tips: false, onboarding: false }, + animations: false, mouse: false, }) + expect(config).not.toHaveProperty("skipped_version") + expect(config).not.toHaveProperty("which_key") expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" }) expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true) expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index ce5289f7cf..38718403f0 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -527,7 +527,8 @@ const layer = Layer.effect( needsContinuation = result.needsContinuation step = result.step + 1 if (needsContinuation) { - promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer" + yield* runPendingCompaction(input.sessionID) + promotion = "steer" continue } yield* runPendingCompaction(input.sessionID) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 355587c650..41525367f3 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1610,14 +1610,14 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("runs one durable compaction barrier before later steer and queued prompts", () => + it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () => Effect.gen(function* () { const session = yield* setup currentModel = recoveryModel streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() responses = [ - reply.text("Active complete", "text-active"), + reply.tool("call-active", "echo", { text: "active" }), [LLMEvent.textDelta({ id: "summary", text: "durable summary" })], reply.text("Steer complete", "text-steer"), reply.text("Queue complete", "text-queue"), diff --git a/packages/docs/build/sdk.mdx b/packages/docs/build/sdk.mdx index b72aac9c09..afc29dca0b 100644 --- a/packages/docs/build/sdk.mdx +++ b/packages/docs/build/sdk.mdx @@ -1,12 +1,21 @@ --- title: "SDK" -description: "Embed an OpenCode host in an Effect application." +description: "Embed OpenCode directly in your application." --- -`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to -host OpenCode in-process. Unlike the [network client](/build/client), it assembles the -OpenCode server and routes API calls through its HTTP router in memory. It opens -no HTTP listener and adds no network hop between the client and server. +We're working on a general-purpose SDK for embedding OpenCode directly inside +your application. The regular SDK is coming soon. + +An Effect-native version is available now for applications built with Effect. +Its current documentation is below. For other applications, run OpenCode as a +server and use the [TypeScript client](/build/client) in the meantime. + +## Effect + +`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the +[network client](/build/client), it assembles the OpenCode server and routes API +calls through its HTTP router in memory. It opens no HTTP listener and adds no +network hop between the client and server. The V2 SDK is beta and currently private to the OpenCode workspace. It is not diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 039ab156fd..a4923bd0b3 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -136,14 +136,14 @@ test("theme swaps restyle active reasoning without resetting the stream", async ...RUN_THEME_FALLBACK, block: { ...RUN_THEME_FALLBACK.block, - subtleSyntax: previousSyntax, + syntax: previousSyntax, }, } const next = { ...RUN_THEME_FALLBACK, block: { ...RUN_THEME_FALLBACK.block, - subtleSyntax: nextSyntax, + syntax: nextSyntax, }, } const out = await setup({ theme: previous, onThemeRelease: (theme) => released.push(theme) }) diff --git a/packages/opencode/test/cli/run/theme.test.ts b/packages/opencode/test/cli/run/theme.test.ts index 52b8fc6439..d62a6e9f7f 100644 --- a/packages/opencode/test/cli/run/theme.test.ts +++ b/packages/opencode/test/cli/run/theme.test.ts @@ -67,9 +67,7 @@ test("returns syntax styles and indexed splash colors", async () => { try { expect(theme.block.syntax).toBeDefined() - expect(theme.block.subtleSyntax).toBeDefined() expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0) - expect([...theme.block.subtleSyntax!.getAllStyles()].length).toBeGreaterThan(0) expectIndexed(theme.splash.left) expectIndexed(theme.splash.right) expectIndexed(theme.splash.leftShadow) @@ -82,7 +80,6 @@ test("returns syntax styles and indexed splash colors", async () => { expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts()) } finally { theme.block.syntax?.destroy() - theme.block.subtleSyntax?.destroy() } }) @@ -103,7 +100,6 @@ test("keeps footer surfaces exact while scrollback stays palette matched", async expectIndexed(theme.block.warning) } finally { theme.block.syntax?.destroy() - theme.block.subtleSyntax?.destroy() } }) @@ -119,9 +115,7 @@ test("uses refreshed background brightness when cached renderer mode is stale", expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts()) } finally { stale.block.syntax?.destroy() - stale.block.subtleSyntax?.destroy() light.block.syntax?.destroy() - light.block.subtleSyntax?.destroy() } }) @@ -138,9 +132,7 @@ test("keeps renderer mode when refreshed default background is unavailable", asy expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts()) } finally { light.block.syntax?.destroy() - light.block.subtleSyntax?.destroy() dark.block.syntax?.destroy() - dark.block.subtleSyntax?.destroy() } }) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 1f6ae64293..d3f144e859 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -358,6 +358,7 @@ export type TuiTheme = { readonly ready: boolean } +/** @deprecated Persistent TUI KV storage is not supported in V2. */ export type TuiKV = { get: (key: string, fallback?: Value) => Value set: (key: string, value: unknown) => void @@ -434,6 +435,7 @@ type TuiConfigView = { sidebar?: "auto" | "hide" scrollbar?: boolean thinking?: "show" | "hide" + markdown?: "source" | "rendered" grouping?: "auto" | "none" } hints?: { tips?: boolean; onboarding?: boolean } @@ -628,6 +630,7 @@ export type TuiPluginApi = { dialog: TuiDialogStack } readonly tuiConfig: Frozen + /** @deprecated Persistent TUI KV storage is not supported in V2. */ kv: TuiKV state: TuiState theme: TuiTheme diff --git a/packages/tui/package.json b/packages/tui/package.json index 8b445f6bd0..ee70ca50b0 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -19,12 +19,10 @@ "./context/args": "./src/context/args.tsx", "./context/epilogue": "./src/context/epilogue.tsx", "./context/exit": "./src/context/exit.tsx", - "./context/kv": "./src/context/kv.tsx", "./context/log": "./src/context/log.tsx", "./context/project": "./src/context/project.tsx", "./context/runtime": "./src/context/runtime.tsx", "./context/sdk": "./src/context/sdk.tsx", - "./context/sync": "./src/context/sync.tsx", "./context/theme": "./src/context/theme.tsx", "./context/editor": "./src/context/editor.ts", "./context/clipboard": "./src/context/clipboard.tsx", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 7507c9208e..574686ced1 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -44,7 +44,6 @@ import { useEvent } from "./context/event" import { SDKProvider, useSDK } from "./context/sdk" import { StartupLoading } from "./component/startup-loading" import { Reconnecting } from "./component/reconnecting" -import { SyncProvider, useSync } from "./context/sync" import { DataProvider, useData } from "./context/data" import { LocationProvider } from "./context/location" import { LocalProvider, useLocal } from "./context/local" @@ -71,7 +70,6 @@ import { DialogAlert } from "./ui/dialog-alert" import { DialogConfirm } from "./ui/dialog-confirm" import { ToastProvider, useToast } from "./ui/toast" import { isDefaultTitle } from "./util/session" -import { KVProvider, useKV } from "./context/kv" import * as Model from "./util/model" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" @@ -181,23 +179,6 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error) } -function isVersionGreater(left: string, right: string) { - const parse = (value: string) => { - const [core, prerelease] = value.replace(/^v/, "").split("-", 2) - return { core: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease } - } - const a = parse(left) - const b = parse(right) - for (let index = 0; index < Math.max(a.core.length, b.core.length); index++) { - const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0) - if (difference) return difference > 0 - } - if (a.prerelease === b.prerelease) return false - if (!a.prerelease) return true - if (!b.prerelease) return false - return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0 -} - export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const log = input.log ?? (() => {}) const global = yield* Global.Service @@ -343,67 +324,63 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { service={input.config} options={{ terminalSuspend: process.platform !== "win32" }} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -440,17 +417,16 @@ function App(props: { }) { const log = useLog({ component: "app" }) const startup = useTuiStartup() - const config = useConfig().data + const configState = useConfig() + const config = configState.data const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() const dialog = useDialog() const local = useLocal() - const kv = useKV() const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() - const sync = useSync() const toast = useToast() const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState @@ -459,7 +435,7 @@ function App(props: { const exit = useExit() const promptRef = usePromptRef() const pluginRuntime = usePluginRuntime() - const attention = createTuiAttention({ renderer, config, kv }) + const attention = createTuiAttention({ renderer, config, update: configState.update }) const clipboard = useClipboard() // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, @@ -496,12 +472,11 @@ function App(props: { tuiConfig: config, dialog, keymap, - kv, route, routes: pluginRuntime.routes, event, sdk, - sync, + project, data, theme: themeState, toast, @@ -549,8 +524,8 @@ function App(props: { renderer.clearSelection() } - const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true) - const [pasteSummaryEnabled, setPasteSummaryEnabled] = kv.signal("paste_summary_enabled", true) + const terminalTitleEnabled = () => config.terminal?.title ?? true + const pasteSummaryEnabled = () => config.prompt?.paste !== "full" createEffect(() => { renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse @@ -880,6 +855,7 @@ function App(props: { { name: "theme.switch_mode", title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", + hidden: true, run: () => { setMode(mode() === "dark" ? "light" : "dark") dialog.clear() @@ -889,6 +865,7 @@ function App(props: { { name: "theme.mode.lock", title: locked() ? "Unlock theme mode" : "Lock theme mode", + hidden: true, run: () => { if (locked()) unlock() else lock() @@ -956,41 +933,57 @@ function App(props: { name: "terminal.title.toggle", title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", category: "System", + hidden: true, run: () => { - setTerminalTitleEnabled((prev) => { - const next = !prev - kv.set("terminal_title_enabled", next) - if (!next) renderer.setTerminalTitle("") - return next - }) + const next = !terminalTitleEnabled() + if (!next) renderer.setTerminalTitle("") + void configState + .update((draft) => { + draft.terminal = { ...draft.terminal, title: next } + }) + .catch(toast.error) dialog.clear() }, }, { name: "app.toggle.animations", - title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", + title: (config.animations ?? true) ? "Disable animations" : "Enable animations", category: "System", + hidden: true, run: () => { - kv.set("animations_enabled", !kv.get("animations_enabled", true)) + void configState + .update((draft) => { + draft.animations = !(config.animations ?? true) + }) + .catch(toast.error) dialog.clear() }, }, { name: "app.toggle.file_context", - title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context", + title: (config.prompt?.editor ?? true) ? "Disable file context" : "Enable file context", category: "System", + hidden: true, run: () => { - kv.set("file_context_enabled", !kv.get("file_context_enabled", true)) + void configState + .update((draft) => { + draft.prompt = { ...draft.prompt, editor: !(config.prompt?.editor ?? true) } + }) + .catch(toast.error) dialog.clear() }, }, { name: "app.toggle.diffwrap", - title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", + title: (config.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", category: "System", + hidden: true, run: () => { - const current = kv.get("diff_wrap_mode", "word") - kv.set("diff_wrap_mode", current === "word" ? "none" : "word") + void configState + .update((draft) => { + draft.diffs = { ...draft.diffs, wrap: (config.diffs?.wrap ?? "word") === "word" ? "none" : "word" } + }) + .catch(toast.error) dialog.clear() }, }, @@ -998,12 +991,13 @@ function App(props: { name: "app.toggle.paste_summary", title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary", category: "System", + hidden: true, run: () => { - setPasteSummaryEnabled((prev) => { - const next = !prev - kv.set("paste_summary_enabled", next) - return next - }) + void configState + .update((draft) => { + draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" } + }) + .catch(toast.error) dialog.clear() }, }, @@ -1101,21 +1095,13 @@ function App(props: { event.on("installation.update-available", async (evt) => { const version = evt.data.version - const skipped = kv.get("skipped_version") - if (skipped && !isVersionGreater(version, skipped)) return - const choice = await DialogConfirm.show( dialog, `Update Available`, `A new release v${version} is available. Would you like to update now?`, - "skip", + "later", ) - if (choice === false) { - kv.set("skipped_version", version) - return - } - if (choice !== true) return toast.show({ diff --git a/packages/tui/src/attention.ts b/packages/tui/src/attention.ts index 948872a26b..e210a745d6 100644 --- a/packages/tui/src/attention.ts +++ b/packages/tui/src/attention.ts @@ -38,9 +38,8 @@ type TuiAttentionHost = TuiAttention & { dispose(): void } -const DEFAULT_TITLE = "opencode" +const DEFAULT_TITLE = "OpenCode" const DEFAULT_PACK_ID = "opencode.default" -const KV_SOUND_PACK = "attention_sound_pack" const TITLE_LIMIT = 80 const MESSAGE_LIMIT = 240 const BUILTIN_PACK: RegisteredSoundPack = { @@ -114,6 +113,8 @@ function focusSkip(when: TuiAttentionWhen, focus: FocusState) { export function createTuiAttention(input: { renderer: AttentionRenderer config: Pick + update?: Config.Interface["update"] + /** @deprecated Ignored. Sound-pack persistence uses CLI config. */ kv?: TuiKV audio?: Pick }): TuiAttentionHost { @@ -134,8 +135,7 @@ export function createTuiAttention(input: { input.renderer.on("blur", onBlur) function configuredPackID() { - const stored = input.kv?.get(KV_SOUND_PACK, undefined) - return activePackID ?? stored ?? input.config.attention.sound_pack + return activePackID ?? input.config.attention.sound_pack } function currentPack() { @@ -234,7 +234,12 @@ export function createTuiAttention(input: { const pack = packs.get(id) if (!pack) return false activePackID = pack.id - if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id) + if (options?.persist) + void input + .update?.((draft) => { + draft.attention = { ...draft.attention, sound_pack: pack.id } + }) + .catch(() => {}) return true }, current() { diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index f4318695e2..7e964d3437 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -77,6 +77,13 @@ const settings: Setting[] = [ default: "hide", values: ["hide", "show"], }, + { + title: "Markdown", + category: "Session", + path: ["session", "markdown"], + default: "rendered", + values: ["source", "rendered"], + }, { title: "Grouping", category: "Session", diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 892f709147..f998a28417 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -132,6 +132,7 @@ export function DialogModel(props: { providerID?: string }) { { command: "model.dialog.provider", title: connected() ? "Connect integration" : "View all integrations", + selection: "none", onTrigger() { dialog.replace(() => ( void create(), }, { @@ -360,6 +361,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { { command: "dialog.move_session.refresh", title: "refresh", + selection: "none", onTrigger: () => void refetch(), }, ] diff --git a/packages/tui/src/component/error-component.tsx b/packages/tui/src/component/error-component.tsx index 1141da8382..8d778badca 100644 --- a/packages/tui/src/component/error-component.tsx +++ b/packages/tui/src/component/error-component.tsx @@ -108,7 +108,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: {/* Headline */} - opencode crashed + OpenCode crashed An unexpected error stopped the session. @@ -192,7 +192,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: ? "Report copied — paste it into a new GitHub issue." : "Copy the report and open a GitHub issue to help us fix this."} - opencode {InstallationVersion} + OpenCode {InstallationVersion} @@ -211,7 +211,7 @@ function buildIssueURL(message: string, stack: string) { url.searchParams.set("terminal", describeTerminal()) url.searchParams.set( "reproduce", - "Reported automatically from the opencode crash screen. If you can, describe what you were doing when it crashed.", + "Reported automatically from the OpenCode crash screen. If you can, describe what you were doing when it crashed.", ) // Budget the stack against the fully URL-encoded length (not the raw length) so @@ -220,7 +220,7 @@ function buildIssueURL(message: string, stack: string) { // so measuring url.toString() is both correct and safe on any input. const MAX_URL_LENGTH = 6000 const marker = "\n... (truncated)" - const head = `The opencode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n` + const head = `The OpenCode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n` const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```") setBody(stack) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 02af67b8fb..e9a22dff0b 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -43,7 +43,6 @@ import { useDialog } from "../../ui/dialog" import { DialogIntegration } from "../dialog-integration" import { useConnected } from "../use-connected" import { useToast } from "../../ui/toast" -import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" import { useArgs } from "../../context/args" @@ -177,11 +176,10 @@ export function Prompt(props: PromptProps) { const exit = useExit() const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() - const kv = useKV() - const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) + const animationsEnabled = createMemo(() => config.animations ?? true) const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) - const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true)) + const fileContextEnabled = createMemo(() => config.prompt?.editor ?? true) const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal() const editorContext = createMemo(() => { const selection = fileContextEnabled() ? editor.selection() : undefined @@ -1191,7 +1189,7 @@ export function Prompt(props: PromptProps) { const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 if ( (lineCount >= 3 || pastedContent.length > 150) && - kv.get("paste_summary_enabled", true) + config.prompt?.paste !== "full" ) { pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) return @@ -1495,7 +1493,7 @@ export function Prompt(props: PromptProps) { - [⋯]}> + [⋯]}> diff --git a/packages/tui/src/component/spinner.tsx b/packages/tui/src/component/spinner.tsx index f648a605a6..5ba9bc3133 100644 --- a/packages/tui/src/component/spinner.tsx +++ b/packages/tui/src/component/spinner.tsx @@ -1,6 +1,6 @@ import { Show } from "solid-js" import { useTheme } from "../context/theme" -import { useKV } from "../context/kv" +import { useConfig } from "../config" import type { JSX } from "@opentui/solid" import type { RGBA } from "@opentui/core" import { registerOpencodeSpinner } from "./register-spinner" @@ -11,10 +11,10 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", export function Spinner(props: { children?: JSX.Element; color?: RGBA }) { const { theme } = useTheme() - const kv = useKV() + const config = useConfig().data const color = () => props.color ?? theme.textMuted return ( - ⋯ {props.children}}> + ⋯ {props.children}}> diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 8c6d9c0a85..ea75ef0699 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -118,6 +118,9 @@ export const Info = Schema.Struct({ grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({ description: "Group related transcript items automatically or render each item separately", }), + markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({ + description: "Show Markdown syntax markers or conceal them in rendered transcript content", + }), }), ).annotate({ description: "Session transcript presentation settings" }), hints: Schema.optional( diff --git a/packages/tui/src/context/kv.tsx b/packages/tui/src/context/kv.tsx deleted file mode 100644 index 95c088f194..0000000000 --- a/packages/tui/src/context/kv.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { createEffect, createSignal, type Setter } from "solid-js" -import { createStore, unwrap } from "solid-js/store" -import { createSimpleContext } from "./helper" -import { Flock } from "@opencode-ai/core/util/flock" -import { Global } from "@opencode-ai/core/global" -import { readJson, writeJsonAtomic } from "../util/persistence" -import { useTuiPaths } from "./runtime" -import path from "path" -import { useConfigOptional, type Config } from "../config" - -export const { use: useKV, provider: KVProvider } = createSimpleContext({ - name: "KV", - init: (props: { config?: Config.Info }) => { - const config = props.config ?? useConfigOptional()?.data - const paths = useTuiPaths() - void Global.Path.state - const file = path.join(paths.state, "kv.json") - const lock = `tui-kv:${file}` - const [ready, setReady] = createSignal(false) - const [store, setStore] = createStore>() - // Queue same-process writes so rapid updates persist in order. - let write = Promise.resolve() - - Flock.withLock(lock, () => readJson>(file)) - .then((x) => { - const values: Record = { ...x } - Object.entries(configValues(config ?? {})).forEach(([key, value]) => { - if (value === undefined) delete values[key] - else values[key] = value - }) - setStore(values) - }) - .catch((error) => { - console.error("Failed to read KV state", { error }) - }) - .finally(() => { - setReady(true) - }) - - createEffect(() => { - if (!ready() || !config) return - Object.entries(configValues(config)).forEach(([key, value]) => { - if (value === undefined) setStore(key, undefined) - else setStore(key, value) - }) - }) - - const result = { - get ready() { - return ready() - }, - get store() { - return store - }, - signal(name: string, defaultValue: T) { - if (store[name] === undefined) setStore(name, defaultValue) - return [ - function () { - return result.get(name) - }, - function setter(next: Setter) { - result.set(name, next) - }, - ] as const - }, - get(key: string, defaultValue?: any) { - return store[key] ?? defaultValue - }, - set(key: string, value: any) { - setStore(key, value) - const snapshot = structuredClone(unwrap(store)) - write = write - .then(() => Flock.withLock(lock, () => writeJsonAtomic(file, snapshot))) - .catch((error) => { - console.error("Failed to write KV state", { error }) - }) - }, - } - return result - }, -}) - -function configValues(config: Config.Info) { - const values: Record = {} - if (config.theme?.name !== undefined) values.theme = config.theme.name - if (config.theme?.mode !== undefined) { - values.theme_mode_lock = config.theme.mode === "system" ? undefined : config.theme.mode - values.theme_mode = undefined - } - if (config.attention?.sound_pack !== undefined) values.attention_sound_pack = config.attention.sound_pack - if (config.diffs?.wrap !== undefined) values.diff_wrap_mode = config.diffs.wrap - if (config.diffs?.tree !== undefined) values.diff_viewer_show_file_tree = config.diffs.tree - if (config.diffs?.single !== undefined) values.diff_viewer_single_patch = config.diffs.single - if (config.diffs?.view !== undefined) - values.diff_viewer_view = config.diffs.view === "auto" ? undefined : config.diffs.view - if (config.terminal?.title !== undefined) values.terminal_title_enabled = config.terminal.title - if (config.prompt?.editor !== undefined) values.file_context_enabled = config.prompt.editor - if (config.prompt?.paste !== undefined) values.paste_summary_enabled = config.prompt.paste === "compact" - if (config.session?.sidebar !== undefined) values.sidebar = config.session.sidebar - if (config.session?.scrollbar !== undefined) values.scrollbar_visible = config.session.scrollbar - if (config.session?.thinking !== undefined) values.thinking_mode = config.session.thinking - if (config.session?.grouping !== undefined) values.exploration_grouping = config.session.grouping === "auto" - if (config.hints?.tips !== undefined) values.tips_hidden = !config.hints.tips - if (config.hints?.onboarding !== undefined) values.dismissed_getting_started = !config.hints.onboarding - if (config.animations !== undefined) values.animations_enabled = config.animations - return values -} diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx deleted file mode 100644 index f0133f85c0..0000000000 --- a/packages/tui/src/context/sync.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { - Agent, - Command, - Config, - FormatterStatus, - LspStatus, - McpResource, - McpStatus, - Message, - Part, - PermissionRequest, - Provider, - QuestionRequest, - Session, - FileDiffInfo, - VcsInfo, -} from "@opencode-ai/sdk/v2" -import { createStore } from "solid-js/store" -import { createSimpleContext } from "./helper" -import { useProject } from "./project" - -export const { - context: SyncContext, - use: useSync, - provider: SyncProvider, -} = createSimpleContext({ - name: "Sync", - init: () => { - const project = useProject() - const [store, setStore] = createStore<{ - status: "loading" | "partial" | "complete" - provider: Provider[] - agent: Agent[] - command: Command[] - permission: Record - question: Record - config: Config - session: Session[] - session_diff: Record - message: Record - part: Record - lsp: LspStatus[] - mcp: Record - mcp_resource: Record - formatter: FormatterStatus[] - vcs: VcsInfo | undefined - }>({ - status: "complete", - provider: [], - agent: [], - command: [], - permission: {}, - question: {}, - config: {}, - session: [], - session_diff: {}, - message: {}, - part: {}, - lsp: [], - mcp: {}, - mcp_resource: {}, - formatter: [], - vcs: undefined, - }) - - return { - data: store, - set: setStore, - get status() { - return store.status - }, - get ready() { - return true - }, - get path() { - return project.instance.path() - }, - session: { - get(_sessionID: string) { - return undefined as Session | undefined - }, - query() { - return {} as { scope?: "project"; path?: string } - }, - async refresh() {}, - status(_sessionID: string) { - return "idle" as const - }, - async sync(_sessionID: string) {}, - }, - async bootstrap(_input: { fatal?: boolean } = {}) {}, - } - }, -}) diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index a2c180feac..62ab31792e 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -4,7 +4,6 @@ import { DEFAULT_THEMES, addTheme, allThemes, - generateSubtleSyntax, generateSyntax, generateSystem, hasTheme, @@ -22,7 +21,6 @@ import { import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" -import { useKV } from "./kv" import { useConfig } from "../config" import { Global } from "@opencode-ai/core/global" import { Glob } from "@opencode-ai/core/util/glob" @@ -64,7 +62,6 @@ export { DEFAULT_THEMES, addTheme, allThemes, - generateSubtleSyntax, generateSyntax, generateSystem, hasTheme, @@ -76,7 +73,6 @@ export { upsertTheme, type Theme, type ThemeJson, - type SyntaxStyleOverrides, } from "../theme" const THEME_REFRESH_DELAYS = [250, 1000] as const @@ -103,8 +99,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => { const renderer = useRenderer() - const config = useConfig().data - const kv = useKV() + const configState = useConfig() + const config = configState.data const themes = props.source ?? themeSource const pick = (value: unknown) => { if (value === "dark" || value === "light") return value @@ -113,12 +109,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setStore( produce((draft) => { - const lock = pick(kv.get("theme_mode_lock")) + const lock = pick(config.theme?.mode) const mode = lock ?? pick(renderer.themeMode) ?? props.mode - if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined) draft.mode = mode draft.lock = lock - const active = config.theme?.name ?? kv.get("theme", "opencode") + const active = config.theme?.name ?? "opencode" draft.active = typeof active === "string" ? active : "opencode" draft.ready = false }), @@ -132,10 +127,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ createEffect(() => { const mode = config.theme?.mode if (mode === "dark" || mode === "light") { - pin(mode) + pin(mode, false) return } - if (mode === "system" && store.lock !== undefined) free() + if (mode === "system" && store.lock !== undefined) free(false) }) function syncCustomThemes() { @@ -209,23 +204,31 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } function apply(mode: "dark" | "light") { - if (store.lock !== undefined) kv.set("theme_mode", mode) if (store.mode === mode) return setStore("mode", mode) refreshSystemTheme(mode) } - function pin(mode: "dark" | "light" = store.mode) { + function pin(mode: "dark" | "light" = store.mode, persist = true) { setStore("lock", mode) - kv.set("theme_mode_lock", mode) apply(mode) + if (!persist) return + void configState + .update((draft) => { + draft.theme = { ...draft.theme, mode } + }) + .catch(() => {}) } - function free() { + function free(persist = true) { setStore("lock", undefined) - kv.set("theme_mode_lock", undefined) - kv.set("theme_mode", undefined) refreshSystemTheme(renderer.themeMode ?? store.mode) + if (!persist) return + void configState + .update((draft) => { + draft.theme = { ...draft.theme, mode: "system" } + }) + .catch(() => {}) } const handle = (mode: "dark" | "light") => { @@ -265,20 +268,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const values = createMemo(() => { const active = store.themes[store.active] if (active) return resolveTheme(active, store.mode) - - const saved = kv.get("theme") - if (typeof saved === "string") { - const theme = store.themes[saved] - if (theme) return resolveTheme(theme, store.mode) - } - return resolveTheme(store.themes.opencode, store.mode) }) createEffect(() => renderer.setBackgroundColor(values().background)) const syntax = createSyntaxStyleMemo(() => generateSyntax(values())) - const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values())) return { theme: new Proxy(values(), { @@ -293,7 +288,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ all: allThemes, has: hasTheme, syntax, - subtleSyntax, mode: () => store.mode, locked: () => store.lock !== undefined, lock: () => pin(store.mode), @@ -302,7 +296,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ set(theme: string) { if (!hasTheme(theme)) return false setStore("active", theme) - kv.set("theme", theme) + void configState + .update((draft) => { + draft.theme = { ...draft.theme, name: theme } + }) + .catch(() => {}) return true }, get ready() { diff --git a/packages/tui/src/context/thinking.ts b/packages/tui/src/context/thinking.ts index bb1c2a6929..3d630fb62a 100644 --- a/packages/tui/src/context/thinking.ts +++ b/packages/tui/src/context/thinking.ts @@ -1,6 +1,3 @@ -import { createMemo, type Setter } from "solid-js" -import { useKV } from "./kv" - export type ThinkingMode = "show" | "hide" const MODES: readonly ThinkingMode[] = ["show", "hide"] as const @@ -16,52 +13,8 @@ export function reasoningSummary(text: string) { return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() } } -export function isThinkingMode(value: unknown): value is ThinkingMode { - return typeof value === "string" && (MODES as readonly string[]).includes(value) -} - // Cycle order matches the slash command: show → hide → show. export function nextThinkingMode(current: ThinkingMode): ThinkingMode { const idx = MODES.indexOf(current) return MODES[(idx + 1) % MODES.length] ?? "show" } - -export function useThinkingMode() { - const kv = useKV() - // Capture pre-state before `kv.signal` seeds a default, so we can detect - // first-time users with a legacy `thinking_visibility` boolean and migrate. - // The KVProvider only renders children once kv.ready, so reads here are safe. - const hadStored = kv.get("thinking_mode") !== undefined - const legacy = kv.get("thinking_visibility") - const [stored, setStored] = kv.signal("thinking_mode", "hide") - - // The kv signal exposes its setter typed as `Setter` which carries Solid's - // overload set; passing an updater fn through a property access loses the - // bivariance trick the existing `setX((prev) => ...)` callsites rely on. - // Wrap it in a sane shape so consumers can just call `set(next)` or pass - // an updater. - const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => { - if (typeof next === "function") setStored(next as Setter) - else setStored(() => next) - } - - // Preserve previous experience for users who had explicitly toggled the - // legacy `thinking_visibility` boolean. First-time users (no legacy key) - // get the new "hide" default (collapsed thinking). - if (!hadStored) { - if (legacy === true) set("show") - else if (legacy === false) set("hide") - } - - if ((stored() as string) === "minimal") set("hide") - - const mode = createMemo(() => { - const value = stored() - return isThinkingMode(value) ? value : "hide" - }) - - return { - mode, - set, - } -} diff --git a/packages/tui/src/feature-plugins/home/tips.tsx b/packages/tui/src/feature-plugins/home/tips.tsx index 7b67d4aac5..2c516b8f32 100644 --- a/packages/tui/src/feature-plugins/home/tips.tsx +++ b/packages/tui/src/feature-plugins/home/tips.tsx @@ -5,10 +5,12 @@ import { Tips } from "./tips-view" import { useBindings } from "../../keymap" import { useData } from "../../context/data" import { hasConnectedProvider } from "../../util/connected-provider" +import { useConfig } from "../../config" const id = "internal:home-tips" function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) { + const config = useConfig() useBindings(() => ({ commands: [ { @@ -16,8 +18,13 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec title: props.hidden ? "Show tips" : "Hide tips", category: "System", namespace: "palette", + hidden: true, run() { - props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false)) + void config + .update((draft) => { + draft.hints = { ...draft.hints, tips: props.hidden } + }) + .catch(() => {}) props.api.ui.dialog.clear() }, }, @@ -40,7 +47,8 @@ const tui: TuiPlugin = async (api) => { slots: { home_bottom() { const data = useData() - const hidden = createMemo(() => api.kv.get("tips_hidden", false)) + const config = useConfig().data + const hidden = createMemo(() => !(config.hints?.tips ?? true)) const first = createMemo(() => api.state.session.count() === 0) const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? [])) const show = createMemo(() => (!first() || !connected()) && !hidden()) diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index b0fb681153..2cb81e4cc6 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -4,18 +4,20 @@ import { createMemo, Show } from "solid-js" import { abbreviateHome } from "../../runtime" import { useTuiPaths } from "../../context/runtime" import { FilePath } from "../../ui/file-path" +import { useConfig } from "../../config" const id = "internal:sidebar-footer" function View(props: { api: TuiPluginApi; directory: string }) { const paths = useTuiPaths() + const config = useConfig() const theme = () => props.api.theme.current const has = createMemo(() => props.api.state.provider.some( (item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0), ), ) - const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) + const done = createMemo(() => !(config.data.hints?.onboarding ?? true)) const show = createMemo(() => !has() && !done()) const location = createMemo(() => { const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined @@ -44,7 +46,16 @@ function View(props: { api: TuiPluginApi; directory: string }) { Getting started - props.api.kv.set("dismissed_getting_started", true)}> + + void config + .update((draft) => { + draft.hints = { ...draft.hints, onboarding: false } + }) + .catch(() => {}) + } + > ✕ diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 68be440dcb..6444a86950 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -19,6 +19,7 @@ import { DiffViewerFileTree } from "./diff-viewer-file-tree" import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" import { DialogSelect } from "../../ui/dialog-select" import { getScrollAcceleration } from "../../util/scroll" +import { useConfig } from "../../config" import { allExpandedFileTreeDirectories, buildFileTree, @@ -41,9 +42,6 @@ const MIN_SPLIT_WIDTH = 100 const FILE_TREE_WIDTH = 32 const PLAIN_TEXT_FILETYPE = "opencode-plain-text" const VCS_DIFF_CONTEXT_LINES = 12 -const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree" -const KV_SINGLE_PATCH = "diff_viewer_single_patch" -const KV_VIEW = "diff_viewer_view" type DiffMode = "working" | "branch" | "last-turn" type DiffViewerFocus = "patches" | "files" type DiffView = "split" | "unified" @@ -92,6 +90,7 @@ function diffSourceLabel(mode: DiffMode) { function DiffViewer(props: { api: TuiPluginApi }) { const dimensions = useTerminalDimensions() const sdk = useSDK() + const config = useConfig() const themeState = useTheme() const theme = () => props.api.theme.current const params = () => @@ -135,11 +134,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { }) const files = createMemo(() => diff() ?? []) const [focus, setFocus] = createSignal("patches") - const [fileTreeEnabled, setFileTreeEnabled] = createSignal( - props.api.kv.get(KV_SHOW_FILE_TREE, true) !== false, - ) + const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true) const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length)) - const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get(KV_SINGLE_PATCH, false) === true) + const [singlePatch, setSinglePatch] = createSignal(config.data.diffs?.single ?? false) const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4) const patchLeftBorder = createMemo(() => (showFileTree() ? ["left"] : [])) const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH) @@ -148,7 +145,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { if (props.api.tuiConfig.diffs?.view === "split") return "split" return splitAvailable() ? "split" : "unified" }) - const [viewOverride, setViewOverride] = createSignal(storedView(props.api.kv.get(KV_VIEW))) + const [viewOverride, setViewOverride] = createSignal(storedView(config.data.diffs?.view)) const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified")) const fileTree = createMemo(() => buildFileTree(files())) const [expandedFileNodes, setExpandedFileNodes] = createSignal>(new Set()) @@ -623,23 +620,33 @@ function DiffViewer(props: { api: TuiPluginApi }) { name: "diff.toggle_file_tree", title: "Toggle diff viewer file tree", category: "VCS", + hidden: true, run() { const next = !fileTreeEnabled() if (!next) setFocus("patches") setFileTreeEnabled(next) - props.api.kv.set(KV_SHOW_FILE_TREE, next) + void config + .update((draft) => { + draft.diffs = { ...draft.diffs, tree: next } + }) + .catch(() => {}) }, }, { name: "diff.single_patch", title: "Toggle single patch view", category: "VCS", + hidden: true, run() { setSelectedHunk(undefined) if (!singlePatch()) { ensureHighlightedPatchFile() setSinglePatch(true) - props.api.kv.set(KV_SINGLE_PATCH, true) + void config + .update((draft) => { + draft.diffs = { ...draft.diffs, single: true } + }) + .catch(() => {}) scrollSinglePatchToTop() return } @@ -653,7 +660,11 @@ function DiffViewer(props: { api: TuiPluginApi }) { ) if (fileIndex !== undefined) selectPatchFile(fileIndex) setSinglePatch(false) - props.api.kv.set(KV_SINGLE_PATCH, false) + void config + .update((draft) => { + draft.diffs = { ...draft.diffs, single: false } + }) + .catch(() => {}) if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex) }, }, @@ -669,12 +680,17 @@ function DiffViewer(props: { api: TuiPluginApi }) { name: "diff.toggle_view", title: "Toggle diff viewer split or unified view", category: "VCS", + hidden: true, run() { if (!splitAvailable()) return setSelectedHunk(undefined) const next = view() === "split" ? "unified" : "split" setViewOverride(next) - props.api.kv.set(KV_VIEW, next) + void config + .update((draft) => { + draft.diffs = { ...draft.diffs, view: next } + }) + .catch(() => {}) }, }, { diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index 78611e034b..2e73f5f28d 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -217,6 +217,7 @@ function View(props: { api: TuiPluginApi }) { { title: "install", command: "dialog.plugins.install", + selection: "none", hidden: lock(), onTrigger: () => { showInstall(props.api) diff --git a/packages/tui/src/feature-plugins/system/which-key.tsx b/packages/tui/src/feature-plugins/system/which-key.tsx index d16dfc28d2..248f49ff29 100644 --- a/packages/tui/src/feature-plugins/system/which-key.tsx +++ b/packages/tui/src/feature-plugins/system/which-key.tsx @@ -22,8 +22,6 @@ const command = { } as const const LAYER_PRIORITY = 900 -const KV_LAYOUT = "which_key_layout" -const KV_PENDING_PREVIEW = "which_key_pending_preview" const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const const scrollCommands = [ command.scrollUp, @@ -531,8 +529,8 @@ function WhichKeyPanel(props: { const tui: TuiPlugin = async (api) => { const [pinned, setPinned] = createSignal(false) - const [mode, setMode] = createSignal(layout(api.kv.get(KV_LAYOUT, "dock"))) - const [pendingPreview, setPendingPreview] = createSignal(api.kv.get(KV_PENDING_PREVIEW, false)) + const [mode, setMode] = createSignal(layout("dock")) + const [pendingPreview, setPendingPreview] = createSignal(false) api.keymap.registerLayer({ priority: LAYER_PRIORITY, @@ -554,7 +552,6 @@ const tui: TuiPlugin = async (api) => { run() { setMode((value) => { const next = value === "dock" ? "overlay" : "dock" - api.kv.set(KV_LAYOUT, next) return next }) }, @@ -566,7 +563,6 @@ const tui: TuiPlugin = async (api) => { category: "System", run() { setPendingPreview((value) => { - api.kv.set(KV_PENDING_PREVIEW, !value) return !value }) }, diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index f7a440c029..4c27c7f9b4 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -3,12 +3,11 @@ import type { Config } from "../config" import type { useEvent } from "../context/event" import type { useRoute } from "../context/route" import type { useSDK } from "../context/sdk" -import type { useSync } from "../context/sync" import type { useData } from "../context/data" +import type { useProject } from "../context/project" import type { useTheme } from "../context/theme" import { Dialog as DialogUI, type useDialog } from "../ui/dialog" import type { useOpencodeKeymap } from "../keymap" -import type { useKV } from "../context/kv" import { DialogAlert } from "../ui/dialog-alert" import { DialogConfirm } from "../ui/dialog-confirm" import { DialogPrompt } from "../ui/dialog-prompt" @@ -26,12 +25,11 @@ type Input = { tuiConfig: Config.Resolved dialog: ReturnType keymap: ReturnType - kv: ReturnType route: ReturnType routes: PluginRoutes event: ReturnType sdk: ReturnType - sync: ReturnType + project: ReturnType data: ReturnType theme: ReturnType toast: ReturnType @@ -97,57 +95,51 @@ function mapOptionCb(cb?: (item: TuiDialogSelectOption) => void) { return (item: SelectOption) => cb(pickOption(item)) } -function stateApi(sync: ReturnType, data: ReturnType): TuiPluginApi["state"] { +function stateApi(project: ReturnType, data: ReturnType): TuiPluginApi["state"] { return { get ready() { return true }, get config() { - return sync.data.config + return {} }, get provider() { - return sync.data.provider + return [] }, get path() { - return sync.path + return project.instance.path() }, get vcs() { - if (!sync.data.vcs) return - return { - branch: sync.data.vcs.branch, - default_branch: sync.data.vcs.default_branch, - } + return undefined }, session: { count() { return data.session.list().length }, - get(sessionID) { - return sync.session.get(sessionID) + get(_sessionID) { + return undefined }, - diff(sessionID) { - return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => - item.file === undefined ? [] : [{ ...item, file: item.file }], - ) + diff(_sessionID) { + return [] }, - messages(sessionID) { - return sync.data.message[sessionID] ?? [] + messages(_sessionID) { + return [] }, status(sessionID) { return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" } }, - permission(sessionID) { - return sync.data.permission[sessionID] ?? [] + permission(_sessionID) { + return [] }, - question(sessionID) { - return sync.data.question[sessionID] ?? [] + question(_sessionID) { + return [] }, }, - part(messageID) { - return sync.data.part[messageID] ?? [] + part(_messageID) { + return [] }, lsp() { - return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status })) + return [] }, mcp() { return (data.location.mcp.server.list() ?? []) @@ -292,17 +284,14 @@ export function createTuiApiAdapters(input: Input): Omit ThinkingMode showThinking: () => boolean + markdownMode: () => "source" | "rendered" groupExploration: () => boolean diffWrapMode: () => "word" | "none" models: () => ModelInfo[] @@ -147,8 +147,8 @@ export function Session() { const data = useData() const project = useProject() const paths = useTuiPaths() - const config = useConfig().data - const kv = useKV() + const configState = useConfig() + const config = configState.data const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => data.session.get(route.sessionID)) @@ -194,15 +194,14 @@ export function Session() { }) const dimensions = useTerminalDimensions() - const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto") + const sidebar = createMemo(() => config.session?.sidebar ?? "auto") const [sidebarOpen, setSidebarOpen] = createSignal(false) - const thinking = useThinkingMode() - const thinkingMode = thinking.mode + const thinkingMode = createMemo(() => config.session?.thinking ?? "hide") const showThinking = createMemo(() => true) - const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) - const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") - const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) - const [groupExploration, setGroupExploration] = kv.signal("exploration_grouping", true) + const showScrollbar = createMemo(() => config.session?.scrollbar ?? false) + const markdownMode = createMemo(() => config.session?.markdown ?? "rendered") + const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word") + const groupExploration = createMemo(() => config.session?.grouping !== "none") const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -462,7 +461,11 @@ export function Session() { run: () => { batch(() => { const isVisible = sidebarVisible() - setSidebar(() => (isVisible ? "hide" : "auto")) + void configState + .update((draft) => { + draft.session = { ...draft.session, sidebar: isVisible ? "hide" : "auto" } + }) + .catch(toast.error) setSidebarOpen(!isVisible) }) dialog.clear() @@ -476,12 +479,17 @@ export function Session() { })(), value: "session.toggle.thinking", category: "Session", + hidden: true, slash: { name: "thinking", aliases: ["toggle-thinking"], }, run: () => { - thinking.set(nextThinkingMode(thinkingMode())) + void configState + .update((draft) => { + draft.session = { ...draft.session, thinking: nextThinkingMode(thinkingMode()) } + }) + .catch(toast.error) dialog.clear() }, }, @@ -489,8 +497,13 @@ export function Session() { title: "Toggle session scrollbar", value: "session.toggle.scrollbar", category: "Session", + hidden: true, run: () => { - setShowScrollbar((prev) => !prev) + void configState + .update((draft) => { + draft.session = { ...draft.session, scrollbar: !showScrollbar() } + }) + .catch(toast.error) dialog.clear() }, }, @@ -498,8 +511,13 @@ export function Session() { title: groupExploration() ? "Show tool calls individually" : "Group related tool calls", value: "session.toggle.exploration_grouping", category: "Session", + hidden: true, run: () => { - setGroupExploration((prev) => !prev) + void configState + .update((draft) => { + draft.session = { ...draft.session, grouping: groupExploration() ? "none" : "auto" } + }) + .catch(toast.error) dialog.clear() }, }, @@ -855,6 +873,7 @@ export function Session() { sessionID: route.sessionID, thinkingMode, showThinking, + markdownMode, groupExploration, diffWrapMode, models, @@ -1287,7 +1306,6 @@ function SessionSkillMessage(props: { message: Extract }) { const ctx = use() - const kv = useKV() const { theme, syntax } = useTheme() const status = () => props.message.status const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary) @@ -1300,7 +1318,7 @@ function CompactionMessage(props: { message: Extract - ⋯}> + ⋯}> @@ -1320,7 +1338,7 @@ function CompactionMessage(props: { message: Extract @@ -1698,7 +1716,7 @@ function ReasoningPart(props: { part: SessionMessageAssistantReasoning message: SessionMessageAssistant }) { - const { theme } = useTheme() + const { theme, syntax } = useTheme() const ctx = use() // Collapsed by default in hide mode: a single line throughout, so the // layout never shifts. Click to open the full markdown block, click to close. @@ -1718,8 +1736,6 @@ function ReasoningPart(props: { return end === undefined ? 0 : Math.max(0, end - start) }) const summary = createMemo(() => reasoningSummary(content())) - const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme)) - const toggle = () => { if (!inMinimal()) return setExpanded((prev) => !prev) @@ -1745,7 +1761,7 @@ function ReasoningPart(props: { streaming={true} syntaxStyle={syntax()} content={summary().body} - conceal={false} + conceal={ctx.markdownMode() === "rendered"} fg={theme.textMuted} /> @@ -1811,7 +1827,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { internalBlockMode="top-level" content={props.part.text.trim()} tableOptions={{ style: "grid" }} - conceal={false} + conceal={ctx.markdownMode() === "rendered"} fg={theme.markdownText} bg={theme.background} /> @@ -2380,10 +2396,6 @@ function Subagent(props: ToolProps) { ) } -export function formatSubagentToolcalls(count: number) { - return `${count} toolcall${count === 1 ? "" : "s"}` -} - export function formatSubagentTitle(agent: string, description: string, background: boolean) { return `${agent} Subagent — ${description}${background ? " [background]" : ""}` } @@ -2392,11 +2404,6 @@ export function formatSubagentRetry(attempt: number, message: string) { return `Retrying (attempt ${attempt}) · ${message}` } -export function formatCompletedSubagentDetail(toolcalls: number, duration: string) { - if (toolcalls === 0) return duration - return `${formatSubagentToolcalls(toolcalls)} · ${duration}` -} - type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record } function executeCalls(value: unknown): ExecuteCall[] { diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 1df58d3539..c7afea1252 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -177,7 +177,9 @@ export function createSessionRows(sessionID: Accessor) { } const queuedStart = (rows: SessionRow[]) => { - const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID)) + const index = rows.findIndex( + (row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)), + ) return index === -1 ? rows.length : index } diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index efdf02357a..27ac47bf0b 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -90,7 +90,6 @@ export type Theme = { _hasSelectedListItemText: boolean } type ThemeColor = Exclude -export type SyntaxStyleOverrides = Record export function selectedForeground(theme: Theme, bg?: RGBA): RGBA { // If theme explicitly defines selectedListItemText, use it @@ -557,32 +556,6 @@ export function generateSyntax(theme: Theme) { return SyntaxStyle.fromTheme(getSyntaxRules(theme)) } -export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) { - const rules = getSyntaxRules(theme) - return SyntaxStyle.fromTheme( - rules.map((rule) => { - const override = rule.scope.reduce((acc, scope) => ({ ...acc, ...overrides?.[scope] }), {}) - if (rule.style.foreground) { - const fg = rule.style.foreground - return { - ...rule, - style: { - ...rule.style, - ...override, - foreground: RGBA.fromInts( - Math.round(fg.r * 255), - Math.round(fg.g * 255), - Math.round(fg.b * 255), - Math.round(theme.thinkingOpacity * 255), - ), - }, - } - } - return rule - }), - ) -} - function getSyntaxRules(theme: Theme) { return [ { diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index e5887599f9..40ad0f24b1 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -36,14 +36,7 @@ export interface DialogSelectProps { renderFilter?: boolean locked?: boolean preserveSelection?: boolean - actions?: { - command: string - title: string - side?: "left" | "right" - hidden?: boolean - disabled?: boolean | ((option: DialogSelectOption | undefined) => boolean) - onTrigger: (option: DialogSelectOption) => void - }[] + actions?: DialogSelectAction[] footerHints?: { title: string label: string @@ -54,6 +47,24 @@ export interface DialogSelectProps { focusCurrent?: boolean } +type DialogSelectActionBase = { + command: string + title: string + side?: "left" | "right" + hidden?: boolean + disabled?: boolean | ((option: DialogSelectOption | undefined) => boolean) +} + +type DialogSelectAction = + | (DialogSelectActionBase & { + selection?: "required" + onTrigger: (option: DialogSelectOption) => void + }) + | (DialogSelectActionBase & { + selection: "none" + onTrigger: () => void + }) + export interface DialogSelectOption { title: string titleView?: JSX.Element @@ -222,7 +233,11 @@ export function DialogSelect(props: DialogSelectProps) { on( () => props.options, () => { - if (!props.preserveSelection) return + if (!props.preserveSelection) { + const next = Math.min(store.selected, flat().length - 1) + if (next >= 0 && next !== store.selected) setStore("selected", next) + return + } if (resetSelection && store.filter.length > 0) { const option = flat()[0] if (!option) return @@ -350,7 +365,7 @@ export function DialogSelect(props: DialogSelectProps) { setStore("input", "keyboard") const index = focusedAction() if (index !== undefined) { - triggerAction(actionItems()[index]) + trigger(actionItems()[index]) return } const option = selected() @@ -441,14 +456,7 @@ export function DialogSelect(props: DialogSelectProps) { name: item.command, title: item.title, category: "Dialog", - run() { - if (props.locked) return - if (isActionDisabled(item)) return - setStore("input", "keyboard") - const option = selected() - if (!option) return - item.onTrigger(option) - }, + run: () => trigger(item), })), ], bindings: [ @@ -504,10 +512,13 @@ export function DialogSelect(props: DialogSelectProps) { const left = createMemo(() => visibleActions().filter((item) => item.side !== "right")) const right = createMemo(() => visibleActions().filter((item) => item.side === "right")) - function triggerAction(item: VisibleAction | undefined) { - if (props.locked) return - if (!item || !isActionItem(item) || isActionDisabled(item)) return + function trigger(item: Action | undefined) { + if (props.locked || !item || isActionDisabled(item)) return setStore("input", "keyboard") + if (item.selection === "none") { + item.onTrigger() + return + } const option = selected() if (!option) return item.onTrigger(option) @@ -518,7 +529,9 @@ export function DialogSelect(props: DialogSelectProps) { } function isActionDisabled(item: Action) { - return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled + const option = selected() + if (item.selection !== "none" && !option) return true + return typeof item.disabled === "function" ? item.disabled(option) : item.disabled } function isActionFocused(item: VisibleAction) { @@ -545,7 +558,7 @@ export function DialogSelect(props: DialogSelectProps) { triggerAction(item)} + onMouseUp={() => trigger(item)} > boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } - -export async function mount(override?: FetchHandler, state?: string) { - const events = createEventStream() - const calls = createFetch(override, events) - let sync!: ReturnType - let project!: ReturnType - let kv!: ReturnType - let done!: () => void - const ready = new Promise((resolve) => { - done = resolve - }) - - function Probe() { - const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() } - onMount(() => { - sync = ctx.sync - project = ctx.project - kv = ctx.kv - done() - }) - return - } - - const app = await testRender(() => ( - - - - - - - {}}> - - - - - - - - - - - )) - - await ready - await wait(() => sync.status === "complete") - return { app, emit: events.emit, kv, project, sync, session: calls.session } -} diff --git a/packages/tui/test/cli/cmd/tui/sync.test.tsx b/packages/tui/test/cli/cmd/tui/sync.test.tsx deleted file mode 100644 index e93c81f091..0000000000 --- a/packages/tui/test/cli/cmd/tui/sync.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { expect, test } from "bun:test" -import { mount } from "./sync-fixture" - -test("legacy sync is an inert compatibility context", async () => { - const { app, session, sync } = await mount() - - try { - expect(sync.status).toBe("complete") - expect(sync.ready).toBe(true) - expect(sync.data.session).toEqual([]) - expect(sync.data.message).toEqual({}) - expect(sync.data.provider).toEqual([]) - expect(sync.session.get("ses_test")).toBeUndefined() - - await sync.bootstrap() - await sync.session.refresh() - await sync.session.sync("ses_test") - - expect(session).toEqual([]) - } finally { - app.renderer.destroy() - } -}) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 416085bdb4..0e4102d30c 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1202,6 +1202,21 @@ test("restores queued compaction from durable pending input", async () => { { type: "compaction-queued", inputID: "message-compaction-later" }, ]) + emitEvent(events, { + id: "evt_text_ended", + created: 2, + type: "session.text.ended", + durable: durable(sessionID, 5), + data: { + sessionID, + assistantMessageID: "message-assistant", + ordinal: 0, + text: "Active output", + }, + }) + await wait(() => rows.some((row) => row.type === "part")) + expect(rows.map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"]) + emitEvent(events, { id: "evt_compaction_started", created: 2, diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index b4b567b9e2..d648258746 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -26,12 +26,10 @@ async function mountPrompt(input: { }) { const state = path.join(input.root, "state") await mkdir(state, { recursive: true }) - await Bun.write(path.join(state, "kv.json"), "{}") const [ { DialogProvider }, { DialogPrompt }, - { KVProvider }, { ThemeProvider }, { ConfigProvider }, { ToastProvider }, @@ -39,7 +37,6 @@ async function mountPrompt(input: { ] = await Promise.all([ import("../../../src/ui/dialog"), import("../../../src/ui/dialog-prompt"), - import("../../../src/context/kv"), import("../../../src/context/theme"), import("../../../src/config"), import("../../../src/ui/toast"), @@ -67,15 +64,13 @@ async function mountPrompt(input: { > - - - - - - - - - + + + + + + + diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx new file mode 100644 index 0000000000..96c849186d --- /dev/null +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -0,0 +1,264 @@ +/** @jsxImportSource @opentui/solid */ +import { InputRenderable } from "@opentui/core" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { createSignal, onCleanup, onMount } from "solid-js" +import type { DialogSelectOption } from "../../../src/ui/dialog-select" +import { tmpdir } from "../../fixture/fixture" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +async function renderSelect( + root: string, + options: DialogSelectOption[], + onGlobal: () => void, + onRow: (option: DialogSelectOption) => void, +) { + const state = path.join(root, "state") + await mkdir(state, { recursive: true }) + const config = createTuiResolvedConfig() + const [ + { ConfigProvider }, + { ThemeProvider }, + { OpencodeKeymapProvider, registerOpencodeKeymap }, + { DialogProvider }, + { DialogSelect }, + { ToastProvider }, + ] = await Promise.all([ + import("../../../src/config"), + import("../../../src/context/theme"), + import("../../../src/keymap"), + import("../../../src/ui/dialog"), + import("../../../src/ui/dialog-select"), + import("../../../src/ui/toast"), + ]) + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const off = registerOpencodeKeymap(keymap, renderer, config) + onCleanup(off) + + return ( + + + + Promise.resolve({}) }}> + + + + + + + + + + ) + } + + const app = await testRender(() => , { width: 80, height: 20, kittyKeyboard: true }) + app.renderer.start() + await app.waitForFrame((frame) => frame.includes("Items")) + await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) + return app +} + +async function mountSelect(root: string, initial: DialogSelectOption[]) { + const state = path.join(root, "state") + await mkdir(state, { recursive: true }) + const config = createTuiResolvedConfig() + const [ + { ConfigProvider }, + { ThemeProvider }, + { OpencodeKeymapProvider, registerOpencodeKeymap }, + { DialogProvider, useDialog }, + { DialogSelect }, + { ToastProvider }, + ] = await Promise.all([ + import("../../../src/config"), + import("../../../src/context/theme"), + import("../../../src/keymap"), + import("../../../src/ui/dialog"), + import("../../../src/ui/dialog-select"), + import("../../../src/ui/toast"), + ]) + + const selected: string[] = [] + const moved: string[] = [] + let replaceOptions!: (options: DialogSelectOption[]) => void + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const off = registerOpencodeKeymap(keymap, renderer, config) + const [options, setOptions] = createSignal(initial) + replaceOptions = setOptions + onCleanup(off) + + function Fixture() { + const dialog = useDialog() + onMount(() => + dialog.replace(() => ( + moved.push(option.value)} + onSelect={(option) => selected.push(option.value)} + /> + )), + ) + return null + } + + return ( + + + + Promise.resolve({}) }}> + + + + + + + + + + ) + } + + const app = await testRender(() => , { width: 80, height: 24, kittyKeyboard: true }) + app.renderer.start() + await app.waitForFrame((frame) => frame.includes("Mutable options")) + await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) + return { app, moved, replaceOptions, selected } +} + +test("dialog actions run without options while row actions still require a selection", async () => { + await using tmp = await tmpdir() + let global = 0 + const rows: string[] = [] + const app = await renderSelect( + tmp.path, + [], + () => global++, + (option) => rows.push(option.value), + ) + + try { + app.mockInput.pressKey("m", { ctrl: true }) + app.mockInput.pressKey("d", { ctrl: true }) + + expect(global).toBe(1) + expect(rows).toEqual([]) + } finally { + app.renderer.destroy() + } +}) + +test("footer actions run when filtering leaves no selected row", async () => { + await using tmp = await tmpdir() + let global = 0 + const rows: string[] = [] + const app = await renderSelect( + tmp.path, + [{ title: "Alpha", value: "alpha" }], + () => global++, + (option) => rows.push(option.value), + ) + + try { + for (const key of "missing") app.mockInput.pressKey(key) + await app.waitForFrame((frame) => frame.includes("No results found")) + + app.mockInput.pressKey("d", { ctrl: true }) + app.mockInput.pressTab() + app.mockInput.pressEnter() + + expect(global).toBe(1) + expect(rows).toEqual([]) + } finally { + app.renderer.destroy() + } +}) + +test("row actions receive the selected option", async () => { + await using tmp = await tmpdir() + const rows: string[] = [] + const app = await renderSelect( + tmp.path, + [{ title: "Alpha", value: "alpha" }], + () => {}, + (option) => rows.push(option.value), + ) + + try { + app.mockInput.pressKey("d", { ctrl: true }) + + expect(rows).toEqual(["alpha"]) + } finally { + app.renderer.destroy() + } +}) + +test("selects the new final option immediately after removing the selected final option", async () => { + await using tmp = await tmpdir() + const options = ["first", "second", "third"].map((value) => ({ title: value, value })) + const select = await mountSelect(tmp.path, options) + + try { + select.app.mockInput.pressArrow("down") + await select.app.waitFor(() => select.moved.at(-1) === "second") + select.app.mockInput.pressArrow("down") + await select.app.waitFor(() => select.moved.at(-1) === "third") + select.replaceOptions(options.slice(0, -1)) + await select.app.waitForFrame((frame) => !frame.includes("third")) + + select.app.mockInput.pressEnter() + await select.app.waitFor(() => select.selected.length === 1) + + expect(select.selected).toEqual(["second"]) + } finally { + select.app.renderer.destroy() + } +}) + +test("selects a repopulated option after removing the only option", async () => { + await using tmp = await tmpdir() + const select = await mountSelect(tmp.path, [{ title: "only", value: "only" }]) + + try { + select.replaceOptions([]) + await select.app.waitForFrame((frame) => frame.includes("No results found")) + select.app.mockInput.pressEnter() + expect(select.selected).toEqual([]) + + select.replaceOptions([{ title: "replacement", value: "replacement" }]) + await select.app.waitForFrame((frame) => frame.includes("replacement")) + select.app.mockInput.pressEnter() + await select.app.waitFor(() => select.selected.length === 1) + + expect(select.selected).toEqual(["replacement"]) + } finally { + select.app.renderer.destroy() + } +}) diff --git a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx index 30ec95565c..6a7cb174a9 100644 --- a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -4,7 +4,6 @@ import { RGBA } from "@opentui/core" import { testRender } from "@opentui/solid" import type { JSX } from "solid-js" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -import { KVProvider } from "../../../src/context/kv" import { ThemeProvider } from "../../../src/context/theme" import { ConfigProvider } from "../../../src/config" import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree" @@ -182,9 +181,7 @@ function withTheme(component: () => JSX.Element) { return ( - - {component()} - + {component()} ) diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 36fff6f135..494837d9a0 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -5,7 +5,6 @@ import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/c import { testRender, useRenderer } from "@opentui/solid" import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui" import type { Session } from "@opencode-ai/sdk/v2" -import { KVProvider } from "../../../src/context/kv" import { ThemeProvider } from "../../../src/context/theme" import { ConfigProvider } from "../../../src/config" import { SDKProvider } from "../../../src/context/sdk" @@ -174,11 +173,9 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: - - - {renderDiff?.({ params: "params" in current ? current.params : undefined })} - - + + {renderDiff?.({ params: "params" in current ? current.params : undefined })} + diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index 0a28ba8de3..b4ab1f57f3 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -7,7 +7,6 @@ import path from "node:path" import { onCleanup } from "solid-js" import { ClipboardProvider } from "../../../src/context/clipboard" import type { FormWithLocation } from "../../../src/context/data" -import { KVProvider } from "../../../src/context/kv" import { SDKProvider } from "../../../src/context/sdk" import { ThemeProvider } from "../../../src/context/theme" import { ConfigProvider } from "../../../src/config" @@ -21,7 +20,6 @@ import { createApi, createClient, createEventStream, createFetch } from "../../f async function mountForm(root: string, width = 80) { const state = path.join(root, "state") await mkdir(state, { recursive: true }) - await Bun.write(path.join(state, "kv.json"), "{}") const replies: unknown[] = [] const copied: string[] = [] @@ -78,13 +76,11 @@ async function mountForm(root: string, width = 80) { - - Promise.resolve({}) }}> - - - - - + Promise.resolve({}) }}> + + + + diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 8f07f81847..2ff61fa1c3 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -2,10 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test" import { For } from "solid-js" import { testRender, type JSX } from "@opentui/solid" import { - formatCompletedSubagentDetail, formatSubagentRetry, formatSubagentTitle, - formatSubagentToolcalls, InlineToolRow, parseApplyPatchFiles, parseDiagnostics, @@ -182,13 +180,6 @@ describe("TUI inline tool wrapping", () => { ).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }]) }) - test("formats completed subagent toolcall details", () => { - expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms") - expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms") - expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms") - expect(formatSubagentToolcalls(0)).toBe("0 toolcalls") - }) - test("keeps background state attached to the subagent identity", () => { expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer") expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe( @@ -207,5 +198,4 @@ describe("TUI inline tool wrapping", () => { test("snapshots expanded tool errors under the tool text", async () => { expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot() }) - }) diff --git a/packages/tui/test/fixture/tui-plugin.ts b/packages/tui/test/fixture/tui-plugin.ts index e9fce425be..23dbf526ae 100644 --- a/packages/tui/test/fixture/tui-plugin.ts +++ b/packages/tui/test/fixture/tui-plugin.ts @@ -11,7 +11,6 @@ type Opts = { } export function createTuiPluginApi(opts: Opts = {}) { - const values = new Map() const color = RGBA.fromInts(200, 200, 200) const dialog = { clear() {}, replace() {}, setSize() {}, size: "medium" as const, depth: 0, open: false } return { @@ -19,15 +18,6 @@ export function createTuiPluginApi(opts: Opts = {}) { client: opts.client, event: opts.event, keymap: opts.keymap, - kv: { - get(name: string, fallback?: unknown) { - return values.has(name) ? values.get(name) : fallback - }, - set(name: string, value: unknown) { - values.set(name, value) - }, - ready: true, - }, state: { session: { get: () => undefined, ...opts.state?.session } }, theme: { current: new Proxy({}, { get: () => color }) }, tuiConfig: createTuiResolvedConfig(), diff --git a/packages/www/package.json b/packages/www/package.json index 75b8a95fd6..de5219829b 100644 --- a/packages/www/package.json +++ b/packages/www/package.json @@ -8,6 +8,7 @@ "dev": "vite dev --host 0.0.0.0 --port 3000", "build": "vite build", "preview": "vite preview --host 0.0.0.0", + "pretypecheck": "fumadocs-mdx", "typecheck": "tsgo --noEmit" }, "dependencies": {