Skip to content

Commit 133b0d7

Browse files
committed
feat(tui): single-line tail preview in minimal thinking mode
1 parent bddc19e commit 133b0d7

2 files changed

Lines changed: 91 additions & 93 deletions

File tree

packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -390,49 +390,46 @@ function AssistantReasoning(props: {
390390
}) {
391391
const { theme } = useTheme()
392392
const thinking = useThinkingMode()
393-
const [userExpanded, setUserExpanded] = createSignal<boolean | undefined>(undefined)
394-
const [autoFolded, setAutoFolded] = createSignal(false)
393+
const dimensions = useTerminalDimensions()
394+
const [expanded, setExpanded] = createSignal(false)
395+
const [graceElapsed, setGraceElapsed] = createSignal(false)
395396
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
396-
// v2 reasoning parts have no `time.end` (see SessionMessageAssistantReasoning
397-
// in the v2 SDK), so collapse on parent-message completion. Coarser than
398-
// the main session view but the best signal we have here.
399-
const collapsible = createMemo(() => thinking.mode() === "minimal" && props.completedAt() !== undefined)
397+
const inMinimal = createMemo(() => thinking.mode() === "minimal")
398+
// v2 reasoning parts have no `time.end`, so we settle on parent-message
399+
// completion. Coarser than the main session view but the best signal here.
400+
const isDone = createMemo(() => props.completedAt() !== undefined)
401+
const tailPreview = createMemo(() => {
402+
const flat = content().replace(/\s+/g, " ").trim()
403+
// Budget for the v2 view's container padding (2) + our paddingLeft (3) +
404+
// "▼ Thinking: " (12) + a little slack.
405+
const max = Math.max(20, dimensions().width - 20)
406+
return Locale.truncateLeft(flat, max)
407+
})
400408

401409
createEffect(() => {
402-
if (!collapsible()) {
403-
setAutoFolded(false)
404-
return
405-
}
406-
if (userExpanded() !== undefined) return
407-
if (autoFolded()) return
410+
if (!inMinimal()) return
411+
if (!isDone()) return
412+
if (graceElapsed()) return
408413
const completed = props.completedAt()
409414
if (completed === undefined) return
410415
const remaining = MINIMAL_AUTO_COLLAPSE_MS - (Date.now() - completed)
411416
if (remaining <= 0) {
412-
setAutoFolded(true)
417+
setGraceElapsed(true)
413418
return
414419
}
415-
const timer = setTimeout(() => setAutoFolded(true), remaining)
420+
const timer = setTimeout(() => setGraceElapsed(true), remaining)
416421
onCleanup(() => clearTimeout(timer))
417422
})
418423

419-
const expanded = createMemo(() => {
420-
if (!collapsible()) return true
421-
const choice = userExpanded()
422-
if (choice !== undefined) return choice
423-
return !autoFolded()
424-
})
425-
const collapsed = createMemo(() => collapsible() && !expanded())
426424
const toggle = () => {
427-
if (!collapsible()) return
428-
setUserExpanded(!expanded())
425+
if (!inMinimal()) return
426+
setExpanded((prev) => !prev)
429427
}
430428

431429
return (
432430
<Show when={content() && thinking.mode() !== "hide"}>
433-
<Show
434-
when={collapsed()}
435-
fallback={
431+
<Switch>
432+
<Match when={!inMinimal() || expanded()}>
436433
<box
437434
paddingLeft={2}
438435
marginTop={1}
@@ -448,24 +445,27 @@ function AssistantReasoning(props: {
448445
drawUnstyledText={false}
449446
streaming={true}
450447
syntaxStyle={props.subtleSyntax}
451-
content={(collapsible() ? "▼ " : "") + "_Thinking:_ " + content()}
448+
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
452449
conceal={true}
453450
fg={theme.textMuted}
454451
/>
455452
</box>
456-
}
457-
>
458-
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
459-
<code
460-
filetype="markdown"
461-
drawUnstyledText={false}
462-
syntaxStyle={props.subtleSyntax}
463-
content={"▶ _Thought_"}
464-
conceal={true}
465-
fg={theme.textMuted}
466-
/>
467-
</box>
468-
</Show>
453+
</Match>
454+
<Match when={isDone() && graceElapsed()}>
455+
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
456+
<text fg={theme.textMuted} wrapMode="none">
457+
▶ Thought
458+
</text>
459+
</box>
460+
</Match>
461+
<Match when={true}>
462+
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
463+
<text fg={theme.textMuted} wrapMode="none">
464+
{"▼ Thinking: " + tailPreview()}
465+
</text>
466+
</box>
467+
</Match>
468+
</Switch>
469469
</Show>
470470
)
471471
}

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 51 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,70 +1513,63 @@ const PART_MAPPING = {
15131513
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
15141514
const { theme, subtleSyntax } = useTheme()
15151515
const ctx = use()
1516-
// `userExpanded` is the user's explicit choice. `undefined` means "no choice
1517-
// yet, follow the auto behavior". Once they click, it pins to true/false.
1518-
const [userExpanded, setUserExpanded] = createSignal<boolean | undefined>(undefined)
1519-
// Flips to true after the grace period elapses post-finalization.
1520-
const [autoFolded, setAutoFolded] = createSignal(false)
1516+
// In minimal mode the part stays a single line throughout — closed by default
1517+
// so the layout never shifts. Clicking expands into the full markdown block.
1518+
const [expanded, setExpanded] = createSignal(false)
1519+
// Flips after the grace window: the closed line switches from a live tail
1520+
// preview of the reasoning to the static "Thought for Xs" stamp.
1521+
const [graceElapsed, setGraceElapsed] = createSignal(false)
15211522

15221523
const content = createMemo(() => {
1523-
// Filter out redacted reasoning chunks from OpenRouter
1524-
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
1524+
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
15251525
return props.part.text.replace("[REDACTED]", "").trim()
15261526
})
15271527
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
1528-
// This flips independently of the parent message completing, so the
1529-
// collapse happens as soon as thinking ends — even while text/tools stream.
1528+
// This flips independently of the parent message completing.
15301529
const isDone = createMemo(() => props.part.time.end !== undefined)
1531-
const collapsible = createMemo(() => ctx.thinkingMode() === "minimal" && isDone())
1530+
const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal")
15321531
const duration = createMemo(() => {
15331532
const end = props.part.time.end
1534-
if (end === undefined) return 0
1535-
return Math.max(0, end - props.part.time.start)
1533+
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
1534+
})
1535+
// Live single-line preview of the streaming reasoning. Whitespace is
1536+
// collapsed so multi-paragraph reasoning shows as one flowing line, and we
1537+
// right-truncate so the most recent words remain visible as deltas arrive.
1538+
const tailPreview = createMemo(() => {
1539+
const flat = content().replace(/\s+/g, " ").trim()
1540+
// Budget for paddingLeft (3) + "▼ Thinking: " (12) + a couple cells slack.
1541+
const max = Math.max(20, ctx.width - 18)
1542+
return Locale.truncateLeft(flat, max)
15361543
})
15371544

1538-
// Schedule auto-collapse a short delay after reasoning finalizes in minimal
1539-
// mode, so the fold doesn't snap the instant streaming stops. A manual
1540-
// toggle or leaving minimal mode cancels the pending timer. For reasoning
1541-
// that finished before this component mounted (e.g. loading a past session)
1542-
// we skip the grace and collapse immediately.
1545+
// Grace timer: after finalization the closed view holds the tail preview
1546+
// for a beat, then switches to the timestamp. Historical sessions (loaded
1547+
// later) snap directly past the grace.
15431548
createEffect(() => {
1544-
if (!collapsible()) {
1545-
setAutoFolded(false)
1546-
return
1547-
}
1548-
if (userExpanded() !== undefined) return
1549-
if (autoFolded()) return
1549+
if (!inMinimal()) return
1550+
if (!isDone()) return
1551+
if (graceElapsed()) return
15501552
const end = props.part.time.end
15511553
if (end === undefined) return
15521554
const remaining = MINIMAL_AUTO_COLLAPSE_MS - (Date.now() - end)
15531555
if (remaining <= 0) {
1554-
setAutoFolded(true)
1556+
setGraceElapsed(true)
15551557
return
15561558
}
1557-
const timer = setTimeout(() => setAutoFolded(true), remaining)
1559+
const timer = setTimeout(() => setGraceElapsed(true), remaining)
15581560
onCleanup(() => clearTimeout(timer))
15591561
})
15601562

1561-
// Effective expansion: stay expanded while streaming and during grace; fold
1562-
// when auto-fold fires; user clicks override everything.
1563-
const expanded = createMemo(() => {
1564-
if (!collapsible()) return true
1565-
const choice = userExpanded()
1566-
if (choice !== undefined) return choice
1567-
return !autoFolded()
1568-
})
1569-
const collapsed = createMemo(() => collapsible() && !expanded())
15701563
const toggle = () => {
1571-
if (!collapsible()) return
1572-
setUserExpanded(!expanded())
1564+
if (!inMinimal()) return
1565+
setExpanded((prev) => !prev)
15731566
}
15741567

15751568
return (
15761569
<Show when={content() && ctx.thinkingMode() !== "hide"}>
1577-
<Show
1578-
when={collapsed()}
1579-
fallback={
1570+
<Switch>
1571+
<Match when={!inMinimal() || expanded()}>
1572+
{/* Full markdown block: `show` mode, or `minimal` after the user clicks open. */}
15801573
<box
15811574
id={"text-" + props.part.id}
15821575
paddingLeft={2}
@@ -1592,24 +1585,29 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
15921585
drawUnstyledText={false}
15931586
streaming={true}
15941587
syntaxStyle={subtleSyntax()}
1595-
content={(collapsible() ? "▼ " : "") + "_Thinking:_ " + content()}
1588+
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
15961589
conceal={ctx.conceal()}
15971590
fg={theme.textMuted}
15981591
/>
15991592
</box>
1600-
}
1601-
>
1602-
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
1603-
<code
1604-
filetype="markdown"
1605-
drawUnstyledText={false}
1606-
syntaxStyle={subtleSyntax()}
1607-
content={"▶ _Thought for " + Locale.duration(duration()) + "_"}
1608-
conceal={ctx.conceal()}
1609-
fg={theme.textMuted}
1610-
/>
1611-
</box>
1612-
</Show>
1593+
</Match>
1594+
<Match when={isDone() && graceElapsed()}>
1595+
{/* Settled: timestamp. */}
1596+
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
1597+
<text fg={theme.textMuted} wrapMode="none">
1598+
{"▶ Thought for " + Locale.duration(duration())}
1599+
</text>
1600+
</box>
1601+
</Match>
1602+
<Match when={true}>
1603+
{/* Still streaming or inside the grace window: live tail preview. */}
1604+
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
1605+
<text fg={theme.textMuted} wrapMode="none">
1606+
{"▼ Thinking: " + tailPreview()}
1607+
</text>
1608+
</box>
1609+
</Match>
1610+
</Switch>
16131611
</Show>
16141612
)
16151613
}

0 commit comments

Comments
 (0)