fix(web): honor the terminal view's description and resolved workdir

Three review findings, each verified against the presentation contract:

The call view's `description` was dropped, so a presenter that authors one
(`terminal_send` declares `Terminal <id>`) lost the contract's above-card text
and the row fell back to an unrelated args-derived summary. It now rides the
same derivation and outranks that summary.

A relative workdir was concatenated but never normalized, while the bash
executor resolves it before running: with session cwd `/w/app` and workdir
`..` the command runs in `/w`, yet the card displayed the label `..`. The
resolved path now collapses `.`/`..` segments, drops a `..` that would climb
past a root the way a filesystem does, and keeps a Windows path's separators
since the value is only ever displayed.

`run_code` sub-dispatches carry no presenter views on the shipped wire —
`session.ts` folds `tool/code-dispatch(-start)` with null views and the host's
`viewFor` presents only top-level call/result events — so a nested bash call
cannot reach a terminal card. The existing test only passed by injecting views
that path cannot produce; it now says so, and a second arm pins the no-view
shape the wire actually delivers.

Restoring master's fixture also fixed the todo snapshot lane, which my earlier
merge had broken by dropping the projection support the todo dock reads. The
terminal sample turn moved ahead of the todo turn, because the standing plan
retires at the next `turn/start` and a turn appended after it emptied the dock.

The card props are now nested under `card` so a render site spreads exactly the
primitive's own surface, and the fixture reads each sample's authored exit
status instead of re-implementing the bash tool's `parseExitStatus`.
This commit is contained in:
Chinesezjc
2026-07-29 11:43:23 +08:00
parent 1c8188ce2b
commit 439c206658
10 changed files with 226 additions and 77 deletions

View File

@@ -157,7 +157,7 @@ export function ToolRow({
)}
</div>
{open && (terminalBody !== null
? <TerminalBlock {...terminalBody} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>)}

View File

@@ -28,10 +28,21 @@ export const CHAT_TERMINAL_MAX_LINES = 8
* client has no home path for the session host (a cwd renders as its last
* path segment), and `maxLines`/`className` belong to each render site.
*/
export type TerminalCardModel = Pick<
TerminalBlockProps,
'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'
>
export interface TerminalCardModel {
/**
* The props {@link TerminalBlock} draws. Held as a nested object so a render
* site spreads exactly the primitive's own surface and can never leak a
* neighbouring field into it.
*/
card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
/**
* The call view's model-authored description, which the contract defines as
* rendering ABOVE the card (the card itself has no description slot). Absent
* when the presenter supplied none, or when the window dropped the call side;
* a row then keeps its args-derived summary.
*/
description: string | undefined
}
/**
* Resolve a terminal view's working directory the way the render-intent
@@ -47,8 +58,41 @@ export type TerminalCardModel = Pick<
*/
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
if (viewCwd === undefined || viewCwd === '') return sessionCwd
if (sessionCwd === undefined || sessionCwd === '') return viewCwd
return resolveToolPath(sessionCwd, viewCwd)
if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
return normalizeSegments(resolveToolPath(sessionCwd, viewCwd))
}
/**
* Collapse `.` and `..` segments so the prompt label names the directory the
* command actually ran in. The bash executor resolves the workdir before
* running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators
* are preserved as authored (a Windows path keeps its backslashes) because this
* value is only ever displayed; a `..` that would climb past the root is
* dropped, which is what a filesystem does with it.
* @param path - a joined or absolute path, possibly carrying `.`/`..` segments.
* @returns the same path with those segments resolved.
*/
function normalizeSegments(path: string): string {
if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
const backslashed = path.includes('\\') && !path.includes('/')
const separator = backslashed ? '\\' : '/'
const leading = /^[/\\]/.test(path) ? separator : ''
const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
const kept: string[] = []
for (const segment of path.slice(drive.length).split(/[/\\]/)) {
if (segment === '' || segment === '.') continue
if (segment === '..') {
// Nothing to climb from: at a root the segment is dropped, matching the
// filesystem; on a relative path the `..` has to stay, since it is still
// meaningful against a cwd this function cannot see.
if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
else if (leading === '' && drive === '') kept.push(segment)
continue
}
kept.push(segment)
}
const body = kept.join(separator)
return drive === '' ? `${leading}${body}` : `${drive}${leading === '' ? separator : leading}${body}`
}
/**
@@ -82,25 +126,31 @@ export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): Te
if (!('kind' in block)) {
// Running: the call view exists, the result view does not yet.
return call === null ? null : {
command: call.title,
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
output: undefined,
exitCode: undefined,
signal: undefined,
running: true,
description: call.description,
card: {
command: call.title,
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
output: undefined,
exitCode: undefined,
signal: undefined,
running: true,
},
}
}
const result = block.resultView?.card === 'terminal' ? block.resultView : null
if (result === null) return null
return {
// The result's title REPLACES the pending one when the tool supplies it
// (the presentation contract's replacement-title rule); the call title is
// what a result without one keeps.
command: result.title ?? call?.title ?? '',
cwd: resolveTerminalCwd(call?.cwd, sessionCwd),
output: result.output,
exitCode: result.exitCode,
signal: result.signal,
running: false,
description: call?.description,
card: {
// The result's title REPLACES the pending one when the tool supplies it
// (the presentation contract's replacement-title rule); the call title is
// what a result without one keeps.
command: result.title ?? call?.title ?? '',
cwd: resolveTerminalCwd(call?.cwd, sessionCwd),
output: result.output,
exitCode: result.exitCode,
signal: result.signal,
running: false,
},
}
}

View File

@@ -135,7 +135,7 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
*/
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
const terminal = terminalCardModel(material.block, cwd)
if (terminal !== null) return <TerminalBlock {...terminal} className={css.terminal} />
if (terminal !== null) return <TerminalBlock {...terminal.card} className={css.terminal} />
// A settled call always carries the result node the flattened form needs;
// the running shape has no result to flatten.
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>

View File

@@ -64,10 +64,12 @@ export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProp
{isChild && <span className={css.scopeBadge}>scoped</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{model.summary}</span>
{/* The terminal presenter's description is the contractual
above-card summary; it outranks the args-derived one. */}
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
</div>
{terminal !== null && (
<TerminalBlock {...terminal} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
)}
</div>
)