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' }