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
85 changes: 59 additions & 26 deletions ui-tui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { PermissionDialog } from './components/PermissionDialog.js'
import { SlashMenu } from './components/SlashMenu.js'
import { Spinner } from './components/Spinner.js'
import { StatusBar } from './components/StatusBar.js'
import { LiveTools, type LiveGroup } from './components/LiveTools.js'
import { READ_LIKE, TOOL_VERB, toolActivityLabel } from './toolMeta.js'
import { messageToEntries, streamDeltaText, type TranscriptEntry } from './sdkMessageAdapter.js'
import { matchSlash, resolveSlash } from './slashCommands.js'
import { parseProtocolMajor, SUPPORTED_PROTOCOL_MAJOR } from './protocol.js'
Expand Down Expand Up @@ -55,28 +57,6 @@ function streamTail(text: string, cols: number, maxLines: number): string {
return visual.slice(-maxLines).join('\n')
}

const TOOL_VERB: Record<string, { verb: string; noun: string }> = {
Read: { verb: 'Reading', noun: 'files' },
Edit: { verb: 'Editing', noun: 'files' },
Write: { verb: 'Writing', noun: 'files' },
MultiEdit: { verb: 'Editing', noun: 'files' },
Bash: { verb: 'Running', noun: 'commands' },
Grep: { verb: 'Searching', noun: '' },
Glob: { verb: 'Globbing', noun: '' },
WebFetch: { verb: 'Fetching', noun: '' },
WebSearch: { verb: 'Searching', noun: '' },
}

/** Live spinner label, e.g. "Reading 3 files" (collapsed count) or "Running git status". */
function toolActivityLabel(name: string | undefined, args: string | undefined, count: number): string {
const { verb, noun } = (name && TOOL_VERB[name]) || {
verb: name ? `Using ${name}` : 'Working',
noun: '',
}
if (count > 1 && noun) return `${verb} ${count} ${noun}`
const target = (args || '').split(/[\\/]/).pop() || args || ''
return target ? `${verb} ${target}` : verb
}

export function App({ transport, serverLabel }: Props): React.ReactElement {
const { exit } = useApp()
Expand All @@ -98,6 +78,11 @@ export function App({ transport, serverLabel }: Props): React.ReactElement {
const bannerAdded = useRef(false)
const [toolActivity, setToolActivity] = useState<string | null>(null)
const turnToolCounts = useRef<Record<string, number>>({})
// Live, in-place tool-progress block: Read-like calls collapse here (not into
// Static) until the round ends, then freeze into a committed summary.
const [liveTools, setLiveTools] = useState<LiveGroup[]>([])
const liveRef = useRef<LiveGroup[]>([])
const collapsedIds = useRef<Set<string>>(new Set())

const slashMatches = !input.includes(' ') ? matchSlash(input) : []
const slashOpen = slashMatches.length > 0 && permissions.length === 0
Expand All @@ -122,6 +107,34 @@ export function App({ transport, serverLabel }: Props): React.ReactElement {
setStream('')
}

const syncLive = () => setLiveTools([...liveRef.current])
const addLive = (name: string, args: string) => {
const g = liveRef.current.find((x) => x.name === name)
if (g) {
g.count += 1
g.current = args
} else {
liveRef.current.push({ name, count: 1, current: args })
}
syncLive()
}
/** Freeze the live read-groups into committed collapsed-summary entries. */
const takeLive = (): TranscriptEntry[] => {
const groups = liveRef.current
if (!groups.length) return []
liveRef.current = []
collapsedIds.current.clear()
syncLive()
return groups.map((g) => ({
id: `l${localSeq.current++}`,
kind: 'tool' as const,
text: '',
toolName: g.name,
argsText: g.current,
count: g.count,
}))
}

useEffect(() => {
const c = new DirectConnectClient(transport, {
onConnected: () => setConnected(true),
Expand Down Expand Up @@ -196,16 +209,29 @@ export function App({ transport, serverLabel }: Props): React.ReactElement {
}
const newEntries = messageToEntries(msg)
if (newEntries.length) {
// Update the live tool-progress label (collapses repeated tools, e.g.
// "Reading 3 files") as each tool call streams in.
const toCommit: TranscriptEntry[] = []
for (const e of newEntries) {
if (e.kind === 'tool') {
const verb = (e.toolName && TOOL_VERB[e.toolName]?.verb) || e.toolName || 'tool'
const n = (turnToolCounts.current[verb] = (turnToolCounts.current[verb] ?? 0) + 1)
setToolActivity(toolActivityLabel(e.toolName, e.argsText, n))
}
if (e.kind === 'tool' && READ_LIKE.has(e.toolName ?? '')) {
// Collapse into the live block (not Static); drop its result later.
if (e.toolUseId) collapsedIds.current.add(e.toolUseId)
addLive(e.toolName ?? 'tool', e.argsText ?? '')
} else if (
e.kind === 'toolResult' &&
(e.forToolUseIds?.length ?? 0) > 0 &&
(e.forToolUseIds ?? []).every((id) => collapsedIds.current.has(id))
) {
// Result for collapsed reads → drop (kept collapsed, like the original).
} else {
// Preserve order: freeze the live read-group before this entry.
toCommit.push(...takeLive(), e)
}
}
setEntries((prev) => [...prev, ...newEntries])
if (toCommit.length) setEntries((prev) => [...prev, ...toCommit])
}
},
})
Expand Down Expand Up @@ -323,6 +349,9 @@ export function App({ transport, serverLabel }: Props): React.ReactElement {
setBusy(true)
turnToolCounts.current = {}
setToolActivity(null)
liveRef.current = []
collapsedIds.current.clear()
setLiveTools([])
setTurnStartedAt(Date.now())
setInput('')
setSlashSel(0)
Expand Down Expand Up @@ -360,9 +389,13 @@ export function App({ transport, serverLabel }: Props): React.ReactElement {
</Box>
) : null}

{liveTools.length > 0 ? <LiveTools groups={liveTools} /> : null}

{busy && permissions.length === 0 ? (
<Box>
<Spinner startedAt={turnStartedAt} activity={toolActivity} />
{/* Spinner shows the activity for non-read tools; the live block above
carries it while reads are collapsing. */}
<Spinner startedAt={turnStartedAt} activity={liveTools.length ? null : toolActivity} />
</Box>
) : null}

Expand Down
37 changes: 37 additions & 0 deletions ui-tui/src/components/LiveTools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Live tool-progress block — the in-place "● Reading N files… └ current" that
* updates during a turn as repeated Read-like calls stream in. Mirrors the
* original Claude Code: repeated tools collapse into one summary whose count
* grows, showing only the current target; it freezes into the transcript when
* the round ends (App commits a collapsed summary).
*/
import { Box, Text } from 'ink'
import React from 'react'
import { theme } from '../theme.js'
import { toolActivityLabel } from '../toolMeta.js'

export interface LiveGroup {
name: string
count: number
current: string
}

export function LiveTools({ groups }: { groups: LiveGroup[] }): React.ReactElement | null {
if (!groups.length) return null
return (
<Box flexDirection="column">
{groups.map((g, i) => (
<Box key={i} flexDirection="column">
<Text>
<Text color={theme.accent}>⏺ </Text>
<Text>{toolActivityLabel(g.name, g.current, g.count)}</Text>
<Text color={theme.dim}>…</Text>
</Text>
{g.count > 1 ? (
<Text color={theme.dim}>{` └ ${(g.current || '').split(/[\\/]/).pop() || g.current}`}</Text>
) : null}
</Box>
))}
</Box>
)
}
12 changes: 12 additions & 0 deletions ui-tui/src/components/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Markdown } from '../markdown.js'
import { theme } from '../theme.js'
import { Banner } from './Banner.js'
import { DiffView } from './DiffView.js'
import { TOOL_VERB } from '../toolMeta.js'
import type { TranscriptEntry } from '../sdkMessageAdapter.js'

const RESULT_MAX_LINES = 8
Expand Down Expand Up @@ -69,6 +70,17 @@ export function Message({ entry }: { entry: TranscriptEntry }): React.ReactEleme
</Box>
)
case 'tool': {
// Collapsed summary of several same-kind calls (e.g. "Read 4 files").
if (entry.count && entry.count > 1) {
const noun = TOOL_VERB[entry.toolName ?? '']?.noun || 'files'
return (
<Text>
<Text color={theme.success}>⏺ </Text>
<Text bold>{entry.toolName}</Text>
<Text color={theme.dim}>{` ${entry.count} ${noun}`}</Text>
</Text>
)
}
const diff = entry.diff
const isWeb = entry.toolName === 'WebFetch' || entry.toolName === 'WebSearch'
return (
Expand Down
17 changes: 15 additions & 2 deletions ui-tui/src/sdkMessageAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export interface TranscriptEntry {
input?: Record<string, unknown>
/** Edit/Write tool calls: precomputed diff (with true file line numbers). */
diff?: DiffLine[]
/** tool calls: the tool_use id (used to correlate + collapse results). */
toolUseId?: string
/** tool results: the tool_use ids they answer (to drop collapsed-read results). */
forToolUseIds?: string[]
/** collapsed tool summary: how many same-kind calls this entry represents. */
count?: number
/** banner only: the session info snapshot, captured once at init. */
bannerData?: { model: string; mode: string; tools: number; cwd?: string }
}
Expand Down Expand Up @@ -157,6 +163,7 @@ export function messageToEntries(msg: ServerMessage): TranscriptEntry[] {
argsText: formatToolArgs(tinput),
input: tinput,
diff,
toolUseId: String((block as { id?: string }).id ?? ''),
})
}
}
Expand All @@ -166,8 +173,14 @@ export function messageToEntries(msg: ServerMessage): TranscriptEntry[] {

if (type === 'user') {
const m = msg as { message: { content: string | ContentBlock[] } }
const text = toolResultText(m.message?.content)
return text ? [{ id: nextId(), kind: 'toolResult', text }] : []
const content = m.message?.content
const text = toolResultText(content)
const forToolUseIds = Array.isArray(content)
? content
.filter((b) => b && b.type === 'tool_result')
.map((b) => String((b as { tool_use_id?: string }).tool_use_id ?? ''))
: []
return text ? [{ id: nextId(), kind: 'toolResult', text, forToolUseIds }] : []
}

if (type === 'result') {
Expand Down
31 changes: 31 additions & 0 deletions ui-tui/src/toolMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Tool display metadata shared by the live tool-progress block, the spinner,
* and the committed collapsed summary — so "Reading 3 files" reads the same
* everywhere.
*/
export const TOOL_VERB: Record<string, { verb: string; noun: string }> = {
Read: { verb: 'Reading', noun: 'files' },
Edit: { verb: 'Editing', noun: 'files' },
Write: { verb: 'Writing', noun: 'files' },
MultiEdit: { verb: 'Editing', noun: 'files' },
Bash: { verb: 'Running', noun: 'commands' },
Grep: { verb: 'Searching', noun: 'patterns' },
Glob: { verb: 'Globbing', noun: 'patterns' },
LS: { verb: 'Listing', noun: 'dirs' },
WebFetch: { verb: 'Fetching', noun: 'urls' },
WebSearch: { verb: 'Searching', noun: 'queries' },
}

/** Read-like tools whose repeated calls collapse into one live "Reading N files" block. */
export const READ_LIKE = new Set(['Read', 'Glob', 'Grep', 'LS'])

/** "Reading 3 files" (collapsed count) or "Reading README.md" (single). */
export function toolActivityLabel(name: string | undefined, args: string | undefined, count: number): string {
const { verb, noun } = (name && TOOL_VERB[name]) || {
verb: name ? `Using ${name}` : 'Working',
noun: '',
}
if (count > 1 && noun) return `${verb} ${count} ${noun}`
const target = (args || '').split(/[\\/]/).pop() || args || ''
return target ? `${verb} ${target}` : verb
}