feat(tui): group sequential thinking (#36901)

Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-07-14 15:13:16 -04:00
committed by GitHub
co-authored by Kit Langton
parent b67bed061a
commit 016545513f
8 changed files with 262 additions and 99 deletions
+112 -1
View File
@@ -968,7 +968,10 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
<Match when={props.row.type === "part" ? props.row : undefined}> <Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} message={props.message} />} {(row) => <SessionPartView partRef={row().ref} message={props.message} />}
</Match> </Match>
<Match when={props.row.type === "group" ? props.row : undefined}> <Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
{(row) => ( {(row) => (
<SessionGroupView <SessionGroupView
refs={row().refs} refs={row().refs}
@@ -1078,6 +1081,114 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
) )
} }
function SessionReasoningGroupView(props: {
refs: PartRef[]
completed: boolean
message: (messageID: string) => 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 (
<Show when={parts().length > 0}>
<Show
when={ctx.thinkingMode() === "hide"}
fallback={
<For each={parts()}>{(item) => <ReasoningPart part={item.part} message={item.message} last={false} />}</For>
}
>
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
<box
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
<Show
when={props.completed}
fallback={<Spinner color={theme.warning}>{latest() ? `Thinking: ${latest()}` : "Thinking"}</Spinner>}
>
<Show
when={expanded()}
fallback={
<text fg={theme.warning} wrapMode="none">
+ Thought
<Show when={latest()}>: {latest()}</Show>
<Show when={parts().length > 1}> · {parts().length} steps</Show>
<Show when={duration()}> · {Locale.duration(duration())}</Show>
</text>
}
>
<text fg={theme.warning} wrapMode="none">
- Thought
<Show when={parts().length > 1}> · {parts().length} steps</Show>
<Show when={duration()}> · {Locale.duration(duration())}</Show>
</text>
</Show>
</Show>
</box>
<Show when={expanded()}>
<For each={parts()}>
{(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 (
<box marginTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={false}
syntaxStyle={syntax()}
content={markdown()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.textMuted}
/>
</box>
)
}}
</For>
</Show>
</box>
</Show>
</Show>
)
}
function SessionGroupView(props: { function SessionGroupView(props: {
refs: PartRef[] refs: PartRef[]
pending: PartRef[] pending: PartRef[]
+30 -3
View File
@@ -12,6 +12,12 @@ export type SessionRow =
| { type: "message"; messageID: string } | { type: "message"; messageID: string }
| { type: "compaction-queued"; inputID: string } | { type: "compaction-queued"; inputID: string }
| { type: "part"; ref: PartRef } | { type: "part"; ref: PartRef }
| {
type: "group"
kind: "reasoning"
refs: PartRef[]
completed: boolean
}
| { | {
type: "group" type: "group"
kind: "exploration" kind: "exploration"
@@ -131,6 +137,16 @@ export function createSessionRows(sessionID: Accessor<string>) {
produce((draft) => { produce((draft) => {
if (hasPart(draft, ref)) return if (hasPart(draft, ref)) return
const index = queuedStart(draft) 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)) { if (name && exploration(name)) {
const previous = draft[index - 1] const previous = draft[index - 1]
if (previous?.type === "group" && previous.kind === "exploration") { if (previous?.type === "group" && previous.kind === "exploration") {
@@ -213,7 +229,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.agent.selected", message), data.on("session.agent.selected", message),
data.on("session.model.selected", message), data.on("session.model.selected", message),
data.on("session.text.delta", (event) => { 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}` }) appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
}), }),
data.on("session.text.ended", (event) => { 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]) { 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 (part.type === "tool") {
if (exploration(part.name)) { if (exploration(part.name)) {
const previous = rows.at(-1) const previous = rows.at(-1)
@@ -313,7 +339,7 @@ function completePrevious(rows: SessionRow[], index = rows.length) {
function partitionPending(rows: SessionRow[], pending: Set<string>) { function partitionPending(rows: SessionRow[], pending: Set<string>) {
rows.forEach((row) => { rows.forEach((row) => {
if (row.type !== "group") return if (row.type !== "group" || row.kind !== "exploration") return
const refs = [...row.refs, ...row.pending] const refs = [...row.refs, ...row.pending]
row.refs = refs.filter((ref) => !pending.has(ref.partID)) row.refs = refs.filter((ref) => !pending.has(ref.partID))
row.pending = 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) => { return rows.some((row) => {
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
if (row.type !== "group") return false 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)
}) })
} }
+1
View File
@@ -38,6 +38,7 @@ export function DialogPrompt(props: DialogPromptProps) {
{ {
id: "dialog.prompt.submit", id: "dialog.prompt.submit",
title: "Submit dialog prompt", title: "Submit dialog prompt",
bind: "return",
group: "Dialog", group: "Dialog",
run: confirm, run: confirm,
}, },
@@ -1,7 +1,6 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { TextareaRenderable } from "@opentui/core" import { TextareaRenderable } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { testRender } from "@opentui/solid"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
@@ -27,31 +26,26 @@ async function mountPrompt(input: {
const state = path.join(input.root, "state") const state = path.join(input.root, "state")
await mkdir(state, { recursive: true }) await mkdir(state, { recursive: true })
const [ const [{ DialogProvider }, { DialogPrompt }, { ThemeProvider }, { ConfigProvider }, { ToastProvider }, { Keymap }] =
{ DialogProvider }, await Promise.all([
{ DialogPrompt },
{ ThemeProvider },
{ ConfigProvider },
{ ToastProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
] = await Promise.all([
import("../../../src/ui/dialog"), import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-prompt"), import("../../../src/ui/dialog-prompt"),
import("../../../src/context/theme"), import("../../../src/context/theme"),
import("../../../src/config"), import("../../../src/config"),
import("../../../src/ui/toast"), import("../../../src/ui/toast"),
import("../../../src/keymap"), import("../../../src/context/keymap"),
]) ])
function Harness() { function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const resolvedConfig = createTuiResolvedConfig({ const resolvedConfig = createTuiResolvedConfig({
keybinds: input.keybinds, keybinds: input.keybinds,
leader: { timeout: 1000 }, leader: { timeout: 1000 },
}) })
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
onCleanup(off) function Prompt() {
onCleanup(Keymap.use().mode.push("modal"))
return <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
}
return ( return (
<TestTuiContexts <TestTuiContexts
@@ -62,22 +56,23 @@ async function mountPrompt(input: {
worktree: input.root, worktree: input.root,
}} }}
> >
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={resolvedConfig}> <ConfigProvider config={resolvedConfig}>
<Keymap.Provider>
<ThemeProvider mode="dark"> <ThemeProvider mode="dark">
<ToastProvider> <ToastProvider>
<DialogProvider> <DialogProvider>
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} /> <Prompt />
</DialogProvider> </DialogProvider>
</ToastProvider> </ToastProvider>
</ThemeProvider> </ThemeProvider>
</Keymap.Provider>
</ConfigProvider> </ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts> </TestTuiContexts>
) )
} }
const app = await testRender(() => <Harness />, { kittyKeyboard: true }) const app = await testRender(() => <Harness />, { kittyKeyboard: true })
app.renderer.start()
return { return {
app, app,
async cleanup() { async cleanup() {
@@ -1,7 +1,6 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { InputRenderable } from "@opentui/core" import { InputRenderable } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { testRender } from "@opentui/solid"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
@@ -21,35 +20,20 @@ async function renderSelect(
const state = path.join(root, "state") const state = path.join(root, "state")
await mkdir(state, { recursive: true }) await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig() const config = createTuiResolvedConfig()
const [ const [{ ConfigProvider }, { ThemeProvider }, { Keymap }, { DialogProvider }, { DialogSelect }, { ToastProvider }] =
{ ConfigProvider }, await Promise.all([
{ ThemeProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
{ DialogProvider },
{ DialogSelect },
{ ToastProvider },
] = await Promise.all([
import("../../../src/config"), import("../../../src/config"),
import("../../../src/context/theme"), import("../../../src/context/theme"),
import("../../../src/keymap"), import("../../../src/context/keymap"),
import("../../../src/ui/dialog"), import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"), import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"), import("../../../src/ui/toast"),
]) ])
function Harness() { function Harness() {
const renderer = useRenderer() function Select() {
const keymap = createDefaultOpenTuiKeymap(renderer) onCleanup(Keymap.use().mode.push("modal"))
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)
return ( return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<DialogSelect <DialogSelect
title="Items" title="Items"
options={options} options={options}
@@ -68,11 +52,22 @@ async function renderSelect(
}, },
]} ]}
/> />
)
}
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={config}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<Select />
</DialogProvider> </DialogProvider>
</ToastProvider> </ToastProvider>
</ThemeProvider> </ThemeProvider>
</Keymap.Provider>
</ConfigProvider> </ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts> </TestTuiContexts>
) )
} }
@@ -91,14 +86,14 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
const [ const [
{ ConfigProvider }, { ConfigProvider },
{ ThemeProvider }, { ThemeProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap }, { Keymap },
{ DialogProvider, useDialog }, { DialogProvider, useDialog },
{ DialogSelect }, { DialogSelect },
{ ToastProvider }, { ToastProvider },
] = await Promise.all([ ] = await Promise.all([
import("../../../src/config"), import("../../../src/config"),
import("../../../src/context/theme"), import("../../../src/context/theme"),
import("../../../src/keymap"), import("../../../src/context/keymap"),
import("../../../src/ui/dialog"), import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"), import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"), import("../../../src/ui/toast"),
@@ -109,12 +104,8 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
let replaceOptions!: (options: DialogSelectOption<string>[]) => void let replaceOptions!: (options: DialogSelectOption<string>[]) => void
function Harness() { function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
const [options, setOptions] = createSignal(initial) const [options, setOptions] = createSignal(initial)
replaceOptions = setOptions replaceOptions = setOptions
onCleanup(off)
function Fixture() { function Fixture() {
const dialog = useDialog() const dialog = useDialog()
@@ -133,8 +124,8 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
return ( return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}> <TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}> <ConfigProvider config={config}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}> <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider> <ToastProvider>
<DialogProvider> <DialogProvider>
@@ -142,8 +133,8 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
</DialogProvider> </DialogProvider>
</ToastProvider> </ToastProvider>
</ThemeProvider> </ThemeProvider>
</Keymap.Provider>
</ConfigProvider> </ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts> </TestTuiContexts>
) )
} }
+4 -11
View File
@@ -1,16 +1,14 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { testRender } from "@opentui/solid"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { onCleanup } from "solid-js"
import { ClipboardProvider } from "../../../src/context/clipboard" import { ClipboardProvider } from "../../../src/context/clipboard"
import type { FormWithLocation } from "../../../src/context/data" import type { FormWithLocation } from "../../../src/context/data"
import { ClientProvider } from "../../../src/context/client" import { ClientProvider } from "../../../src/context/client"
import { ThemeProvider } from "../../../src/context/theme" import { ThemeProvider } from "../../../src/context/theme"
import { Keymap } from "../../../src/context/keymap"
import { ConfigProvider } from "../../../src/config" import { ConfigProvider } from "../../../src/config"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
import { ToastProvider } from "../../../src/ui/toast" import { ToastProvider } from "../../../src/ui/toast"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment" 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") const { FormPrompt } = await import("../../../src/routes/session/form")
function Harness() { function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)
return ( return (
<TestTuiContexts <TestTuiContexts
directory={root} directory={root}
@@ -73,8 +66,8 @@ async function mountForm(root: string, width = 80) {
}, },
}} }}
> >
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}> <ConfigProvider config={config}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}> <ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}> <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider> <ToastProvider>
@@ -82,8 +75,8 @@ async function mountForm(root: string, width = 80) {
</ToastProvider> </ToastProvider>
</ThemeProvider> </ThemeProvider>
</ClientProvider> </ClientProvider>
</Keymap.Provider>
</ConfigProvider> </ConfigProvider>
</OpencodeKeymapProvider>
</ClipboardProvider> </ClipboardProvider>
</TestTuiContexts> </TestTuiContexts>
) )
+48 -3
View File
@@ -74,9 +74,49 @@ test("assigns stable kind ordinals within an assistant message", () => {
expect(reduceSessionRows(messages)).toEqual([ expect(reduceSessionRows(messages)).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, { 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: "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([ 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", type: "group",
kind: "exploration", kind: "exploration",