Sfoglia il codice sorgente

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

Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
opencode-agent[bot] 1 mese fa
parent
commit
016545513f

BIN
artifacts/tui-thinking-disclosure/thinking-flat-markdown-disclosure.mp4


+ 112 - 1
packages/tui/src/routes/session/index.tsx

@@ -968,7 +968,10 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
         <Match when={props.row.type === "part" ? props.row : undefined}>
           {(row) => <SessionPartView partRef={row().ref} message={props.message} />}
         </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) => (
             <SessionGroupView
               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: {
   refs: PartRef[]
   pending: PartRef[]

+ 30 - 3
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<string>) {
       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<string>) {
     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<string>) {
   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)
   })
 }

+ 1 - 0
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,
       },

+ 21 - 26
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 <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
+    }
 
     return (
       <TestTuiContexts
@@ -62,22 +56,23 @@ async function mountPrompt(input: {
           worktree: input.root,
         }}
       >
-        <OpencodeKeymapProvider keymap={keymap}>
-          <ConfigProvider config={resolvedConfig}>
+        <ConfigProvider config={resolvedConfig}>
+          <Keymap.Provider>
             <ThemeProvider mode="dark">
               <ToastProvider>
                 <DialogProvider>
-                  <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
+                  <Prompt />
                 </DialogProvider>
               </ToastProvider>
             </ThemeProvider>
-          </ConfigProvider>
-        </OpencodeKeymapProvider>
+          </Keymap.Provider>
+        </ConfigProvider>
       </TestTuiContexts>
     )
   }
 
   const app = await testRender(() => <Harness />, { kittyKeyboard: true })
+  app.renderer.start()
   return {
     app,
     async cleanup() {

+ 44 - 53
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 (
+        <DialogSelect
+          title="Items"
+          options={options}
+          current={current}
+          actions={[
+            {
+              command: "dialog.move_session.delete",
+              title: "delete",
+              onTrigger: onRow,
+            },
+            {
+              command: "dialog.move_session.new",
+              title: "new",
+              selection: "none",
+              onTrigger: onGlobal,
+            },
+          ]}
+        />
+      )
+    }
 
     return (
       <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({}) }}>
               <ToastProvider>
                 <DialogProvider>
-                  <DialogSelect
-                    title="Items"
-                    options={options}
-                    current={current}
-                    actions={[
-                      {
-                        command: "dialog.move_session.delete",
-                        title: "delete",
-                        onTrigger: onRow,
-                      },
-                      {
-                        command: "dialog.move_session.new",
-                        title: "new",
-                        selection: "none",
-                        onTrigger: onGlobal,
-                      },
-                    ]}
-                  />
+                  <Select />
                 </DialogProvider>
               </ToastProvider>
             </ThemeProvider>
-          </ConfigProvider>
-        </OpencodeKeymapProvider>
+          </Keymap.Provider>
+        </ConfigProvider>
       </TestTuiContexts>
     )
   }
@@ -91,14 +86,14 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
   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<string>[])
   let replaceOptions!: (options: DialogSelectOption<string>[]) => 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<string>[])
 
     return (
       <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({}) }}>
               <ToastProvider>
                 <DialogProvider>
@@ -142,8 +133,8 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
                 </DialogProvider>
               </ToastProvider>
             </ThemeProvider>
-          </ConfigProvider>
-        </OpencodeKeymapProvider>
+          </Keymap.Provider>
+        </ConfigProvider>
       </TestTuiContexts>
     )
   }

+ 6 - 13
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 (
       <TestTuiContexts
         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)}>
                 <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
                   <ToastProvider>
@@ -82,8 +75,8 @@ async function mountForm(root: string, width = 80) {
                   </ToastProvider>
                 </ThemeProvider>
               </ClientProvider>
-            </ConfigProvider>
-          </OpencodeKeymapProvider>
+            </Keymap.Provider>
+          </ConfigProvider>
         </ClipboardProvider>
       </TestTuiContexts>
     )

+ 48 - 3
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",