Split the transcript, buses, panels, approval, and pickers out of App
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
co-authored by
Sisyphus
parent
a4edbcc71e
commit
744763050b
@@ -0,0 +1,106 @@
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import React from 'react';
|
||||
import type { ApprovalDecision, ApprovalRequest } from '../session';
|
||||
import { Diff } from './Diff';
|
||||
import { toolDetail } from './transcript';
|
||||
|
||||
export type Pending = { req: ApprovalRequest; resolve: (d: ApprovalDecision) => void };
|
||||
|
||||
/** Bridges Session's promise-based approval callback into React state. */
|
||||
export type ApprovalBridge = {
|
||||
bind: (fn: (p: Pending | undefined) => void) => void;
|
||||
ask: (req: ApprovalRequest) => Promise<ApprovalDecision>;
|
||||
};
|
||||
|
||||
export function createApprovalBridge(): ApprovalBridge {
|
||||
let setter: ((p: Pending | undefined) => void) | undefined;
|
||||
return {
|
||||
bind(fn) {
|
||||
setter = fn;
|
||||
},
|
||||
ask(req) {
|
||||
return new Promise((resolve) => {
|
||||
if (!setter) return resolve('deny'); // UI not mounted: fail closed
|
||||
setter({
|
||||
req,
|
||||
resolve: (d) => {
|
||||
setter?.(undefined);
|
||||
resolve(d);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the call is about to do, in the shape that decision needs.
|
||||
*
|
||||
* An edit gets a coloured diff, because the question is which lines change. Every
|
||||
* other tool gets the same argument lines the transcript shows, which is both
|
||||
* consistent and far more readable than a JSON dump of the input.
|
||||
*/
|
||||
function ApprovalDetail({ name, input }: { name: string; input: unknown }) {
|
||||
const o = (input ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (name === 'write_file') {
|
||||
const content = String(o['content'] ?? '');
|
||||
return <Diff before="" after={content} path={`${String(o['path'])} (new content)`} />;
|
||||
}
|
||||
if (name === 'edit_file') {
|
||||
return <Diff before={String(o['oldString'] ?? '')} after={String(o['newString'] ?? '')} path={String(o['path'])} />;
|
||||
}
|
||||
|
||||
const detail = toolDetail(name, input);
|
||||
if (detail.length === 0) return <Text dimColor>{JSON.stringify(input)}</Text>;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{detail.slice(0, 10).map((d, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{` ${d}`}
|
||||
</Text>
|
||||
))}
|
||||
{detail.length > 10 && <Text dimColor>{` ... ${detail.length - 10} more`}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Why this call stopped, in the words that make the decision obvious. */
|
||||
function reason(req: ApprovalRequest): string {
|
||||
if (req.repeated) return `${req.toolName} is repeating the same call`;
|
||||
if (req.subagent) return `a worker subagent wants to run ${req.toolName}`;
|
||||
return `${req.toolName} wants to run`;
|
||||
}
|
||||
|
||||
export function Approval({ pending }: { pending: Pending }) {
|
||||
const { req } = pending;
|
||||
|
||||
useInput((input, key) => {
|
||||
const c = input.toLowerCase();
|
||||
if (c === 'y' || key.return) pending.resolve('once');
|
||||
else if (c === 'a') pending.resolve('always');
|
||||
else if (c === 'n' || key.escape) pending.resolve('deny');
|
||||
});
|
||||
|
||||
const grant = req.suggestedPattern === '*' ? req.toolName : `${req.toolName} ${req.suggestedPattern}`;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>
|
||||
{reason(req)}
|
||||
</Text>
|
||||
{req.repeated && <Text dimColor>allowed by the rules, but this is the third identical call this turn</Text>}
|
||||
{req.subagent && !req.repeated && (
|
||||
<Text dimColor>delegated work, gated by your rules exactly as a direct call is</Text>
|
||||
)}
|
||||
{!req.repeated && req.matchedPattern && req.matchedPattern !== '*' && (
|
||||
<Text dimColor>{`matched ${req.toolName}: "${req.matchedPattern}"`}</Text>
|
||||
)}
|
||||
<ApprovalDetail name={req.toolName} input={req.input} />
|
||||
<Text>
|
||||
<Text color="green">y</Text> allow once | <Text color="green">a</Text> always allow {grant} |{' '}
|
||||
<Text color="red">n</Text> deny
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import React from 'react';
|
||||
import type { CommandSpec } from '../commands';
|
||||
import { InstallPrompt, type RegistryRow } from './Panels';
|
||||
|
||||
/** The `/` menu, narrowing as the name is typed. */
|
||||
export function CommandMenu({ matches, index }: { matches: readonly CommandSpec[]; index: number }) {
|
||||
const width = Math.max(...matches.map((c) => `/${c.name}${c.arg ? ` ${c.arg}` : ''}`.length)) + 1;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{matches.map((c, i) => (
|
||||
<Box key={c.name}>
|
||||
<Text color={i === index ? 'cyan' : undefined}>{i === index ? '> ' : ' '}</Text>
|
||||
<Text color={i === index ? 'cyan' : undefined} bold={i === index}>
|
||||
{`/${c.name}${c.arg ? ` ${c.arg}` : ''}`.padEnd(width)}
|
||||
</Text>
|
||||
<Text dimColor>{c.summary}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text dimColor>up/down move | tab complete | enter run | esc dismiss</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keyboard wrapper around InstallPrompt, so the prompt itself stays presentational. */
|
||||
export function InstallConfirm({
|
||||
staged,
|
||||
onDone,
|
||||
}: {
|
||||
staged: { row: RegistryRow; url: string; preview: string };
|
||||
onDone: (yes: boolean) => void;
|
||||
}) {
|
||||
useInput((input, key) => {
|
||||
const c = input.toLowerCase();
|
||||
if (c === 'y' || key.return) onDone(true);
|
||||
else if (c === 'n' || key.escape) onDone(false);
|
||||
});
|
||||
|
||||
return <InstallPrompt name={staged.row.name} kind={staged.row.kind} url={staged.url} preview={staged.preview} />;
|
||||
}
|
||||
|
||||
export type PickerOption = { value: string; label: string };
|
||||
|
||||
/**
|
||||
* The bordered frame every wizard and picker sits in.
|
||||
*
|
||||
* It lived as two private copies, one in Onboard and one in McpAdd, which had
|
||||
* already drifted: one took an `error` line and the other did not. One component
|
||||
* means the border, title, hint, and error treatment stay the same everywhere.
|
||||
*/
|
||||
export function Frame({
|
||||
title,
|
||||
hint,
|
||||
error,
|
||||
warning,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
{title}
|
||||
</Text>
|
||||
{hint && <Text dimColor>{hint}</Text>}
|
||||
{warning && <Text color="yellow">could not list models: {warning}</Text>}
|
||||
{error && <Text color="red">{error}</Text>}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** A `label:` prefix in the frame's colour, for one input line of a form. */
|
||||
export function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="cyan">{label}: </Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One bordered list picker, shared by the model, agent, and thinking choosers.
|
||||
*
|
||||
* They were three copies of the same twenty lines differing only in title, hint,
|
||||
* and items. One component means a change to the picker's behaviour — the escape
|
||||
* hint, the highlight, the row limit — lands in all three at once.
|
||||
*/
|
||||
export function Picker({
|
||||
title,
|
||||
hint,
|
||||
options,
|
||||
current,
|
||||
limit = 10,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
options: readonly PickerOption[];
|
||||
/** Value to start on, so a picker opens at what is already in use. */
|
||||
current?: string;
|
||||
limit?: number;
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
{title}
|
||||
</Text>
|
||||
<Text dimColor>{hint ? `${hint} - enter to select, esc to cancel` : 'enter to select, esc to cancel'}</Text>
|
||||
<SelectInput
|
||||
items={options.map((o) => ({ key: o.value, label: o.label, value: o.value }))}
|
||||
limit={limit}
|
||||
initialIndex={Math.max(
|
||||
0,
|
||||
options.findIndex((o) => o.value === current),
|
||||
)}
|
||||
onSelect={(item) => onSelect(item.value)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { SubagentEvent } from '../subagent';
|
||||
import type { SubagentView } from './Panels';
|
||||
|
||||
/** One-way channel for out-of-band notices, e.g. an endpoint fallback. */
|
||||
export type NoticeBus = {
|
||||
bind: (fn: (text: string) => void) => void;
|
||||
emit: (text: string) => void;
|
||||
};
|
||||
|
||||
export function createNoticeBus(): NoticeBus {
|
||||
const queued: string[] = [];
|
||||
let sink: ((text: string) => void) | undefined;
|
||||
return {
|
||||
bind(fn) {
|
||||
sink = fn;
|
||||
for (const text of queued.splice(0)) fn(text);
|
||||
},
|
||||
emit(text) {
|
||||
if (sink) sink(text);
|
||||
else queued.push(text);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Subagent progress, from the task tool to the panel. */
|
||||
export type SubagentBus = {
|
||||
bind: (fn: (event: SubagentEvent) => void) => void;
|
||||
emit: (event: SubagentEvent) => void;
|
||||
};
|
||||
|
||||
export function createSubagentBus(): SubagentBus {
|
||||
const queued: SubagentEvent[] = [];
|
||||
let sink: ((event: SubagentEvent) => void) | undefined;
|
||||
return {
|
||||
bind(fn) {
|
||||
sink = fn;
|
||||
for (const event of queued.splice(0)) fn(event);
|
||||
},
|
||||
emit(event) {
|
||||
if (sink) sink(event);
|
||||
else queued.push(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Folds a subagent event into the panel's view, keeping finished agents visible. */
|
||||
export function applySubagentEvent(current: SubagentView[], event: SubagentEvent): SubagentView[] {
|
||||
switch (event.type) {
|
||||
case 'start':
|
||||
return [
|
||||
...current,
|
||||
{ id: event.id, kind: event.kind, description: event.description, steps: [], status: 'running' },
|
||||
];
|
||||
case 'step':
|
||||
return current.map((a) =>
|
||||
a.id === event.id ? { ...a, steps: [...a.steps, { tool: event.tool, summary: event.summary }] } : a,
|
||||
);
|
||||
case 'result':
|
||||
// Attaches to the step it answers rather than appending, so a subagent's
|
||||
// step count stays the number of calls it made.
|
||||
return current.map((a) => {
|
||||
if (a.id !== event.id) return a;
|
||||
const last = a.steps.at(-1);
|
||||
if (!last || last.tool !== event.tool || last.outcome !== undefined) return a;
|
||||
return {
|
||||
...a,
|
||||
steps: [...a.steps.slice(0, -1), { ...last, outcome: event.summary, ok: event.ok }],
|
||||
};
|
||||
});
|
||||
case 'end':
|
||||
return current.map((a) => (a.id === event.id ? { ...a, status: event.ok ? 'done' : 'failed' } : a));
|
||||
case 'error':
|
||||
return current.map((a) => (a.id === event.id ? { ...a, status: 'failed', error: event.message } : a));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { costOf, formatUsd } from '../pricing';
|
||||
import type { Session } from '../session';
|
||||
import { toolSetOf } from '../tools';
|
||||
import { todoLines } from './transcript';
|
||||
|
||||
export type Panel = { title: string; hint?: string; body: string };
|
||||
|
||||
/**
|
||||
* The read-only panel bodies, as pure functions of session and hook state.
|
||||
*
|
||||
* These were inline inside App's submit switch, where each one added a `setPanel`
|
||||
* call wrapped around string assembly and pushed the switch past the point a reader
|
||||
* can follow it. Out here they are testable without mounting Ink, and the switch
|
||||
* reads as routing rather than as formatting.
|
||||
*/
|
||||
|
||||
export function toolsPanel(session: Session): Panel {
|
||||
const offered = session.activeTools().sort();
|
||||
return {
|
||||
title: 'tools',
|
||||
hint: `${offered.length} offered this turn of ${Object.keys(session.tools).length} registered`,
|
||||
body: offered
|
||||
.map((t) => {
|
||||
const set = toolSetOf(t);
|
||||
return `- \`${t}\`${set ? ` ${set}` : ''}`;
|
||||
})
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export function costPanel(
|
||||
session: Session,
|
||||
info: { sessionId: string; model: string; agent: string; thinking: string },
|
||||
): Panel {
|
||||
const spend = costOf(info.model, session.inputTokens, session.outputTokens);
|
||||
return {
|
||||
title: 'cost',
|
||||
hint: `session ${info.sessionId}`,
|
||||
body: [
|
||||
`- model: \`${info.model}\``,
|
||||
`- billed: ${session.inputTokens} in / ${session.outputTokens} out`,
|
||||
`- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`,
|
||||
`- context: ~${session.estimatedTokens()} tokens`,
|
||||
`- agent: \`${info.agent}\` thinking \`${info.thinking}\``,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export function contextPanel(files: readonly string[]): Panel {
|
||||
return {
|
||||
title: 'project instructions',
|
||||
body:
|
||||
files.length > 0
|
||||
? files.map((f) => `- \`${f}\``).join('\n')
|
||||
: 'No `AGENTS.md`, `CLAUDE.md`, or `.shiro.md` found. Run `/init` to write one.',
|
||||
};
|
||||
}
|
||||
|
||||
export const todosPanel = (session: Session): Panel => ({
|
||||
title: 'task list',
|
||||
body: todoLines(session.notebook.state().todos),
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { TODO_MARK } from '../notebook';
|
||||
|
||||
export type Line =
|
||||
| { key: string; kind: 'user'; text: string }
|
||||
| { key: string; kind: 'assistant'; text: string }
|
||||
| { key: string; kind: 'tool'; name: string; detail: string[]; result?: string; ok: boolean }
|
||||
| { key: string; kind: 'info'; text: string }
|
||||
| { key: string; kind: 'error'; text: string };
|
||||
|
||||
export type NewLine = Line extends infer T ? (T extends Line ? Omit<T, 'key'> : never) : never;
|
||||
|
||||
let seq = 0;
|
||||
export const nextKey = () => `l${seq++}`;
|
||||
|
||||
export const clip = (s: string, n = 68) => (s.length > n ? `${s.slice(0, n)}...` : s);
|
||||
|
||||
/** The single argument that identifies a call, for a tool with no richer formatter. */
|
||||
export function preview(input: unknown): string {
|
||||
if (input === null || typeof input !== 'object') return String(input);
|
||||
const o = input as Record<string, unknown>;
|
||||
const first = o['command'] ?? o['path'] ?? o['pattern'] ?? o['url'] ?? o['description'] ?? o['question'] ?? o['name'];
|
||||
if (typeof first === 'string') return first.length > 90 ? `${first.slice(0, 90)}...` : first;
|
||||
|
||||
// A tool with no obvious label, e.g. todo_write, gets a shape rather than a
|
||||
// JSON dump; the panels below already show the content.
|
||||
const todos = o['todos'];
|
||||
if (Array.isArray(todos)) return `${todos.length} task${todos.length === 1 ? '' : 's'}`;
|
||||
const keys = Object.keys(o);
|
||||
return keys.length === 0 ? '' : keys.slice(0, 3).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The arguments that matter for one call, one per line.
|
||||
*
|
||||
* `preview` picks a single field, which loses exactly the information a reader
|
||||
* wants: a `read_file` with an offset, a `grep` scoped by `include`, the twenty
|
||||
* paths a batch read is about to pull in. This is what goes under the tool line in
|
||||
* the transcript, beside the spinner while a call is in flight, and in the approval
|
||||
* prompt for any tool without a diff of its own.
|
||||
*/
|
||||
export function toolDetail(name: string, input: unknown): string[] {
|
||||
if (input === null || typeof input !== 'object') return [];
|
||||
const o = input as Record<string, unknown>;
|
||||
const str = (k: string) => (typeof o[k] === 'string' ? (o[k] as string) : undefined);
|
||||
const num = (k: string) => (typeof o[k] === 'number' ? (o[k] as number) : undefined);
|
||||
const bool = (k: string) => o[k] === true;
|
||||
|
||||
switch (name) {
|
||||
case 'read_file': {
|
||||
const range = num('offset')
|
||||
? `lines ${num('offset')}${num('limit') ? `-${num('offset')! + num('limit')! - 1}` : '+'}`
|
||||
: undefined;
|
||||
return [clip(str('path') ?? ''), ...(range ? [range] : [])];
|
||||
}
|
||||
case 'read_many_files': {
|
||||
const files = Array.isArray(o['files']) ? (o['files'] as { path?: unknown }[]) : [];
|
||||
const paths = files.map((f) => (typeof f.path === 'string' ? f.path : '?'));
|
||||
// Every path, not a count: the point of showing this is knowing what is
|
||||
// about to enter the context. `.map((p) => clip(p))` rather than `.map(clip)`,
|
||||
// because the latter hands the array index to clip's width parameter and
|
||||
// truncates every line to nothing.
|
||||
return paths
|
||||
.slice(0, 8)
|
||||
.map((p) => clip(p))
|
||||
.concat(paths.length > 8 ? [`... ${paths.length - 8} more`] : []);
|
||||
}
|
||||
case 'write_file': {
|
||||
const content = str('content') ?? '';
|
||||
return [clip(str('path') ?? ''), `${content.split('\n').length} lines, ${content.length} chars`];
|
||||
}
|
||||
case 'edit_file': {
|
||||
const old = str('oldString') ?? '';
|
||||
return [
|
||||
clip(str('path') ?? ''),
|
||||
`- ${clip(old.split('\n')[0] ?? '', 60)}${old.includes('\n') ? ` (+${old.split('\n').length - 1} lines)` : ''}`,
|
||||
...(bool('replaceAll') ? ['every occurrence'] : []),
|
||||
];
|
||||
}
|
||||
case 'multi_edit': {
|
||||
const edits = Array.isArray(o['edits']) ? (o['edits'] as { oldString?: unknown }[]) : [];
|
||||
return [
|
||||
clip(str('path') ?? ''),
|
||||
...edits.slice(0, 5).map((e, i) => {
|
||||
const old = typeof e.oldString === 'string' ? e.oldString : '';
|
||||
return `${i + 1}. - ${clip(old.split('\n')[0] ?? '', 58)}`;
|
||||
}),
|
||||
...(edits.length > 5 ? [`... ${edits.length - 5} more edits`] : []),
|
||||
];
|
||||
}
|
||||
case 'apply_patch': {
|
||||
const patch = str('patch') ?? '';
|
||||
const ops = [...patch.matchAll(/^\*\*\* (Add|Update|Delete) File: (.+)$/gm)].map(
|
||||
(m) => `${m[1]!.toLowerCase()} ${m[2]!.trim()}`,
|
||||
);
|
||||
const moves = [...patch.matchAll(/^\*\*\* Move to: (.+)$/gm)].map((m) => `move to ${m[1]!.trim()}`);
|
||||
return [...ops, ...moves].slice(0, 10).map((line) => clip(line));
|
||||
}
|
||||
case 'move_file':
|
||||
return [`${clip(str('from') ?? '', 40)} -> ${clip(str('to') ?? '', 40)}`];
|
||||
case 'delete_file':
|
||||
return [clip(str('path') ?? '')];
|
||||
case 'bash': {
|
||||
const timeout = num('timeout');
|
||||
return [
|
||||
...(str('command') ?? '')
|
||||
.split('\n')
|
||||
.slice(0, 4)
|
||||
.map((l) => clip(l)),
|
||||
...(timeout ? [`timeout ${Math.round(timeout / 1000)}s`] : []),
|
||||
];
|
||||
}
|
||||
case 'grep': {
|
||||
const parts = [`/${str('pattern') ?? ''}/`];
|
||||
if (str('include')) parts.push(`in ${str('include')}`);
|
||||
if (bool('ignoreCase')) parts.push('case-insensitive');
|
||||
if (bool('includeIgnored')) parts.push('including ignored files');
|
||||
return [clip(parts.join(' '), 90)];
|
||||
}
|
||||
case 'glob':
|
||||
return [clip(str('pattern') ?? ''), ...(bool('includeIgnored') ? ['including ignored files'] : [])];
|
||||
case 'list_dir':
|
||||
return [clip(str('path') ?? '.'), `depth ${num('depth') ?? 2}`];
|
||||
case 'web_fetch':
|
||||
return [clip(str('url') ?? '', 90)];
|
||||
case 'task': {
|
||||
const kind = str('kind') ?? 'explore';
|
||||
return [`${kind}${kind === 'worker' ? ' (writes)' : ''}: ${clip(str('description') ?? '')}`];
|
||||
}
|
||||
case 'todo_write': {
|
||||
const todos = Array.isArray(o['todos']) ? (o['todos'] as { content?: unknown; status?: unknown }[]) : [];
|
||||
return todos.slice(0, 6).map((t) => `${String(t.status ?? '')}: ${clip(String(t.content ?? ''), 56)}`);
|
||||
}
|
||||
case 'git_show':
|
||||
return [str('ref') ?? '', ...(str('path') ? [clip(str('path')!)] : [])];
|
||||
case 'git_log':
|
||||
return [`${num('limit') ?? 15} commits`, ...(str('path') ? [clip(str('path')!)] : [])];
|
||||
case 'git_diff':
|
||||
return [bool('staged') ? 'staged' : 'working tree', ...(str('path') ? [clip(str('path')!)] : [])];
|
||||
case 'git_branch':
|
||||
return [bool('remote') ? 'local and remote' : 'local'];
|
||||
case 'git_blame': {
|
||||
const from = num('startLine');
|
||||
return [clip(str('path') ?? ''), ...(from ? [`lines ${from}-${num('endLine') ?? from + 40}`] : [])];
|
||||
}
|
||||
case 'remember':
|
||||
return [`${str('kind') ?? 'fact'}: ${clip(str('text') ?? '', 60)}`];
|
||||
case 'recall':
|
||||
case 'forget':
|
||||
return [clip(str('query') ?? str('text') ?? '')];
|
||||
case 'skill':
|
||||
return [str('name') ?? ''];
|
||||
default: {
|
||||
const label = preview(input);
|
||||
return label ? [clip(label, 90)] : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** First line of a tool result, so the transcript shows an outcome not just a call. */
|
||||
export function resultSummary(name: string, output: unknown): string {
|
||||
const text = typeof output === 'string' ? output : JSON.stringify(output ?? '');
|
||||
if (!text) return '';
|
||||
|
||||
const lines = text.split('\n').filter((l) => l.trim().length > 0);
|
||||
const first = lines[0] ?? '';
|
||||
|
||||
// grep and glob return one hit per line, so the count is the useful summary.
|
||||
if (name === 'grep' || name === 'glob') {
|
||||
if (/^No (matches|files matched)/.test(first)) return first;
|
||||
return `${lines.length} ${name === 'grep' ? 'hit' : 'path'}${lines.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (name === 'read_file' || name === 'read_many_files') return `${lines.length} lines`;
|
||||
if (name === 'bash') {
|
||||
const exit = /^exit: (\d+)/.exec(first);
|
||||
return exit ? `exit ${exit[1]}${lines.length > 1 ? `, ${lines.length - 1} lines out` : ''}` : clip(first);
|
||||
}
|
||||
return clip(first, 78);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a result to the most recent unanswered call of that tool.
|
||||
*
|
||||
* Matched on name rather than call id because the transcript is a flat list of
|
||||
* committed lines, and a parallel pair of calls to the same tool is rare enough
|
||||
* that "the newest one still waiting" is right in practice and cheap.
|
||||
*/
|
||||
export function withResult(lines: Line[], name: string, result: string, ok: boolean): Line[] {
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
if (line.kind !== 'tool' || line.name !== name || line.result !== undefined) continue;
|
||||
const next = [...lines];
|
||||
next[i] = { ...line, result, ok };
|
||||
return next;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** A task list as markdown, for the `/todos` panel. */
|
||||
export const todoLines = (todos: readonly { status: keyof typeof TODO_MARK; content: string; note?: string }[]) =>
|
||||
todos.length > 0
|
||||
? todos.map((t) => `- ${TODO_MARK[t.status]} ${t.content}${t.note ? ` (${t.note})` : ''}`).join('\n')
|
||||
: 'No task list yet.';
|
||||
Reference in New Issue
Block a user