Files
deepseek-harness/packages/core/tools/src/presentation.ts
Chinesezjc 7b6f33f872 refactor(fs): minimize and cap search card meta; keep TUI byte-identical
Address the review of the search render card:

- The search result view carries no `content`: it was a no-op for every
  consumer and serialized the whole search text twice. A UI without a search
  card falls back to the raw tool/result content; the TUI stays byte-identical
  to the pre-search-card generic fallback.
- Bound the serialized presentationMeta with a configurable searchMetaMaxBytes
  (default 64 KiB): the inline item cap does not bound bytes, and spill-policy
  only shrinks content, never meta. capMetaBytes drops trailing groups/paths.
- Share one retention pass (retainGrepMatches/retainGlobPaths in search-core)
  between the model-facing render and the meta projection; remove the second
  cap/preview implementation and the presentation<->grep module cycle by
  moving GrepMatch/previewLine to search-core.
- Rename the result-view discriminant kind -> shape so it no longer collides
  with GenericCallView.kind (ToolCallKind, whose values include 'search').
- Narrow the entry export surface to consumed symbols.
- Sync the three bilingual ToolResultView doc pairs and the Agent Note pair;
  document the deliberate empty-card acceptance vs diffsFromMeta.
- Regenerate config/tool/cordis catalogs for the new config field.
2026-07-30 21:57:49 +08:00

256 lines
10 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. `shape: 'matches'` discriminates this variant from the path
* variant ({@link SearchPathsResultView}) within {@link SearchResultView}. The
* discriminant is `shape`, not `kind`, so it never collides with the
* {@link ToolCallKind} `kind` an icon-picking bridge reads off a call view.
*/
export interface SearchMatchesResultView {
card: 'search'
shape: '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
}
/**
* A completed path search (`glob`) rendered as a search card whose result is a flat
* path list. `shape: 'paths'` discriminates this variant from the grouped-matches
* variant ({@link SearchMatchesResultView}) within {@link SearchResultView}.
*/
export interface SearchPathsResultView {
card: 'search'
shape: '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
}
/**
* 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 `shape`-discriminated variants: 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. The view carries no result text: a
* UI without a search card falls back to the raw `tool/result` content. 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