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({}) }}>
-
+
-
-
+
+
)
}
@@ -91,14 +86,14 @@ async function mountSelect(root: string, initial: DialogSelectOption[])
const [
{ ConfigProvider },
{ ThemeProvider },
- { OpencodeKeymapProvider, registerOpencodeKeymap },
+ { Keymap },
{ DialogProvider, useDialog },
{ DialogSelect },
{ ToastProvider },
] = await Promise.all([
import("../../../src/config"),
import("../../../src/context/theme"),
- import("../../../src/keymap"),
+ import("../../../src/context/keymap"),
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"),
@@ -109,12 +104,8 @@ async function mountSelect(root: string, initial: DialogSelectOption[])
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()
@@ -133,8 +124,8 @@ async function mountSelect(root: string, initial: DialogSelectOption[])
return (
-
-
+
+
Promise.resolve({}) }}>
@@ -142,8 +133,8 @@ async function mountSelect(root: string, initial: DialogSelectOption[])
-
-
+
+
)
}
diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx
index c1c83e8723..9aa82f7a89 100644
--- a/packages/tui/test/cli/tui/form.test.tsx
+++ b/packages/tui/test/cli/tui/form.test.tsx
@@ -1,16 +1,14 @@
/** @jsxImportSource @opentui/solid */
-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"
-import { onCleanup } from "solid-js"
import { ClipboardProvider } from "../../../src/context/clipboard"
import type { FormWithLocation } from "../../../src/context/data"
import { ClientProvider } from "../../../src/context/client"
import { ThemeProvider } from "../../../src/context/theme"
+import { Keymap } from "../../../src/context/keymap"
import { ConfigProvider } from "../../../src/config"
-import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
import { ToastProvider } from "../../../src/ui/toast"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -51,11 +49,6 @@ async function mountForm(root: string, width = 80) {
const { FormPrompt } = await import("../../../src/routes/session/form")
function Harness() {
- const renderer = useRenderer()
- const keymap = createDefaultOpenTuiKeymap(renderer)
- const off = registerOpencodeKeymap(keymap, renderer, config)
- onCleanup(off)
-
return (
-
-
+
+
Promise.resolve({}) }}>
@@ -82,8 +75,8 @@ async function mountForm(root: string, width = 80) {
-
-
+
+
)
diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts
index f6d2701ac7..b0f2f64fbc 100644
--- a/packages/tui/test/cli/tui/session-rows.test.ts
+++ b/packages/tui/test/cli/tui/session-rows.test.ts
@@ -74,9 +74,49 @@ test("assigns stable kind ordinals within an assistant message", () => {
expect(reduceSessionRows(messages)).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
- { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
+ {
+ type: "group",
+ kind: "reasoning",
+ completed: true,
+ refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
+ },
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
- { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } },
+ {
+ type: "group",
+ kind: "reasoning",
+ completed: false,
+ refs: [{ messageID: "assistant-1", partID: "reasoning:1" }],
+ },
+ ])
+})
+
+test("groups adjacent reasoning parts until a visible boundary", () => {
+ const messages: SessionMessageInfo[] = [
+ assistant("assistant-1", [
+ { type: "reasoning", text: "First" },
+ { type: "reasoning", text: "Second" },
+ { type: "text", text: "Visible" },
+ { type: "reasoning", text: "Third" },
+ ]),
+ ]
+
+ expect(reduceSessionRows(messages)).toEqual([
+ {
+ type: "group",
+ kind: "reasoning",
+ completed: true,
+ refs: [
+ { messageID: "assistant-1", partID: "reasoning:0" },
+ { messageID: "assistant-1", partID: "reasoning:1" },
+ ],
+ },
+ { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
+ {
+ type: "group",
+ kind: "reasoning",
+ completed: false,
+ refs: [{ messageID: "assistant-1", partID: "reasoning:2" }],
+ },
])
})
@@ -93,7 +133,12 @@ test("groups across empty assistant reasoning parts", () => {
]
expect(reduceSessionRows(messages)).toEqual([
- { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
+ {
+ type: "group",
+ kind: "reasoning",
+ completed: true,
+ refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
+ },
{
type: "group",
kind: "exploration",