Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions scripts/tool-gallery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}'
Expand Down
93 changes: 93 additions & 0 deletions src/__tests__/thinkingPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -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)')
})
})
14 changes: 11 additions & 3 deletions src/__tests__/toolBrief.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}
}
Expand Down
14 changes: 12 additions & 2 deletions src/app/useMainApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'
Expand Down Expand Up @@ -403,6 +411,7 @@ export function useMainApp(gw: GatewayClient) {
}),
virtualRows[index]!.msg
),
thinkingExpanded: thinkingDetailsExpanded,
thinkingVisible: thinkingDetailsVisible,
toolsExpanded: toolsDetailsExpanded,
toolsVisible: toolsDetailsVisible,
Expand All @@ -413,6 +422,7 @@ export function useMainApp(gw: GatewayClient) {
cols,
detailsVisible,
firstUserIdx,
thinkingDetailsExpanded,
thinkingDetailsVisible,
toolsDetailsExpanded,
toolsDetailsVisible,
Expand Down
30 changes: 28 additions & 2 deletions src/components/thinking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../lib/subagentTree.js'
import {
boundedLiveRenderText,
cleanThinkingText,
compactPreview,
estimateTokensRough,
fmtK,
Expand Down Expand Up @@ -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
}
Expand All @@ -702,9 +712,17 @@ export const Thinking = memo(function Thinking({
</Text>
))
) : (
<Text color={t.color.muted} wrap="truncate-end">
// 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.
<Text color={t.color.muted} wrap="wrap-trim">
{preview}
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
{elided ? (
<Text color={t.color.muted} dimColor>
{' (ctrl+o to expand)'}
</Text>
) : null}
</Text>
)
) : (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1111,7 +1137,7 @@ export const ToolTrail = memo(function ToolTrail({
<Thinking
active={reasoningActive}
branch="last"
mode="full"
mode={thinkingExpanded ? 'full' : 'truncated'}
rails={rails}
reasoning={busy ? reasoning : cot}
streaming={busy && reasoningStreaming}
Expand Down
16 changes: 16 additions & 0 deletions src/harness/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1982,6 +1982,22 @@ export class HarnessGatewayClient extends GatewayClient {
}
}

// `/reasoning show|hide` is a TUI-local display toggle — no harness
// service owns it, and returning `{}` (the catch-all below) left the
// command silently doing nothing: the caller only flips its state when
// a value comes back. Echoing it makes an advertised command work.
// The reasoning EFFORT levels this command also accepts belong to the
// harness and keep falling through to `/effort`.
if (String(p.key ?? '') === 'reasoning') {
const value = String(p.value ?? '')
.trim()
.toLowerCase()

if (value === 'show' || value === 'hide') {
return Promise.resolve({ ok: true, value } as T)
}
}

if (String(p.key ?? '') === 'model') {
return this.applyModelSwitch(String(p.value ?? '')).then(r => r as T)
}
Expand Down
15 changes: 13 additions & 2 deletions src/lib/virtualHeights.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -180,6 +181,7 @@ export const estimatedMsgHeight = (
compact,
details,
leadGap = false,
thinkingExpanded = false,
thinkingVisible = details,
toolsExpanded = false,
toolsVisible = details,
Expand All @@ -189,6 +191,7 @@ export const estimatedMsgHeight = (
compact: boolean
details: boolean
leadGap?: boolean
thinkingExpanded?: boolean
thinkingVisible?: boolean
toolsExpanded?: boolean
toolsVisible?: boolean
Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions test/e2e/probe-llm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
Loading