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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,13 @@ as you and the shipped policy confines writes rather than reads.

## Highlights

- **Conversation loop**: streamed deltas render live; reasoning behind Ctrl+O/Ctrl+R density
toggles; busy verbs and spinners; Esc interrupts (Ctrl+C never kills the app).
- **Tools**: Claude-style `⏺ Tool(args)` / `⎿ result` trail, each result summarised the way
the original does it — `Read 3 lines`, `Found 37 files`, `Received 1.2KB (200 OK)`,
`Did 1 search in 2s` — with a failed call carrying the tool's own message and a running one
its own clock; write/edit diffs render as structured diff cards; sandbox-escalation
- **Conversation loop**: streamed deltas render live; reasoning shows a line or two with the
whole chain of thought behind Ctrl+O; busy verbs and spinners; Esc interrupts (Ctrl+C never
kills the app).
- **Tools**: Claude-style `⏺ Tool(args)` / `⎿ result` trail — every call keeps its own row
with the path it opened or the command it ran, over a few lines of what came back and
`… +N lines (ctrl+o to expand)`; a failed call carries the tool's own message and a running
one its own clock; write/edit diffs render as structured diff cards; sandbox-escalation
approvals pop the approval box (`1` approve / `2`+Enter deny); todo lists pin under the
busy line.
- **Subagents**: a delegation reads `⏺ Subagent(Review the diff)` / `⎿ Done (2 tool uses ·
Expand Down
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.17",
"version": "0.3.18",
"description": "Claude-Code-style terminal UI for deepseek-harness, ported from clawcodex ui-tui",
"license": "MIT",
"repository": {
Expand Down
14 changes: 7 additions & 7 deletions src/__tests__/expandResults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,21 +230,21 @@ describe('mergeToolShelfInto lockstep', () => {
// ── render + heights ─────────────────────────────────────────────────────────

describe('expanded rendering', () => {
const trail = ['Bash(seq 6) :: 1\n2\n3\n… +3 lines (ctrl+o to expand) ✓']
const trail = ['Bash(seq 6) :: 1\n2\n3\n… +3 lines ✓']
const verboseTrail = ['Bash(seq 6) (0.2s) :: Result:\n1\n2\n3\n4\n5\n6 ✓']

it('collapsed folds the call into the brief; expanded swaps in the full result', () => {
it('collapsed shows the head of the result; expanded swaps in all of it', () => {
const collapsed = stripAnsi(
renderToString(
React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail, verboseTrail })
)
)

// Collapsed is the brief line: the tally only — no call row, no result
// summary, and none of the verbose-only content.
expect(collapsed).toContain('Ran 1 shell command')
expect(collapsed).not.toContain('Bash(seq 6)')
expect(collapsed).not.toContain('+3 lines (ctrl+o to expand)')
// The call keeps its row and the head of what it returned, and names the
// key that shows the rest — but none of the verbose-only content.
expect(collapsed).toContain('Bash(seq 6)')
expect(collapsed).toContain('… +3 lines (ctrl+o to expand)')
expect(collapsed).not.toContain('Result:')
expect(collapsed).not.toContain('4')

const expanded = stripAnsi(
Expand Down
22 changes: 18 additions & 4 deletions src/__tests__/harnessClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ const TOOL_CARDS: Record<string, unknown> = {
],
truncated: false
},
read_long: {
card: 'read',
lines: Array.from({ length: 10 }, (_, i) => ({ number: i + 1, text: `l${i + 1}` })),
offset: 1,
path: 'src/big.ts',
totalLines: 10
},
read_window: {
card: 'read',
lines: [
Expand Down Expand Up @@ -436,15 +443,22 @@ describe('HarnessGatewayClient', () => {
return w.events.at(-1) as Extract<GatewayEvent, { type: 'tool.complete' }>
}

it('summarises a read as its line count, keeping the text for ctrl+o', async () => {
it('shows the head of the file it read, not a tally', async () => {
const done = await runTool('read_window', '{"file_path":"src/y.ts","offset":8}')

// upstream renders `⎿ Read 3 lines` and stops — the file's text is what the
// MODEL was handed, and repeating it inline would bury the turn.
expect(done.payload.result_text).toBe('Read 3 lines')
// a tally says a read happened without saying what it found; the row exists
// so a reader can follow along without opening the file themselves
expect(done.payload.result_text).toBe(' 8 import x\n 9 \n10 export const y = 1')
expect(done.payload.result_raw).toBe(' 8 import x\n 9 \n10 export const y = 1')
})

it('holds back a read longer than the row, and says how much', async () => {
const done = await runTool('read_long', '{"file_path":"src/big.ts"}')

expect(done.payload.result_text).toBe(' 1 l1\n 2 l2\n 3 l3\n 4 l4\n… +6 lines')
expect(done.payload.result_raw?.split('\n')).toHaveLength(10)
})

it('counts a path search in files', async () => {
const done = await runTool('glob', '{"pattern":"src/*.ts"}')

Expand Down
144 changes: 61 additions & 83 deletions src/__tests__/toolBrief.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,22 +210,27 @@ const renderToString = (element: React.ReactElement, columns = 100): string => {
return lastFrame(output)
}

describe('ToolTrail brief render', () => {
describe('ToolTrail flat render', () => {
const trail = [
buildToolTrailLine('Read', 'src/alpha.py', false, 'Read 8 lines'),
buildToolTrailLine('Read', 'src/beta.py', false, 'Read 8 lines'),
buildToolTrailLine('Read', 'src/alpha.py', false, ' 1 import os'),
buildToolTrailLine('Read', 'src/beta.py', false, ' 1 import sys'),
buildToolTrailLine('Bash', 'ls src', false, 'alpha.py\nbeta.py'),
buildToolTrailLine('Bash', 'echo hello', false, 'hello')
]

it('collapses the whole run to one summary line', () => {
it('keeps every call on its own row, with what it opened and what came back', () => {
// the path a read opened and the command a shell ran are the transcript's
// whole point; a tally in their place takes the one thing worth reading
const out = stripAnsi(
renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail }))
)

expect(out).toContain('Read 2 files, listed 1 directory, ran 1 shell command')
expect(out).not.toContain('Read(src/alpha.py)')
expect(out).not.toContain('hello')
expect(out).toContain('Read(src/alpha.py)')
expect(out).toContain('Read(src/beta.py)')
expect(out).toContain('Bash(ls src)')
expect(out).toContain('Bash(echo hello)')
expect(out).toContain('import os')
expect(out).toContain('hello')
})

it('runs a clock on a tool that is still going', () => {
Expand Down Expand Up @@ -265,29 +270,61 @@ describe('ToolTrail brief render', () => {
expect(out).not.toContain('(0s)')
})

it('puts every call back under ctrl+o', () => {
it('swaps in the fuller result under ctrl+o', () => {
const verboseTrail = ['', '', 'Bash(ls src) :: Result:\nalpha.py\nbeta.py\ngamma.py ✓', '']
const out = stripAnsi(
renderToString(React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail }))
renderToString(
React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail, verboseTrail })
)
)

expect(out).toContain('Read(src/alpha.py)')
expect(out).toContain('Read(src/beta.py)')
expect(out).toContain('Bash(echo hello)')
expect(out).not.toContain('Read 2 files,')
expect(out).toContain('gamma.py')
})

it('keeps a standalone edit visible while collapsed', () => {
const withEdit = [trail[0]!, buildToolTrailLine('Edit', 'src/gamma.py', false, 'Updated src/gamma.py')]
it('names the expand key on a body its card held part of back', () => {
const held = [buildToolTrailLine('Bash', 'ls src', false, 'alpha.py\nbeta.py\n… +8 lines')]
const verboseTrail = ['Bash(ls src) :: Result:\nalpha.py ✓']

const out = stripAnsi(
renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: withEdit }))
renderToString(
React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: held, verboseTrail })
)
)

expect(out).toContain('Read 1 file')
expect(out).toContain('… +8 lines (ctrl+o to expand)')
})

it('offers no expand key when there is nothing more to show', () => {
// a result the gateway kept no raw copy of expands to the same text, and
// pointing at a key that changes nothing is worse than silence
const held = [buildToolTrailLine('Bash', 'ls src', false, 'alpha.py\nbeta.py\n… +8 lines')]

const out = stripAnsi(
renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: held }))
)

expect(out).toContain('… +8 lines')
expect(out).not.toContain('(ctrl+o to expand)')
})

it('shows an edit and a failed call alongside the reads', () => {
const mixed = [
trail[0]!,
buildToolTrailLine('Edit', 'src/gamma.py', false, 'Updated src/gamma.py'),
buildToolTrailLine('Bash', 'cat missing.txt', true, 'Error: No such file or directory')
]

const out = stripAnsi(
renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: mixed }))
)

expect(out).toContain('Read(src/alpha.py)')
expect(out).toContain('Edit(src/gamma.py)')
expect(out).toContain('Bash(cat missing.txt)')
expect(out).toContain('Error: No such file or directory')
})

it('separates expanded blocks with a blank line', () => {
it('separates blocks with a blank line', () => {
const out = stripAnsi(
renderToString(React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail }))
)
Expand All @@ -311,66 +348,6 @@ describe('ToolTrail brief render', () => {

expect(out.split('\n').find(row => row.includes('⎿'))).toMatch(/^ {2}⎿ {2}\S/)
})

it('renders the brief under a two-column gutter, like the ⏺ rows', () => {
const out = stripAnsi(
renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail }))
)

const row = out.split('\n').find(line => line.includes('Read 2 files'))

expect(row).toMatch(/^ {2}Read 2 files/)
})

// WebSearch and friends are NOT part of the read/search band upstream folds:
// captured from Claude Code 2.1.228, a lone Web Search keeps its row and its
// `⎿ Did 1 search in 2s`. A tally would delete the only interesting part.
it('keeps a catch-all tool standalone with its result', () => {
const withSearch = [trail[0]!, buildToolTrailLine('WebSearch', 'rust 1.90', false, 'Did 1 search in 2s')]

const out = stripAnsi(
renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: withSearch }))
)

expect(out).toContain('WebSearch(rust 1.90)')
expect(out).toContain('Did 1 search in 2s')
expect(out).toContain('Read 1 file')
expect(out).not.toContain('called 1 tool')
})

it('breaks a failed call out of the brief so its error stays readable', () => {
const withError = [
trail[0]!,
buildToolTrailLine('Bash', 'cat missing.txt', true, 'Error: No such file or directory'),
trail[3]!
]

const out = stripAnsi(
renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: withError }))
)

expect(out).toContain('Bash(cat missing.txt)')
expect(out).toContain('Error: No such file or directory')
// The successes around it still fold.
expect(out).toContain('Read 1 file')
expect(out).toContain('Ran 1 shell command')
})

// Bold and faint share SGR close code 22, so a `dim` wrapper rewrites each
// bold tally's reset into `\e[2m` — faint re-opened, bold never cleared —
// and every column after the first tally paints bold. stripAnsi cannot see
// this, so assert on the bytes.
it('closes each bold tally without leaving the rest of the line bold', () => {
const out = renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail }))
const BOLD = '\u001b[1m'
const RESET_INTENSITY = '\u001b[22m'
const FAINT = '\u001b[2m'

expect(out).toContain(`${BOLD}2${RESET_INTENSITY}`)
// No faint is opened anywhere, so no tally's reset can be rewritten into
// one — bold cannot survive past the tally that opened it.
expect(out).not.toContain(FAINT)
})
})

// ── estimate vs paint ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -557,11 +534,12 @@ describe('estimatedMsgHeight matches the painted trail', () => {
const withDetails = estimatedMsgHeight(diff, 80, { compact: false, details: true })
const withoutDetails = estimatedMsgHeight(diff, 80, { compact: false, details: false })

// Turning details on costs the one brief row, plus the 2 the estimator
// charges for a `Response` separator this branch never paints (older
// divergence, left alone). The point is that it is 3 and not 4 — the
// details wrapper's marginBottom belongs to prose rows only.
expect(withDetails - withoutDetails).toBe(3)
// Turning details on costs the call's two rows (`⏺ Read(a.py)` over its
// `⎿` result), plus the 2 the estimator charges for a `Response`
// separator this branch never paints (older divergence, left alone).
// The point is that it is 4 and not 5 — the details wrapper's
// marginBottom belongs to prose rows only.
expect(withDetails - withoutDetails).toBe(4)
})
})

Expand Down
11 changes: 6 additions & 5 deletions src/__tests__/toolTranscript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,12 @@ describe('estimatedMsgHeight with multi-line tool details', () => {
expect(tall - short).toBe(2) // two extra detail rows
})

it('collapses to the one-row brief regardless of detail length', () => {
// The brief renders the tally alone, so a 3-line result costs no more
// rows than a 1-line one — the estimate has to agree or the scrollbar
// and topSpacer math drift against the paint.
expect(estimatedMsgHeight(base, 80, opts)).toBe(estimatedMsgHeight(single, 80, opts))
it('counts the same detail rows collapsed, since the call keeps its block', () => {
// Every call renders its own `⏺ …` + `⎿ …` block in both views; ctrl+o
// changes how much of the RESULT shows (the verbose sibling), not whether
// the call does. The estimate has to agree or the scrollbar and topSpacer
// math drift against the paint.
expect(estimatedMsgHeight(base, 80, opts) - estimatedMsgHeight(single, 80, opts)).toBe(2)
})
})

Expand Down
Loading
Loading