Files
deepseek-harness/packages/core/tools/src/presentation.ts
Chinesezjc 3e22adab28 feat(fs): add a search render-intent card for grep and glob results
grep and glob returned only model-facing text; the structured matches/paths
never reached the client. Add a card:'search' result view with a kind
discriminant ('matches' grouped by file for grep, 'paths' for glob), projected
through each tool's output.presentationMeta and read back in presentResult. The
projections re-apply the same inline cap and per-line budget as the render text
and report total + truncated, so a UI never presents a capped page as complete.
A UI without the search card falls back to content; the TUI is unchanged. The
web consumer is a follow-up.
2026-07-30 17:03:54 +08:00

266 lines
11 KiB
TypeScript

/**
* Tool render-intent vocabulary: the provider-neutral types a tool declares via
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say how one of its calls
* renders in a UI (an editor's tool-call card, a CLI log line).
* @module @deepseek-ai/dsh-tools/src/presentation
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
/**
* Category of a tool call, used by a UI to pick an icon or treatment. The
* provider-neutral vocabulary lets tools describe themselves without depending
* on a particular client; `other` is the default.
*/
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
/**
* A file location a tool reads or modifies, so a capable UI can "follow along" —
* highlight or jump to the file (and line) as the tool runs. `path` is what the
* tool operated on (the model-facing path); `line` is an optional 1-based line
* to focus (e.g. a read's offset).
*/
export interface FileLocation {
path: string
line?: number
}
/**
* A single-file change a tool is about to make, for a UI that renders inline
* diffs. `oldText` is `null` for a new-file create (nothing to diff against);
* an overwrite also uses `null`, because a call-time presenter has no access to
* the file's prior content.
*/
export interface FileDiff {
path: string
/** Prior content, or `null` for a new file / an overwrite (no prior content available at call time). */
oldText: string | null
/** Content after the change. */
newText: string
}
/**
* Provider-neutral pending-call presentation. Tools declare one tagged intent;
* UI bridges map it without special-casing tool names.
*/
export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
/**
* The default card: a titled tool-call row with an optional category icon, a
* salient raw input, extra content blocks, and follow-along file locations. Any
* tool whose call is not a terminal or a diff uses this.
*/
export interface GenericCallView {
card: 'generic'
/**
* Human-readable, always-visible label describing what THIS call does. Keep it
* short — a UI shows it as a card header / log line.
*/
title: string
/** Category for icon/treatment; defaults to `other` when omitted. */
kind?: ToolCallKind
/**
* The salient input to surface in a detail/expanded view (e.g. a background
* task id). Omit to show nothing; a string renders as-is, an object as pretty
* JSON. NOT the full raw args object unless that is genuinely what a reader wants.
*/
rawInput?: unknown
/**
* UI-facing content blocks to show on the pending call alongside the title.
* Omit to show none. A UI maps these to its own content blocks.
*/
content?: ContentBlock[]
/** Files this call reads/modifies, for editor follow-along. Omit for a call that touches no file. */
locations?: FileLocation[]
}
/**
* A call that IS a shell command running in a working directory: a capable UI
* renders it as a terminal card (cwd-headed, with the command as the title and
* live/afterward output from the {@link TerminalResultView}); an incapable UI
* falls back to a generic card whose body is the fenced command output. Set by a
* tool whose call is a foreground command (e.g. `bash`).
*/
export interface TerminalCallView {
card: 'terminal'
/** The command, shown as the terminal card's title / header line. */
title: string
/**
* A human-readable one-line summary of what the command does, rendered ABOVE
* the terminal card (the card itself has no description slot). Omit for none.
*/
description?: string
/**
* Working directory the command runs in, shown as the terminal header. An
* ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge
* against the session workspace (the pure presenter can't see the session cwd).
* Omit entirely to let the bridge use the session workspace.
*/
cwd?: string
}
/**
* A call that creates or modifies files, rendered as an inline diff card by a
* capable UI. Set by a tool whose call writes/edits a file (e.g. `write`,
* `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is
* `null`); the tool emits a separate {@link DiffResultView} after `execute` — the
* applied change (an edit/overwrite hunk with context, or a whole-file diff for a
* create).
*/
export interface DiffCallView {
card: 'diff'
/** Card header (e.g. `Write foo.txt`). */
title: string
/** One entry per file the call changes. */
diffs: FileDiff[]
/** Files this call modifies, for editor follow-along (usually the diffs' paths). */
locations?: FileLocation[]
}
/**
* How a tool wants the COMPLETED call shown — the *result* state, after `execute`
* returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on
* `card`. Lets the tool reformat its result for a UI distinctly from the
* model-facing text it returned from `execute`. Returned by
* `ToolDefinition.presentResult`; omitting the method keeps the pending
* title and renders the raw result content.
*/
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView
/**
* The default completed card: an optional replacement title and reformatted
* content. Omit a field to keep the pending title / render the raw result content.
*/
export interface GenericResultView {
card: 'generic'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/**
* UI-facing result content (harness {@link ContentBlock}s), reformatted from
* the model-facing result. Omit to let the UI render the raw result content.
*/
content?: ContentBlock[]
}
/**
* The completed state of a {@link TerminalCallView}: the captured output and exit
* status. A capable UI renders `output` in the terminal card and shows an
* exit-status pill; an incapable UI gets a fenced ```console fallback the BRIDGE
* derives from `output` (the tool does not double-encode it).
*/
export interface TerminalResultView {
card: 'terminal'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** Captured command output (stdout+stderr as the tool chooses to combine them). */
output?: string
/**
* Process exit code, when the run ended by exiting (not a signal). Lets a
* capable UI show an exit-status pill. Omit when killed by a signal or unknown.
*/
exitCode?: number
/** Signal name that killed the process (e.g. `SIGTERM`). Mutually exclusive with `exitCode`. */
signal?: string
}
/**
* A completed file mutation rendered as an inline diff card, the result-time
* analogue of {@link DiffCallView}. Because a completed UI update replaces the
* pending card content, mutation tools return this even when it repeats the
* call-time diff; otherwise raw result text would replace the diff.
*/
export interface DiffResultView {
card: 'diff'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */
diffs: FileDiff[]
}
/** One matched line inside a {@link SearchFileMatches} group: its 1-based line number and text. */
export interface SearchLineMatch {
/** 1-based line number of the match within its file. */
lineNumber: number
/** The matched line text, as the tool surfaced it (the per-line preview budget already applied). */
line: string
}
/** One file's grouped content matches for a {@link SearchMatchesResultView}, in first-seen file order. */
export interface SearchFileMatches {
/** The file the matches belong to (the model-facing display path). */
path: string
/** The file's matched lines, in output order. */
matches: SearchLineMatch[]
}
/**
* A completed content search (`grep`) rendered as a search card whose matches are
* grouped by file, so a capable UI can list each file as an expandable group of
* its matched lines. `kind: 'matches'` discriminates this shape from the path
* shape ({@link SearchPathsResultView}) within {@link SearchResultView}.
*/
export interface SearchMatchesResultView {
card: 'search'
kind: 'matches'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** Matched lines grouped by file, in first-seen file order. */
files: SearchFileMatches[]
/**
* Whether the tool capped the inline result: `files` carries only the retained
* matches, not every match the search found. A UI shows a capped indicator so it
* never presents a partial group as complete.
*/
truncated: boolean
/** Total matches the search found before capping (equals the retained count when not `truncated`). */
total: number
/**
* UI-facing content blocks reproducing the model-facing result text, so a UI
* without a dedicated search card renders it as text. Omit to let the UI render
* the raw result content.
*/
content?: ContentBlock[]
}
/**
* A completed path search (`glob`) rendered as a search card whose result is a flat
* path list. `kind: 'paths'` discriminates this shape from the grouped-matches
* shape ({@link SearchMatchesResultView}) within {@link SearchResultView}.
*/
export interface SearchPathsResultView {
card: 'search'
kind: 'paths'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** The discovered paths, in the tool's result order (the retained page when `truncated`). */
paths: string[]
/**
* Whether the tool capped the inline result: `paths` carries only the retained
* page, not every path the search found. A UI shows a capped indicator so it
* never presents a partial list as complete.
*/
truncated: boolean
/** Total paths the search found before capping (equals `paths.length` when not `truncated`). */
total: number
/**
* UI-facing content blocks reproducing the model-facing result text, so a UI
* without a dedicated search card renders it as text. Omit to let the UI render
* the raw result content.
*/
content?: ContentBlock[]
}
/**
* A completed search rendered as a search card, the result-time view a discovery
* tool (`grep`, `glob`) returns from `presentResult`. One `card: 'search'` view
* with two `kind`-discriminated shapes: grouped-by-file content matches
* ({@link SearchMatchesResultView}) and a flat path list
* ({@link SearchPathsResultView}). Both carry a `truncated`/`total` signal so a UI
* never presents a capped result as complete, and an optional `content` a UI
* without a search card renders as text. There is no call-time analogue: a search
* call stays a {@link GenericCallView} (`kind: 'search'`) because the pending
* state has no matches or paths to show — the structured shape exists only after
* `execute`.
*/
export type SearchResultView = SearchMatchesResultView | SearchPathsResultView