Browse Source

Thinking & tool call visibility settings for `/copy` and `/export` (#6243)

Co-authored-by: Aiden Cline <[email protected]>
rektide 1 month ago
parent
commit
7ea0d37ee3

+ 39 - 12
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -52,7 +52,6 @@ import { DialogMessage } from "./dialog-message"
 import type { PromptInfo } from "../../component/prompt/history"
 import { iife } from "@/util/iife"
 import { DialogConfirm } from "@tui/ui/dialog-confirm"
-import { DialogPrompt } from "@tui/ui/dialog-prompt"
 import { DialogTimeline } from "./dialog-timeline"
 import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
 import { DialogSessionRename } from "../../component/dialog-session-rename"
@@ -67,7 +66,7 @@ import stripAnsi from "strip-ansi"
 import { Footer } from "./footer.tsx"
 import { usePromptRef } from "../../context/prompt"
 import { Filesystem } from "@/util/filesystem"
-import { DialogSubagent } from "./dialog-subagent.tsx"
+import { DialogExportOptions } from "../../ui/dialog-export-options"
 
 addDefaultParsers(parsers.parsers)
 
@@ -784,8 +783,22 @@ export function Session() {
             for (const part of parts) {
               if (part.type === "text" && !part.synthetic) {
                 transcript += `${part.text}\n\n`
+              } else if (part.type === "reasoning") {
+                if (showThinking()) {
+                  transcript += `_Thinking:_\n\n${part.text}\n\n`
+                }
               } else if (part.type === "tool") {
-                transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n`
+                transcript += `\`\`\`\nTool: ${part.tool}\n`
+                if (showDetails() && part.state.input) {
+                  transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
+                }
+                if (showDetails() && part.state.status === "completed" && part.state.output) {
+                  transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
+                }
+                if (showDetails() && part.state.status === "error" && part.state.error) {
+                  transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
+                }
+                transcript += `\n\`\`\`\n\n`
               }
             }
 
@@ -812,6 +825,14 @@ export function Session() {
           const sessionData = session()
           const sessionMessages = messages()
 
+          const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
+
+          const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails())
+
+          if (options === null) return
+
+          const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options
+
           let transcript = `# ${sessionData.title}\n\n`
           transcript += `**Session ID:** ${sessionData.id}\n`
           transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
@@ -826,22 +847,28 @@ export function Session() {
             for (const part of parts) {
               if (part.type === "text" && !part.synthetic) {
                 transcript += `${part.text}\n\n`
+              } else if (part.type === "reasoning") {
+                if (includeThinking) {
+                  transcript += `_Thinking:_\n\n${part.text}\n\n`
+                }
               } else if (part.type === "tool") {
-                transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n`
+                transcript += `\`\`\`\nTool: ${part.tool}\n`
+                if (includeToolDetails && part.state.input) {
+                  transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
+                }
+                if (includeToolDetails && part.state.status === "completed" && part.state.output) {
+                  transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
+                }
+                if (includeToolDetails && part.state.status === "error" && part.state.error) {
+                  transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
+                }
+                transcript += `\n\`\`\`\n\n`
               }
             }
 
             transcript += `---\n\n`
           }
 
-          // Prompt for optional filename
-          const customFilename = await DialogPrompt.show(dialog, "Export filename", {
-            value: `session-${sessionData.id.slice(0, 8)}.md`,
-          })
-
-          // Cancel if user pressed escape
-          if (customFilename === null) return
-
           // Save to file in current working directory
           const exportDir = process.cwd()
           const filename = customFilename.trim()

+ 148 - 0
packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx

@@ -0,0 +1,148 @@
+import { TextareaRenderable, TextAttributes } from "@opentui/core"
+import { useTheme } from "../context/theme"
+import { useDialog, type DialogContext } from "./dialog"
+import { createStore } from "solid-js/store"
+import { onMount, Show, type JSX } from "solid-js"
+import { useKeyboard } from "@opentui/solid"
+
+export type DialogExportOptionsProps = {
+  defaultFilename: string
+  defaultThinking: boolean
+  defaultToolDetails: boolean
+  onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void
+  onCancel?: () => void
+}
+
+export function DialogExportOptions(props: DialogExportOptionsProps) {
+  const dialog = useDialog()
+  const { theme } = useTheme()
+  let textarea: TextareaRenderable
+  const [store, setStore] = createStore({
+    thinking: props.defaultThinking,
+    toolDetails: props.defaultToolDetails,
+    active: "filename" as "filename" | "thinking" | "toolDetails",
+  })
+
+  useKeyboard((evt) => {
+    if (evt.name === "return") {
+      props.onConfirm?.({
+        filename: textarea.plainText,
+        thinking: store.thinking,
+        toolDetails: store.toolDetails,
+      })
+    }
+    if (evt.name === "tab") {
+      const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"]
+      const currentIndex = order.indexOf(store.active)
+      const nextIndex = (currentIndex + 1) % order.length
+      setStore("active", order[nextIndex])
+      evt.preventDefault()
+    }
+    if (evt.name === "space") {
+      if (store.active === "thinking") setStore("thinking", !store.thinking)
+      if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
+      evt.preventDefault()
+    }
+  })
+
+  onMount(() => {
+    dialog.setSize("medium")
+    setTimeout(() => {
+      textarea.focus()
+    }, 1)
+    textarea.gotoLineEnd()
+  })
+
+  return (
+    <box paddingLeft={2} paddingRight={2} gap={1}>
+      <box flexDirection="row" justifyContent="space-between">
+        <text attributes={TextAttributes.BOLD} fg={theme.text}>
+          Export Options
+        </text>
+        <text fg={theme.textMuted}>esc</text>
+      </box>
+      <box gap={1}>
+        <box>
+          <text fg={theme.text}>Filename:</text>
+        </box>
+        <textarea
+          onSubmit={() => {
+            props.onConfirm?.({
+              filename: textarea.plainText,
+              thinking: store.thinking,
+              toolDetails: store.toolDetails,
+            })
+          }}
+          height={3}
+          keyBindings={[{ name: "return", action: "submit" }]}
+          ref={(val: TextareaRenderable) => (textarea = val)}
+          initialValue={props.defaultFilename}
+          placeholder="Enter filename"
+          textColor={theme.text}
+          focusedTextColor={theme.text}
+          cursorColor={theme.text}
+        />
+      </box>
+      <box flexDirection="column">
+        <box
+          flexDirection="row"
+          gap={2}
+          paddingLeft={1}
+          backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined}
+          onMouseUp={() => setStore("active", "thinking")}
+        >
+          <text fg={store.active === "thinking" ? theme.primary : theme.textMuted}>
+            {store.thinking ? "[x]" : "[ ]"}
+          </text>
+          <text fg={store.active === "thinking" ? theme.primary : theme.text}>Include thinking</text>
+        </box>
+        <box
+          flexDirection="row"
+          gap={2}
+          paddingLeft={1}
+          backgroundColor={store.active === "toolDetails" ? theme.backgroundElement : undefined}
+          onMouseUp={() => setStore("active", "toolDetails")}
+        >
+          <text fg={store.active === "toolDetails" ? theme.primary : theme.textMuted}>
+            {store.toolDetails ? "[x]" : "[ ]"}
+          </text>
+          <text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
+        </box>
+      </box>
+      <Show when={store.active !== "filename"}>
+        <text fg={theme.textMuted} paddingBottom={1}>
+          Press <span style={{ fg: theme.text }}>space</span> to toggle, <span style={{ fg: theme.text }}>return</span>{" "}
+          to confirm
+        </text>
+      </Show>
+      <Show when={store.active === "filename"}>
+        <text fg={theme.textMuted} paddingBottom={1}>
+          Press <span style={{ fg: theme.text }}>return</span> to confirm, <span style={{ fg: theme.text }}>tab</span>{" "}
+          for options
+        </text>
+      </Show>
+    </box>
+  )
+}
+
+DialogExportOptions.show = (
+  dialog: DialogContext,
+  defaultFilename: string,
+  defaultThinking: boolean,
+  defaultToolDetails: boolean,
+) => {
+  return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => {
+    dialog.replace(
+      () => (
+        <DialogExportOptions
+          defaultFilename={defaultFilename}
+          defaultThinking={defaultThinking}
+          defaultToolDetails={defaultToolDetails}
+          onConfirm={(options) => resolve(options)}
+          onCancel={() => resolve(null)}
+        />
+      ),
+      () => resolve(null),
+    )
+  })
+}