From be1bf48bde217e0e39ba036c3898323f824758c3 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Sun, 23 Aug 2026 00:11:08 -0700 Subject: [PATCH] fix(thinking): a line or two of reasoning, not the whole chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every answer arrived under its turn's entire chain of thought — hundreds of lines of it, `[showing live tail; omitted 242 lines / 11.5k chars]` and then the tail, pushing the actual reply off the screen. The panel rendered `mode="full"` unconditionally. The `thinking` section defaults to `expanded`, but that default means "let the stream be VISIBLE" — it is what `/reasoning show` sets too — and reading it as "print the whole chain" is what produced the wall. Reasoning now takes the GLOBAL details mode alone: open by default at a line or two, with the rest one ctrl+o away. ∴ Thinking… ~1.3k tokens reasoning line 1: weighing the options and writing enough of them to fill a screen reasoning line 2: weighing the … (ctrl+o to expand) The hint only appears when the preview is actually standing in for more, compared against the same whitespace-collapsed form it is cut from. The preview wraps rather than truncating at the terminal edge, so the half that was kept is not then thrown away. Deliberately NOT keyed on the section pin the way tools are: `/reasoning show` sets `thinking: 'expanded'` to make the stream visible, and treating that as a request for the full chain would put the wall straight back. The height estimator learned the same rule — it was charging every reasoning-heavy turn hundreds of rows it no longer paints, which is exactly the drift that makes the scrollbar jump. Fixes `/reasoning show|hide` along the way: `config.set` returned `{}` for it, and the caller only flips its state when a value comes back, so an advertised command silently did nothing. It is a TUI-local display toggle with no harness service behind it, like `logoColor`; the reasoning EFFORT levels the same command accepts still belong to the harness and keep falling through. Co-Authored-By: Claude Opus 5 --- package-lock.json | 4 +- package.json | 2 +- scripts/tool-gallery.py | 5 ++ src/__tests__/thinkingPreview.test.tsx | 93 ++++++++++++++++++++++++++ src/__tests__/toolBrief.test.ts | 14 +++- src/app/useMainApp.ts | 14 +++- src/components/thinking.tsx | 30 ++++++++- src/harness/client.ts | 16 +++++ src/lib/virtualHeights.ts | 15 ++++- test/e2e/probe-llm.mjs | 10 +++ 10 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/thinkingPreview.test.tsx diff --git a/package-lock.json b/package-lock.json index fe22028..860a4b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-cctui", - "version": "0.3.16", + "version": "0.3.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-cctui", - "version": "0.3.16", + "version": "0.3.17", "license": "MIT", "dependencies": { "@dsh-cctui/ink": "file:./packages/dsh-cctui-ink", diff --git a/package.json b/package.json index b8d1184..2e0025a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-cctui", - "version": "0.3.16", + "version": "0.3.17", "description": "Claude-Code-style terminal UI for deepseek-harness, ported from clawcodex ui-tui", "license": "MIT", "repository": { diff --git a/scripts/tool-gallery.py b/scripts/tool-gallery.py index adc2d64..65d5278 100755 --- a/scripts/tool-gallery.py +++ b/scripts/tool-gallery.py @@ -123,6 +123,11 @@ ], "job_list": 'PROBE job_list {}', "list_agents": 'PROBE list_agents {}', + # a long reasoning stream, for the transcript's thinking block + "thinking": [ + "/reasoning show", + "THINK about how the transcript renders a long chain of thought", + ], # a RUN of calls, which is the only way the collapsed brief reads right "mixed_run": ( 'PROBE read {"file_path": "src/domain/usage.ts"}' diff --git a/src/__tests__/thinkingPreview.test.tsx b/src/__tests__/thinkingPreview.test.tsx new file mode 100644 index 0000000..aa583d0 --- /dev/null +++ b/src/__tests__/thinkingPreview.test.tsx @@ -0,0 +1,93 @@ +/** + * The transcript's reasoning block: a line or two by default, the whole chain + * of thought behind ctrl+o. + * + * The thinking section defaults to `expanded` so the stream is VISIBLE, and + * reading that default as a request for the full chain printed a turn's entire + * reasoning — hundreds of lines — above every answer. + */ +import { PassThrough } from 'node:stream' + +import { renderSync } from '@dsh-cctui/ink' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +vi.hoisted(() => { + process.env.FORCE_COLOR = '0' + process.env.NO_COLOR = '1' +}) + +import { ToolTrail } from '../components/thinking.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' + +const REASONING = Array.from({ length: 60 }, (_, i) => `reasoning line ${i + 1}: weighing the options at length`).join( + '\n' +) + +const renderToString = (element: React.ReactElement): string => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', (chunk: Buffer) => { + output += chunk.toString() + }) + + const instance = renderSync(element, { + patchConsole: false, + stderr: stderr as never, + stdin: stdin as never, + stdout: stdout as never + }) + + instance.unmount() + instance.cleanup() + + return stripAnsi(output) +} + +const trail = (detailsMode: 'collapsed' | 'expanded') => + renderToString( + React.createElement(ToolTrail, { detailsMode, reasoning: REASONING, t: DEFAULT_THEME, trail: [] }) + ) + +describe('ToolTrail reasoning', () => { + it('shows a line or two by default, and says where the rest is', () => { + const out = trail('collapsed') + const body = out.split('∴ Thinking…')[1] ?? '' + + expect(out).toContain('∴ Thinking…') + expect(out).toContain('reasoning line 1') + expect(out).toContain('(ctrl+o to expand)') + // the tail is not printed above the answer + expect(out).not.toContain('reasoning line 40') + expect(body.split('\n').filter(line => line.trim()).length).toBeLessThanOrEqual(3) + }) + + it('gives the whole chain back under ctrl+o', () => { + const out = trail('expanded') + + expect(out).toContain('reasoning line 1') + expect(out).toContain('reasoning line 40') + expect(out).not.toContain('(ctrl+o to expand)') + }) + + it('offers no expand hint when the reasoning already fits', () => { + const out = renderToString( + React.createElement(ToolTrail, { + detailsMode: 'collapsed', + reasoning: 'one short thought', + t: DEFAULT_THEME, + trail: [] + }) + ) + + expect(out).toContain('one short thought') + expect(out).not.toContain('(ctrl+o to expand)') + }) +}) diff --git a/src/__tests__/toolBrief.test.ts b/src/__tests__/toolBrief.test.ts index 81befc3..b6058f8 100644 --- a/src/__tests__/toolBrief.test.ts +++ b/src/__tests__/toolBrief.test.ts @@ -575,9 +575,17 @@ describe('estimatedMsgHeight matches the painted trail', () => { }) it(`agrees on ${name} (expanded, cols=${cols})`, () => { - expect(estimatedMsgHeight(msg, cols, { compact: false, details: true, toolsExpanded: true })).toBe( - paintedRows(msg, 'expanded', cols) - ) + // ctrl+o expands BOTH — the flat per-call rows and the whole chain of + // thought — so the estimate has to be handed both, exactly as + // useMainApp derives them from the one global mode. + expect( + estimatedMsgHeight(msg, cols, { + compact: false, + details: true, + thinkingExpanded: true, + toolsExpanded: true + }) + ).toBe(paintedRows(msg, 'expanded', cols)) }) } } diff --git a/src/app/useMainApp.ts b/src/app/useMainApp.ts index 687dbcb..e00d894 100644 --- a/src/app/useMainApp.ts +++ b/src/app/useMainApp.ts @@ -346,6 +346,11 @@ export function useMainApp(gw: GatewayClient) { // collapsed brief, so the estimate and the paint must derive it the same way. const toolsDetailsExpanded = ui.detailsMode === 'expanded' || ui.sections?.tools === 'expanded' + // …and its reasoning twin. Deliberately the GLOBAL mode alone, with no + // section pin: `thinking: 'expanded'` means "let the stream be visible", not + // "print the whole chain of thought". ToolTrail derives it the same way. + const thinkingDetailsExpanded = ui.detailsMode === 'expanded' + const detailsLayoutKey = useMemo(() => { const thinking = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) const tools = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) @@ -356,8 +361,11 @@ export function useMainApp(gw: GatewayClient) { // `/details tools expanded` pin flips the layout without moving the global // mode, and the resolved section mode already reads 'expanded' by default — // so re-deriving it here would hand the pinned layout the brief's heights. - return `${thinking}:${tools}:${toolsDetailsExpanded ? 'x' : '-'}` - }, [toolsDetailsExpanded, ui.detailsMode, ui.detailsModeCommandOverride, ui.sections]) + // Reasoning swaps between a one-or-two-line preview and the whole chain, + // which is a far bigger height swing than the tool variants — it has to + // bucket the cache too. + return `${thinking}:${tools}:${toolsDetailsExpanded ? 'x' : '-'}:${thinkingDetailsExpanded ? 'x' : '-'}` + }, [thinkingDetailsExpanded, toolsDetailsExpanded, ui.detailsMode, ui.detailsModeCommandOverride, ui.sections]) const [thinkingDetailsMode, toolsDetailsMode] = detailsLayoutKey.split(':') const thinkingDetailsVisible = thinkingDetailsMode !== 'hidden' @@ -403,6 +411,7 @@ export function useMainApp(gw: GatewayClient) { }), virtualRows[index]!.msg ), + thinkingExpanded: thinkingDetailsExpanded, thinkingVisible: thinkingDetailsVisible, toolsExpanded: toolsDetailsExpanded, toolsVisible: toolsDetailsVisible, @@ -413,6 +422,7 @@ export function useMainApp(gw: GatewayClient) { cols, detailsVisible, firstUserIdx, + thinkingDetailsExpanded, thinkingDetailsVisible, toolsDetailsExpanded, toolsDetailsVisible, diff --git a/src/components/thinking.tsx b/src/components/thinking.tsx index c943b4a..b05211a 100644 --- a/src/components/thinking.tsx +++ b/src/components/thinking.tsx @@ -17,6 +17,7 @@ import { } from '../lib/subagentTree.js' import { boundedLiveRenderText, + cleanThinkingText, compactPreview, estimateTokensRough, fmtK, @@ -684,6 +685,15 @@ export const Thinking = memo(function Thinking({ const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview]) + // Whether the preview is standing in for more than it shows. Compared + // against the same whitespace-collapsed form the preview is cut from, so a + // reasoning block that fits says nothing and one that does not offers the key + // that opens it. + const elided = useMemo( + () => mode !== 'full' && cleanThinkingText(reasoning).replace(/\s+/g, ' ').trim().length > preview.length, + [mode, preview, reasoning] + ) + if (!preview && !active) { return null } @@ -702,9 +712,17 @@ export const Thinking = memo(function Thinking({ )) ) : ( - + // Wrapped, not truncate-end: the preview is cut to a length that + // lands in a line or two at any usual width, and hard-truncating it + // to exactly one would throw away the half that was kept. + {preview} + {elided ? ( + + {' (ctrl+o to expand)'} + + ) : null} ) ) : ( @@ -853,6 +871,14 @@ export const ToolTrail = memo(function ToolTrail({ // for both, or the estimate drifts from the paint. const toolsExpanded = detailsMode === 'expanded' || sections?.tools === 'expanded' + // Reasoning takes the GLOBAL mode alone, and deliberately not the section + // pin the way tools do. `thinking: 'expanded'` is what both the built-in + // default and `/reasoning show` set to mean "let the stream be VISIBLE"; + // reading it as "print the whole chain of thought" put a turn's entire + // reasoning — hundreds of lines — above every answer. So it stays open by + // default and shows a line or two, and ctrl+o is what asks for all of it. + const thinkingExpanded = detailsMode === 'expanded' + for (const [i, compactLine] of trail.entries()) { // Expanded details render the verbose sibling (full Args/Result blocks) // when the gateway retained raw output; '' means the compact line is @@ -1111,7 +1137,7 @@ export const ToolTrail = memo(function ToolTrail({ r as T) } diff --git a/src/lib/virtualHeights.ts b/src/lib/virtualHeights.ts index 666b9d7..a6615b3 100644 --- a/src/lib/virtualHeights.ts +++ b/src/lib/virtualHeights.ts @@ -1,9 +1,10 @@ import { TERMUX_TUI_MODE } from '../config/env.js' +import { THINKING_COT_MAX } from '../config/limits.js' import { briefCallOfTrailLine, briefRuns, briefText, countBriefTools } from '../domain/toolBrief.js' import type { Msg } from '../types.js' import { transcriptBodyWidth, transcriptTrailWidth } from './inputMetrics.js' -import { parseToolTrailResultLine } from './text.js' +import { parseToolTrailResultLine, thinkingPreview } from './text.js' const hashText = (text: string) => { let h = 5381 @@ -180,6 +181,7 @@ export const estimatedMsgHeight = ( compact, details, leadGap = false, + thinkingExpanded = false, thinkingVisible = details, toolsExpanded = false, toolsVisible = details, @@ -189,6 +191,7 @@ export const estimatedMsgHeight = ( compact: boolean details: boolean leadGap?: boolean + thinkingExpanded?: boolean thinkingVisible?: boolean toolsExpanded?: boolean toolsVisible?: boolean @@ -248,7 +251,15 @@ export const estimatedMsgHeight = ( // (Known gap, pre-dating the brief: `/details thinking collapsed` closes // the body while this still counts it. The panel is expanded by default, // so the common path is exact.) - const thinkingRows = hasVisibleThinking ? 1 + wrappedLines(msg.thinking ?? '', trailWidth - DETAIL_GUTTER) : 0 + // + // Unexpanded, the body is the whitespace-collapsed preview ToolTrail + // paints — a line or two — not the whole chain of thought. Counting the + // chain there over-charged a reasoning-heavy turn by hundreds of rows, + // which is exactly the kind of drift that makes the scrollbar jump. + const thinkingBody = thinkingExpanded + ? (msg.thinking ?? '') + : thinkingPreview(msg.thinking ?? '', 'truncated', THINKING_COT_MAX) + const thinkingRows = hasVisibleThinking ? 1 + wrappedLines(thinkingBody, trailWidth - DETAIL_GUTTER) : 0 h += toolRows + thinkingRows diff --git a/test/e2e/probe-llm.mjs b/test/e2e/probe-llm.mjs index 8794617..440682f 100644 --- a/test/e2e/probe-llm.mjs +++ b/test/e2e/probe-llm.mjs @@ -56,6 +56,16 @@ class ProbeAdapter extends LlmAdapter { return } + // `THINK` asks for a long reasoning stream, so the transcript's thinking + // block can be seen at the length a real turn produces rather than the two + // words a scripted reply would. + if (user.includes('THINK')) { + for (let i = 1; i <= 60; i++) { + yield { index: 0, text: `reasoning line ${i}: weighing the options and writing enough of them to fill a screen\n`, type: 'reasoning-delta' } + await new Promise(r => setTimeout(r, 5)) + } + } + const text = hasToolResult ? 'probe done.' : `echo: ${user}` yield { blockType: 'text', index: 0, type: 'block-start' } yield { index: 0, text, type: 'text-delta' }