diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index df71fd77e..beb39b355 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -250,6 +250,18 @@ export const SettingsGeneral: Component = () => { )} + + +
+ settings.general.setShowReasoningSummaries(checked)} + /> +
+
) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index fbcd0a851..d279a7f32 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -22,6 +22,7 @@ export interface Settings { general: { autoSave: boolean releaseNotes: boolean + showReasoningSummaries: boolean } updates: { startup: boolean @@ -42,6 +43,7 @@ const defaultSettings: Settings = { general: { autoSave: true, releaseNotes: true, + showReasoningSummaries: false, }, updates: { startup: true, @@ -120,6 +122,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setReleaseNotes(value: boolean) { setStore("general", "releaseNotes", value) }, + showReasoningSummaries: withFallback( + () => store.general?.showReasoningSummaries, + defaultSettings.general.showReasoningSummaries, + ), + setShowReasoningSummaries(value: boolean) { + setStore("general", "showReasoningSummaries", value) + }, }, updates: { startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8837dcbad..a8c27cc63 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -610,6 +610,8 @@ export const dict = { "settings.general.row.theme.description": "Customise how OpenCode is themed.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", + "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", + "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.wayland.title": "Use native Wayland", "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.", diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 567ef5fc8..6ac89a3a7 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -14,6 +14,7 @@ import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/ import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" @@ -80,6 +81,7 @@ export function MessageTimeline(props: { const navigate = useNavigate() const sdk = useSDK() const sync = useSync() + const settings = useSettings() const dialog = useDialog() const language = useLanguage() @@ -535,6 +537,7 @@ export function MessageTimeline(props: { sessionID={sessionID() ?? ""} messageID={message.id} lastUserMessageID={props.lastUserMessageID} + showReasoningSummaries={settings.general.showReasoningSummaries()} classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 6421985e0..828ddbe87 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -96,6 +96,7 @@ export interface MessageProps { parts: PartType[] showAssistantCopyPartID?: string | null interrupted?: boolean + showReasoningSummaries?: boolean } export interface MessagePartProps { @@ -264,14 +265,14 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -function renderable(part: PartType) { +function renderable(part: PartType, showReasoningSummaries = true) { if (part.type === "tool") { if (HIDDEN_TOOLS.has(part.tool)) return false if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running" return true } if (part.type === "text") return !!part.text?.trim() - if (part.type === "reasoning") return !!part.text?.trim() + if (part.type === "reasoning") return showReasoningSummaries && !!part.text?.trim() return !!PART_MAPPING[part.type] } @@ -280,6 +281,7 @@ export function AssistantParts(props: { showAssistantCopyPartID?: string | null turnDurationMs?: number working?: boolean + showReasoningSummaries?: boolean }) { const data = useData() const emptyParts: PartType[] = [] @@ -300,7 +302,7 @@ export function AssistantParts(props: { const parts = props.messages.flatMap((message) => list(data.store.part?.[message.id], emptyParts) - .filter(renderable) + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) .map((part) => ({ message, part })), ) @@ -480,6 +482,7 @@ export function Message(props: MessageProps) { message={assistantMessage() as AssistantMessage} parts={props.parts} showAssistantCopyPartID={props.showAssistantCopyPartID} + showReasoningSummaries={props.showReasoningSummaries} /> )} @@ -491,6 +494,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage parts: PartType[] showAssistantCopyPartID?: string | null + showReasoningSummaries?: boolean }) { const grouped = createMemo(() => { const keys: string[] = [] @@ -519,7 +523,7 @@ export function AssistantMessageDisplay(props: { } parts.forEach((part, index) => { - if (!renderable(part)) return + if (!renderable(part, props.showReasoningSummaries ?? true)) return if (isContextGroupTool(part)) { if (start < 0) start = index diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index bf1258d2e..9639e6635 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -41,6 +41,8 @@ display: flex; align-items: center; gap: 8px; + width: 100%; + min-width: 0; color: var(--text-weak); font-family: var(--font-family-sans); font-size: var(--font-size-base); @@ -52,6 +54,16 @@ width: 16px; height: 16px; } + + [data-slot="session-turn-thinking-heading"] { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-weaker); + font-weight: var(--font-weight-regular); + } } .error-card { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index aa2769280..33e72fb1e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -6,7 +6,7 @@ import { Binary } from "@opencode-ai/util/binary" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" -import { AssistantParts, Message } from "./message-part" +import { AssistantParts, Message, PART_MAPPING } from "./message-part" import { Card } from "./card" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" @@ -83,15 +83,55 @@ function list(value: T[] | undefined | null, fallback: T[]) { const hidden = new Set(["todowrite", "todoread"]) -function visible(part: PartType) { +function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { - if (hidden.has(part.tool)) return false - if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running" - return true + if (hidden.has(part.tool)) return + if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return + return "visible" as const + } + if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined + if (part.type === "reasoning") { + if (showReasoningSummaries) return "visible" as const + return + } + if (PART_MAPPING[part.type]) return "visible" as const + return +} + +function clean(value: string) { + return value + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") + .replace(/[*_~]+/g, "") + .trim() +} + +function heading(text: string) { + const markdown = text.replace(/\r\n?/g, "\n") + + const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i) + if (html?.[1]) { + const value = clean(html[1].replace(/<[^>]+>/g, " ")) + if (value) return value + } + + const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m) + if (atx?.[1]) { + const value = clean(atx[1]) + if (value) return value + } + + const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m) + if (setext?.[1]) { + const value = clean(setext[1]) + if (value) return value + } + + const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m) + if (strong?.[1]) { + const value = clean(strong[1]) + if (value) return value } - if (part.type === "text") return !!part.text?.trim() - if (part.type === "reasoning") return !!part.text?.trim() - return false } export function SessionTurn( @@ -99,6 +139,7 @@ export function SessionTurn( sessionID: string messageID: string lastUserMessageID?: string + showReasoningSummaries?: boolean onUserInteracted?: () => void classes?: { root?: string @@ -242,6 +283,7 @@ export function SessionTurn( const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) const working = createMemo(() => status().type !== "idle" && isLastUserMessage()) + const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) const assistantCopyPartID = createMemo(() => { if (working()) return null @@ -265,9 +307,33 @@ export function SessionTurn( const assistantVisible = createMemo(() => assistantMessages().reduce((count, message) => { const parts = list(data.store.part?.[message.id], emptyParts) - return count + parts.filter(visible).length + return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length }, 0), ) + const assistantTailVisible = createMemo(() => + assistantMessages() + .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) + .flatMap((part) => { + if (partState(part, showReasoningSummaries()) !== "visible") return [] + if (part.type === "text") return ["text" as const] + return ["other" as const] + }) + .at(-1), + ) + const reasoningHeading = createMemo(() => + assistantMessages() + .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) + .filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning") + .map((part) => heading(part.text)) + .filter((text): text is string => !!text) + .at(-1), + ) + const showThinking = createMemo(() => { + if (!working() || !!error()) return false + if (showReasoningSummaries()) return assistantVisible() === 0 + if (assistantTailVisible() === "text") return false + return true + }) const autoScroll = createAutoScroll({ working, @@ -295,11 +361,6 @@ export function SessionTurn(
- -
- -
-
0}>
+ +
+ + + {(text) => {text()}} + +
+
0 && !working()}>