diff --git a/artifacts/tui-thinking-disclosure/thinking-flat-markdown-disclosure.mp4 b/artifacts/tui-thinking-disclosure/thinking-flat-markdown-disclosure.mp4 new file mode 100644 index 0000000000..7df912aad1 Binary files /dev/null and b/artifacts/tui-thinking-disclosure/thinking-flat-markdown-disclosure.mp4 differ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 47ee2d55fc..054d35d405 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -968,7 +968,10 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) = {(row) => } - + + {(row) => } + + {(row) => ( SessionMessageInfo | undefined +}) { + const ctx = use() + const { theme, syntax } = useTheme() + const renderer = useRenderer() + const [expanded, setExpanded] = createSignal(false) + const parts = createMemo<{ message: SessionMessageAssistant; part: SessionMessageAssistantReasoning }[]>( + (previous) => { + const next = props.refs.flatMap((ref) => { + const message = props.message(ref.messageID) + if (message?.type !== "assistant") return [] + const part = resolvePart(message, ref.partID) + if (part?.type !== "reasoning" || !part.text.replace("[REDACTED]", "").trim()) return [] + return [{ message, part }] + }) + return next.length > 0 ? next : previous + }, + [] as { message: SessionMessageAssistant; part: SessionMessageAssistantReasoning }[], + ) + const latest = createMemo((previous: string | null) => { + const item = parts().at(-1) + if (!item) return previous + const title = reasoningSummary(item.part.text.replace("[REDACTED]", "").trim()).title + if (title) return title + if (item.part.time?.completed !== undefined || item.message.time.completed !== undefined) return null + return previous + }, null) + const duration = createMemo(() => + parts().reduce((total, item) => { + const end = item.part.time?.completed ?? item.message.time.completed + const start = item.part.time?.created ?? item.message.time.created + return total + (end === undefined ? 0 : Math.max(0, end - start)) + }, 0), + ) + + return ( + 0}> + {(item) => } + } + > + + { + if (renderer.getSelection()?.getSelectedText()) return + setExpanded((value) => !value) + }} + > + {latest() ? `Thinking: ${latest()}` : "Thinking"}} + > + + + Thought + : {latest()} + 1}> · {parts().length} steps + · {Locale.duration(duration())} + + } + > + + - Thought + 1}> · {parts().length} steps + · {Locale.duration(duration())} + + + + + + + {(item) => { + const content = createMemo(() => item.part.text.replace("[REDACTED]", "").trim()) + const summary = createMemo(() => reasoningSummary(content())) + const markdown = createMemo(() => { + if (!summary().title) return content() + if (!summary().body) return `**${summary().title}**` + return `**${summary().title}** · ${summary().body}` + }) + return ( + + + + ) + }} + + + + + + ) +} + function SessionGroupView(props: { refs: PartRef[] pending: PartRef[] diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index c7afea1252..6d3f4cf422 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -12,6 +12,12 @@ export type SessionRow = | { type: "message"; messageID: string } | { type: "compaction-queued"; inputID: string } | { type: "part"; ref: PartRef } + | { + type: "group" + kind: "reasoning" + refs: PartRef[] + completed: boolean + } | { type: "group" kind: "exploration" @@ -131,6 +137,16 @@ export function createSessionRows(sessionID: Accessor) { produce((draft) => { if (hasPart(draft, ref)) return const index = queuedStart(draft) + if (ref.partID.startsWith("reasoning:")) { + const previous = draft[index - 1] + if (previous?.type === "group" && previous.kind === "reasoning") { + previous.refs.push(ref) + return + } + completePrevious(draft, index) + draft.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false }) + return + } if (name && exploration(name)) { const previous = draft[index - 1] if (previous?.type === "group" && previous.kind === "exploration") { @@ -213,7 +229,7 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.agent.selected", message), data.on("session.model.selected", message), data.on("session.text.delta", (event) => { - if (event.data.sessionID === sessionID()) + if (event.data.sessionID === sessionID() && event.data.delta.trim()) appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) }), data.on("session.text.ended", (event) => { @@ -290,6 +306,16 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) { } function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) { + if (part.type === "reasoning") { + const previous = rows.at(-1) + if (previous?.type === "group" && previous.kind === "reasoning") { + previous.refs.push(ref) + return + } + completePrevious(rows) + rows.push({ type: "group", kind: "reasoning", refs: [ref], completed: false }) + return + } if (part.type === "tool") { if (exploration(part.name)) { const previous = rows.at(-1) @@ -313,7 +339,7 @@ function completePrevious(rows: SessionRow[], index = rows.length) { function partitionPending(rows: SessionRow[], pending: Set) { rows.forEach((row) => { - if (row.type !== "group") return + if (row.type !== "group" || row.kind !== "exploration") return const refs = [...row.refs, ...row.pending] row.refs = refs.filter((ref) => !pending.has(ref.partID)) row.pending = refs.filter((ref) => pending.has(ref.partID)) @@ -328,6 +354,7 @@ function hasPart(rows: SessionRow[], ref: PartRef) { return rows.some((row) => { if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID if (row.type !== "group") return false - return [...row.refs, ...row.pending].some((item) => item.messageID === ref.messageID && item.partID === ref.partID) + const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs + return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID) }) } diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index 094e87e58b..be89c9a8d4 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -38,6 +38,7 @@ export function DialogPrompt(props: DialogPromptProps) { { id: "dialog.prompt.submit", title: "Submit dialog prompt", + bind: "return", group: "Dialog", run: confirm, }, diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index d648258746..b2716d0559 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -1,7 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { TextareaRenderable } from "@opentui/core" -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" -import { testRender, useRenderer } from "@opentui/solid" +import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" @@ -27,31 +26,26 @@ async function mountPrompt(input: { const state = path.join(input.root, "state") await mkdir(state, { recursive: true }) - const [ - { DialogProvider }, - { DialogPrompt }, - { ThemeProvider }, - { ConfigProvider }, - { ToastProvider }, - { OpencodeKeymapProvider, registerOpencodeKeymap }, - ] = await Promise.all([ - import("../../../src/ui/dialog"), - import("../../../src/ui/dialog-prompt"), - import("../../../src/context/theme"), - import("../../../src/config"), - import("../../../src/ui/toast"), - import("../../../src/keymap"), - ]) + const [{ DialogProvider }, { DialogPrompt }, { ThemeProvider }, { ConfigProvider }, { ToastProvider }, { Keymap }] = + await Promise.all([ + import("../../../src/ui/dialog"), + import("../../../src/ui/dialog-prompt"), + import("../../../src/context/theme"), + import("../../../src/config"), + import("../../../src/ui/toast"), + import("../../../src/context/keymap"), + ]) function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) const resolvedConfig = createTuiResolvedConfig({ keybinds: input.keybinds, leader: { timeout: 1000 }, }) - const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) - onCleanup(off) + + function Prompt() { + onCleanup(Keymap.use().mode.push("modal")) + return + } return ( - - + + - + - - + + ) } const app = await testRender(() => , { kittyKeyboard: true }) + app.renderer.start() return { app, async cleanup() { diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index 8bfcb0929d..5e6a28e9be 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -1,7 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { InputRenderable } from "@opentui/core" -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" -import { testRender, useRenderer } from "@opentui/solid" +import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" @@ -21,58 +20,54 @@ async function renderSelect( 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"), - ]) + const [{ ConfigProvider }, { ThemeProvider }, { Keymap }, { DialogProvider }, { DialogSelect }, { ToastProvider }] = + await Promise.all([ + import("../../../src/config"), + import("../../../src/context/theme"), + import("../../../src/context/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) + function Select() { + onCleanup(Keymap.use().mode.push("modal")) + return ( + + ) + } return ( - - + + Promise.resolve({}) }}> - +