Merge upstream zakirkun/shiro-neko (v1.0.0: cost control, 41 tools, 29 skills, custom commands, auto-load)
Keeps local claude-code preset (readClaudeCodeSettings from ~/.claude/settings.json) merged with upstream Pickers refactor. Resolved Onboard.tsx: both readClaudeCodeSettings + Frame/Row imports.
This commit is contained in:
commit
b1ff00d840
119 files changed
+7726
-1169
No files matched your search
+266
-523
@@ -1,137 +1,51 @@
|
||||
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { parseCommand, matchCommands, type CommandSpec } from '../commands';
|
||||
import { parseCommand, matchCommands } from '../commands';
|
||||
import { expandCommand, type CustomCommand } from '../custom-commands';
|
||||
import { THINKING_LEVELS, VARIANTS } from '../agents';
|
||||
import { completePath, matchPaths, pathToken } from '../complete';
|
||||
import type { Config } from '../config';
|
||||
import { TODO_MARK, type NotebookState } from '../notebook';
|
||||
import { type NotebookState } from '../notebook';
|
||||
import { costOf, formatUsd, usageLine } from '../pricing';
|
||||
import type { ApprovalDecision, ApprovalRequest, Session } from '../session';
|
||||
import type { SubagentEvent } from '../subagent';
|
||||
import type { Session } from '../session';
|
||||
import { interruptBash, toolSetOf } from '../tools';
|
||||
import { AskPanel, type AskBridge, type AskPending } from './Ask';
|
||||
import { Diff } from './Diff';
|
||||
import { Approval, createApprovalBridge, type ApprovalBridge, type Pending } from './Approval';
|
||||
import { applySubagentEvent, createNoticeBus, createSubagentBus, type NoticeBus, type SubagentBus } from './buses';
|
||||
import { Markdown } from './Markdown';
|
||||
import { McpAdd, type McpAddResult } from './McpAdd';
|
||||
import { Onboard, type OnboardResult } from './Onboard';
|
||||
import { InfoPanel, OutputPanel, QueuePanel, RegistryPanel, InstallPrompt, StatusBar, SubagentPanel, ThinkingPanel, TodoPanel, ActiveTool, FileMenu, type RegistryRow, type SubagentView } from './Panels';
|
||||
import {
|
||||
InfoPanel,
|
||||
OutputPanel,
|
||||
QueuePanel,
|
||||
RegistryPanel,
|
||||
Footer,
|
||||
InputStatus,
|
||||
StatusBar,
|
||||
SubagentPanel,
|
||||
ThinkingPanel,
|
||||
TodoPanel,
|
||||
ActiveTool,
|
||||
FileMenu,
|
||||
Working,
|
||||
type RegistryRow,
|
||||
type SubagentView,
|
||||
} from './Panels';
|
||||
import { CommandMenu, InstallConfirm, Picker } from './Pickers';
|
||||
import { contextPanel, costPanel, todosPanel, toolsPanel } from './panel-bodies';
|
||||
import { PromptInput } from './PromptInput';
|
||||
import { accent, glyph } from './theme';
|
||||
import { nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript';
|
||||
|
||||
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 };
|
||||
|
||||
type NewLine = Line extends infer T ? (T extends Line ? Omit<T, 'key'> : never) : never;
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
}
|
||||
export { createApprovalBridge, createNoticeBus, createSubagentBus, applySubagentEvent };
|
||||
export type { ApprovalBridge, NoticeBus, SubagentBus };
|
||||
|
||||
/** Everything the slash commands need from the outside world. */
|
||||
export type AppHooks = {
|
||||
sessionId: string;
|
||||
/** Session title for the welcome dashboard; absent in tests. */
|
||||
title?: string;
|
||||
config: () => Config;
|
||||
switchModel: (id: string) => string;
|
||||
switchAgent: (name: string) => string;
|
||||
@@ -151,6 +65,8 @@ export type AppHooks = {
|
||||
instructionFiles: () => string[];
|
||||
/** Ignore-aware workspace paths for `@` completion, loaded on first use. */
|
||||
listPaths: () => Promise<string[]>;
|
||||
/** Custom slash commands from markdown files, for the menu and the parser. */
|
||||
customCommands?: () => readonly CustomCommand[];
|
||||
/** Registry index, installed set, and the install/remove actions. */
|
||||
registry: {
|
||||
list: () => Promise<RegistryRow[]>;
|
||||
@@ -160,274 +76,25 @@ export type AppHooks = {
|
||||
install: (name: string) => Promise<string>;
|
||||
remove: (name: string) => Promise<string>;
|
||||
};
|
||||
/** Configured MCP servers, and the add/remove actions that write config.json. */
|
||||
mcp: {
|
||||
names: () => string[];
|
||||
list: () => string;
|
||||
add: (result: McpAddResult) => Promise<string>;
|
||||
remove: (name: string) => Promise<string>;
|
||||
};
|
||||
/** Prompt to hand the model for /init. */
|
||||
initPrompt: string;
|
||||
history: string[];
|
||||
recordPrompt: (text: string) => void;
|
||||
};
|
||||
|
||||
let seq = 0;
|
||||
const nextKey = () => `l${seq++}`;
|
||||
|
||||
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(', ');
|
||||
}
|
||||
|
||||
const clip = (s: string, n = 68) => (s.length > n ? `${s.slice(0, n)}...` : s);
|
||||
|
||||
/**
|
||||
* 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 and beside the spinner while a call is in flight.
|
||||
*/
|
||||
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.
|
||||
return paths.slice(0, 8).map(clip).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(clip);
|
||||
}
|
||||
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_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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
function ApprovalDetail({ name, input }: { name: string; input: unknown }) {
|
||||
const o = (input ?? {}) as Record<string, unknown>;
|
||||
if (name === 'bash') return <Text dimColor>{String(o['command'] ?? '')}</Text>;
|
||||
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'])} />;
|
||||
}
|
||||
return <Text dimColor>{JSON.stringify(input, null, 2)}</Text>;
|
||||
}
|
||||
|
||||
function Approval({ pending }: { pending: 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');
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>
|
||||
{pending.req.repeated
|
||||
? `${pending.req.toolName} is repeating the same call`
|
||||
: pending.req.subagent
|
||||
? `a worker subagent wants to run ${pending.req.toolName}`
|
||||
: `${pending.req.toolName} wants to run`}
|
||||
</Text>
|
||||
{pending.req.repeated && (
|
||||
<Text dimColor>
|
||||
allowed by the rules, but this is the third identical call this turn
|
||||
</Text>
|
||||
)}
|
||||
{pending.req.subagent && !pending.req.repeated && (
|
||||
<Text dimColor>delegated work, gated by your rules exactly as a direct call is</Text>
|
||||
)}
|
||||
{!pending.req.repeated && pending.req.matchedPattern && pending.req.matchedPattern !== '*' && (
|
||||
<Text dimColor>{`matched ${pending.req.toolName}: "${pending.req.matchedPattern}"`}</Text>
|
||||
)}
|
||||
<ApprovalDetail name={pending.req.toolName} input={pending.req.input} />
|
||||
<Text>
|
||||
<Text color="green">y</Text> allow once | <Text color="green">a</Text> always allow{' '}
|
||||
{pending.req.suggestedPattern === '*'
|
||||
? pending.req.toolName
|
||||
: `${pending.req.toolName} ${pending.req.suggestedPattern}`}{' '}
|
||||
| <Text color="red">n</Text> deny
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandMenu({ matches, index }: { matches: 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. */
|
||||
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 function App({
|
||||
session,
|
||||
bridge,
|
||||
header,
|
||||
headerNode,
|
||||
version,
|
||||
hooks,
|
||||
notices,
|
||||
askBridge,
|
||||
@@ -437,6 +104,10 @@ export function App({
|
||||
session: Session;
|
||||
bridge: ApprovalBridge;
|
||||
header: string;
|
||||
/** Rich welcome screen; when present it replaces the plain `header` string. */
|
||||
headerNode?: React.ReactNode;
|
||||
/** Build version, shown in the welcome dashboard's meta panel. */
|
||||
version?: string;
|
||||
hooks: AppHooks;
|
||||
notices?: NoticeBus;
|
||||
askBridge?: AskBridge;
|
||||
@@ -444,7 +115,19 @@ export function App({
|
||||
needsProvider?: boolean;
|
||||
}) {
|
||||
const { exit } = useApp();
|
||||
const { write } = useStdout();
|
||||
const { write, stdout } = useStdout();
|
||||
// The footer splits hints left from context/cost right, and the input box and
|
||||
// dashboards lay out against the real terminal width, so it is tracked and
|
||||
// kept current on resize rather than read once.
|
||||
const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80);
|
||||
useEffect(() => {
|
||||
if (!stdout) return;
|
||||
const onResize = () => setTermWidth(stdout.columns ?? 80);
|
||||
stdout.on('resize', onResize);
|
||||
return () => {
|
||||
stdout.off('resize', onResize);
|
||||
};
|
||||
}, [stdout]);
|
||||
const [history, setHistory] = useState<Line[]>([]);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [live, setLive] = useState('');
|
||||
@@ -477,10 +160,14 @@ export function App({
|
||||
const [installing, setInstalling] = useState<
|
||||
{ row: RegistryRow; url: string; preview: string } | undefined
|
||||
>();
|
||||
const [addingMcp, setAddingMcp] = useState(false);
|
||||
const [seconds, setElapsed] = useState(0);
|
||||
const startedAt = useRef<number | undefined>(undefined);
|
||||
|
||||
const modal = pending !== undefined || asking !== undefined || onboarding || installing !== undefined;
|
||||
const modal =
|
||||
pending !== undefined || asking !== undefined || onboarding || installing !== undefined || addingMcp;
|
||||
const anyPicker = modelPicker !== undefined || agentPicker || thinkPicker;
|
||||
const matches = matchCommands(draft);
|
||||
const matches = matchCommands(draft, hooks.customCommands?.() ?? []);
|
||||
const menuOpen = matches.length > 0 && !menuDismissed && !busy && !modal && !anyPicker && !panel;
|
||||
const highlighted = matches[Math.min(menuIndex, matches.length - 1)];
|
||||
|
||||
@@ -536,8 +223,22 @@ export function App({
|
||||
const setWorking = useCallback((value: boolean) => {
|
||||
busyRef.current = value;
|
||||
setBusy(value);
|
||||
setElapsed(0);
|
||||
startedAt.current = value ? Date.now() : undefined;
|
||||
}, []);
|
||||
|
||||
// One tick per second while busy, so a long turn reports how long it has been
|
||||
// going. Derived from a timestamp rather than counted, because Ink's render loop
|
||||
// is not a clock and a dropped tick would drift.
|
||||
useEffect(() => {
|
||||
if (!busy) return;
|
||||
const t = setInterval(() => {
|
||||
const from = startedAt.current;
|
||||
if (from !== undefined) setElapsed(Math.floor((Date.now() - from) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [busy]);
|
||||
|
||||
const push = useCallback((line: NewLine) => {
|
||||
setHistory((h) => [...h, { ...line, key: nextKey() }]);
|
||||
}, []);
|
||||
@@ -764,7 +465,7 @@ export function App({
|
||||
|
||||
// Enter on an open menu runs the highlighted entry, so `/mo` + enter works.
|
||||
const chosen = menuOpen && highlighted ? `/${highlighted.name}` : raw;
|
||||
const action = parseCommand(chosen);
|
||||
const action = parseCommand(chosen, hooks.customCommands?.() ?? []);
|
||||
|
||||
switch (action.type) {
|
||||
case 'none':
|
||||
@@ -810,58 +511,28 @@ export function App({
|
||||
return;
|
||||
case 'tools':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({
|
||||
title: 'tools',
|
||||
hint: `${session.activeTools().length} offered this turn of ${Object.keys(session.tools).length} registered`,
|
||||
body: session
|
||||
.activeTools()
|
||||
.sort()
|
||||
.map((t) => {
|
||||
const set = toolSetOf(t);
|
||||
return `- \`${t}\`${set ? ` ${set}` : ''}`;
|
||||
})
|
||||
.join('\n'),
|
||||
});
|
||||
setPanel(toolsPanel(session));
|
||||
return;
|
||||
case 'cost': {
|
||||
case 'cost':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
const model = hooks.config().model;
|
||||
const spend = costOf(model, session.inputTokens, session.outputTokens);
|
||||
setPanel({
|
||||
title: 'cost',
|
||||
hint: `session ${hooks.sessionId}`,
|
||||
body: [
|
||||
`- model: \`${model}\``,
|
||||
`- billed: ${session.inputTokens} in / ${session.outputTokens} out`,
|
||||
`- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`,
|
||||
`- context: ~${session.estimatedTokens()} tokens`,
|
||||
`- agent: \`${hooks.agentName()}\` thinking \`${hooks.thinkingLevel()}\``,
|
||||
].join('\n'),
|
||||
});
|
||||
setPanel(
|
||||
costPanel(session, {
|
||||
sessionId: hooks.sessionId,
|
||||
model: hooks.config().model,
|
||||
agent: hooks.agentName(),
|
||||
thinking: hooks.thinkingLevel(),
|
||||
...(hooks.config().subagentModel ? { subagentModel: hooks.config().subagentModel! } : {}),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'context': {
|
||||
case 'context':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
const files = hooks.instructionFiles();
|
||||
setPanel({
|
||||
title: 'project instructions',
|
||||
body: files.length
|
||||
? files.map((f) => `- \`${f}\``).join('\n')
|
||||
: 'No `AGENTS.md`, `CLAUDE.md`, or `.shiro.md` found. Run `/init` to write one.',
|
||||
});
|
||||
setPanel(contextPanel(hooks.instructionFiles()));
|
||||
return;
|
||||
}
|
||||
case 'todos': {
|
||||
case 'todos':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
const { todos } = session.notebook.state();
|
||||
setPanel({
|
||||
title: 'task list',
|
||||
body: todos.length
|
||||
? todos.map((t) => `- ${TODO_MARK[t.status]} ${t.content}${t.note ? ` (${t.note})` : ''}`).join('\n')
|
||||
: 'No task list yet.',
|
||||
});
|
||||
setPanel(todosPanel(session));
|
||||
return;
|
||||
}
|
||||
case 'notes': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({ title: 'project memory', body: await hooks.listMemory() });
|
||||
@@ -945,6 +616,23 @@ export function App({
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'mcp': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
if (action.action === 'add') {
|
||||
setAddingMcp(true);
|
||||
return;
|
||||
}
|
||||
if (action.action === 'remove') {
|
||||
try {
|
||||
push({ kind: 'info', text: await hooks.mcp.remove(action.arg!) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setPanel({ title: 'mcp servers', hint: '/mcp add to add one', body: hooks.mcp.list() });
|
||||
return;
|
||||
}
|
||||
case 'memory': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setWorking(true);
|
||||
@@ -1021,6 +709,37 @@ export function App({
|
||||
setRecall((h) => (h.at(-1) === action.text ? h : [...h, action.text]));
|
||||
await runTurn(action.text);
|
||||
return;
|
||||
case 'custom': {
|
||||
const typed = chosen.trim();
|
||||
push({ kind: 'user', text: typed });
|
||||
setWorking(true);
|
||||
try {
|
||||
// A command may pin an agent; it runs the prompt under that variant
|
||||
// and restores afterwards, so one command does not leak its agent into
|
||||
// the rest of the session.
|
||||
const previous = hooks.agentName();
|
||||
if (action.command.agent && action.command.agent !== previous) {
|
||||
try {
|
||||
hooks.switchAgent(action.command.agent);
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
const prompt = await expandCommand(action.command, action.args);
|
||||
await runTurn(prompt);
|
||||
if (action.command.agent && action.command.agent !== previous) {
|
||||
try {
|
||||
hooks.switchAgent(previous);
|
||||
} catch {
|
||||
// Restoring the agent is best-effort; the next /agent sets it explicitly.
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setWorking(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[exit, highlighted, hooks, menuOpen, push, runTurn, session, setWorking, unconfigured, write],
|
||||
@@ -1035,13 +754,24 @@ export function App({
|
||||
<Static items={history}>
|
||||
{(line) => (
|
||||
<Box key={line.key} flexDirection="column" marginBottom={1}>
|
||||
{line.kind === 'user' && <Text color="cyan">{`> ${line.text}`}</Text>}
|
||||
{line.kind === 'assistant' && <Markdown text={line.text} />}
|
||||
{line.kind === 'user' && (
|
||||
<Text color={accent.user} bold>
|
||||
{`${glyph.user} ${line.text}`}
|
||||
</Text>
|
||||
)}
|
||||
{line.kind === 'assistant' && (
|
||||
<Box>
|
||||
<Text color={accent.ok}>{`${glyph.assistant} `}</Text>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<Markdown text={line.text} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{line.kind === 'tool' && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={line.ok ? 'magenta' : 'red'}>{line.ok ? '*' : 'x'} </Text>
|
||||
<Text color={line.ok ? 'magenta' : 'red'} bold>
|
||||
<Text color={line.ok ? accent.tool : accent.err}>{line.ok ? glyph.toolOk : glyph.toolErr} </Text>
|
||||
<Text color={line.ok ? accent.tool : accent.err} bold>
|
||||
{line.name}
|
||||
</Text>
|
||||
{line.detail[0] !== undefined && <Text dimColor>{` ${line.detail[0]}`}</Text>}
|
||||
@@ -1052,23 +782,26 @@ export function App({
|
||||
</Text>
|
||||
))}
|
||||
{line.result !== undefined && line.result.length > 0 && (
|
||||
<Text color={line.ok ? undefined : 'red'} dimColor={line.ok}>
|
||||
{` ${line.ok ? '->' : 'x'} ${line.result}`}
|
||||
<Text color={line.ok ? undefined : accent.err} dimColor={line.ok}>
|
||||
{` ${line.ok ? glyph.result : glyph.err} ${line.result}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{line.kind === 'info' && <Text dimColor>{line.text}</Text>}
|
||||
{line.kind === 'error' && <Text color="red">error: {line.text}</Text>}
|
||||
{line.kind === 'info' && <Text dimColor>{`${glyph.info} ${line.text}`}</Text>}
|
||||
{line.kind === 'error' && <Text color={accent.err}>{`${glyph.err} ${line.text}`}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
</Static>
|
||||
|
||||
{history.length === 0 && (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>{header}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{history.length === 0 &&
|
||||
(headerNode !== undefined ? (
|
||||
headerNode
|
||||
) : (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text dimColor>{header}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{agents.length > 0 && <SubagentPanel agents={agents} />}
|
||||
|
||||
@@ -1109,6 +842,24 @@ export function App({
|
||||
|
||||
{asking && <AskPanel pending={asking} />}
|
||||
|
||||
{addingMcp && (
|
||||
<McpAdd
|
||||
existing={hooks.mcp.names()}
|
||||
onCancel={() => {
|
||||
setAddingMcp(false);
|
||||
push({ kind: 'info', text: 'mcp setup cancelled' });
|
||||
}}
|
||||
onDone={async (result) => {
|
||||
setAddingMcp(false);
|
||||
try {
|
||||
push({ kind: 'info', text: await hooks.mcp.add(result) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pending && <Approval pending={pending} />}
|
||||
|
||||
{onboarding && (
|
||||
@@ -1131,76 +882,54 @@ export function App({
|
||||
)}
|
||||
|
||||
{modelPicker && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
Choose a model ({modelPicker.length} available)
|
||||
</Text>
|
||||
<Text dimColor>enter to select, esc to cancel</Text>
|
||||
<SelectInput
|
||||
items={modelPicker.map((m) => ({ key: m, label: m, value: m }))}
|
||||
limit={10}
|
||||
initialIndex={Math.max(0, modelPicker.indexOf(hooks.config().model))}
|
||||
onSelect={(item) => {
|
||||
setModelPicker(undefined);
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchModel(item.value) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Picker
|
||||
title={`Choose a model (${modelPicker.length} available)`}
|
||||
options={modelPicker.map((m) => ({ value: m, label: m }))}
|
||||
current={hooks.config().model}
|
||||
onSelect={(value) => {
|
||||
setModelPicker(undefined);
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchModel(value) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{agentPicker && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
Choose an agent
|
||||
</Text>
|
||||
<Text dimColor>enter to select, esc to cancel</Text>
|
||||
<SelectInput
|
||||
items={VARIANTS.map((v) => ({
|
||||
key: v.name,
|
||||
label: `${v.name.padEnd(8)} ${v.summary}`,
|
||||
value: v.name,
|
||||
}))}
|
||||
limit={8}
|
||||
initialIndex={Math.max(
|
||||
0,
|
||||
VARIANTS.findIndex((v) => v.name === hooks.agentName()),
|
||||
)}
|
||||
onSelect={(item) => {
|
||||
setAgentPicker(false);
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchAgent(item.value) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Picker
|
||||
title="Choose an agent"
|
||||
options={VARIANTS.map((v) => ({ value: v.name, label: `${v.name.padEnd(8)} ${v.summary}` }))}
|
||||
current={hooks.agentName()}
|
||||
limit={8}
|
||||
onSelect={(value) => {
|
||||
setAgentPicker(false);
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchAgent(value) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{thinkPicker && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
Thinking level
|
||||
</Text>
|
||||
<Text dimColor>higher costs more and is slower; enter to select, esc to cancel</Text>
|
||||
<SelectInput
|
||||
items={THINKING_LEVELS.map((l) => ({ key: l, label: l, value: l }))}
|
||||
limit={8}
|
||||
initialIndex={Math.max(0, THINKING_LEVELS.indexOf(hooks.thinkingLevel() as (typeof THINKING_LEVELS)[number]))}
|
||||
onSelect={(item) => {
|
||||
setThinkPicker(false);
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchThinking(item.value) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Picker
|
||||
title="Thinking level"
|
||||
hint="higher costs more and is slower"
|
||||
options={THINKING_LEVELS.map((l) => ({ value: l, label: l }))}
|
||||
current={hooks.thinkingLevel()}
|
||||
limit={8}
|
||||
onSelect={(value) => {
|
||||
setThinkPicker(false);
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchThinking(value) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{busy && !modal && (
|
||||
@@ -1208,26 +937,42 @@ export function App({
|
||||
<ThinkingPanel text={thinking} expanded={thinkingOpen} />
|
||||
{active && <ActiveTool name={active.name} {...(active.detail ? { detail: active.detail } : {})} />}
|
||||
<OutputPanel text={toolOutput} />
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" /> <Text dimColor>working... esc to interrupt</Text>
|
||||
</Text>
|
||||
<Working seconds={seconds} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!modal && !anyPicker && (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginTop={1} width={termWidth}>
|
||||
<QueuePanel prompts={queue} />
|
||||
<Box>
|
||||
<Text color="cyan">{'> '}</Text>
|
||||
<PromptInput
|
||||
key={inputGeneration}
|
||||
value={draft}
|
||||
initialCursor={inputCursor}
|
||||
onChange={onDraftChange}
|
||||
onSubmit={submit}
|
||||
history={recall}
|
||||
onKey={handleInputKey}
|
||||
placeholder={busy ? 'type to queue for the next turn...' : 'ask shiro-neko... (/ commands, @ files)'}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={termWidth}
|
||||
borderStyle="round"
|
||||
borderColor={accent.mute}
|
||||
borderLeftColor={busy ? accent.warn : accent.user}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box>
|
||||
<Text color={accent.user} bold>
|
||||
{`${glyph.user} `}
|
||||
</Text>
|
||||
<PromptInput
|
||||
key={inputGeneration}
|
||||
value={draft}
|
||||
initialCursor={inputCursor}
|
||||
onChange={onDraftChange}
|
||||
onSubmit={submit}
|
||||
history={recall}
|
||||
onKey={handleInputKey}
|
||||
placeholder={busy ? 'type to queue for the next turn…' : 'ask shiro-neko… (/ commands, @ files)'}
|
||||
/>
|
||||
</Box>
|
||||
<InputStatus
|
||||
agent={hooks.agentName()}
|
||||
model={hooks.config().model}
|
||||
right={`${hooks.thinkingLevel()} ${glyph.info} ${session.activeTools().length} tools`}
|
||||
width={termWidth - 6}
|
||||
/>
|
||||
</Box>
|
||||
{fileOpen ? (
|
||||
@@ -1240,17 +985,15 @@ export function App({
|
||||
) : (
|
||||
menuOpen && <CommandMenu matches={matches} index={Math.min(menuIndex, matches.length - 1)} />
|
||||
)}
|
||||
<StatusBar
|
||||
model={hooks.config().model}
|
||||
agent={hooks.agentName()}
|
||||
thinking={hooks.thinkingLevel()}
|
||||
<Footer
|
||||
busy={busy}
|
||||
width={termWidth}
|
||||
contextTokens={session.estimatedTokens()}
|
||||
contextLimit={session.compactThreshold()}
|
||||
cost={(() => {
|
||||
const spend = costOf(hooks.config().model, session.inputTokens, session.outputTokens);
|
||||
return spend === undefined ? 'unpriced' : formatUsd(spend);
|
||||
})()}
|
||||
toolCount={session.activeTools().length}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import React from 'react';
|
||||
import type { ApprovalDecision, ApprovalRequest } from '../session';
|
||||
import { Diff } from './Diff';
|
||||
import { accent, glyph } from './theme';
|
||||
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={accent.warn} paddingX={1}>
|
||||
<Text color={accent.warn} bold>
|
||||
{`${glyph.warn} ${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} />
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Text color={accent.ok} bold>
|
||||
y
|
||||
</Text>
|
||||
<Text dimColor>{` allow once ${glyph.sep} `}</Text>
|
||||
<Text color={accent.ok} bold>
|
||||
a
|
||||
</Text>
|
||||
<Text dimColor>{` always allow ${grant} ${glyph.sep} `}</Text>
|
||||
<Text color={accent.err} bold>
|
||||
n
|
||||
</Text>
|
||||
<Text dimColor> deny</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+9
-4
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
|
||||
import type { AskRequest } from '../ask';
|
||||
import { InlineMarkdown } from './Markdown';
|
||||
import { PromptInput } from './PromptInput';
|
||||
import { accent, glyph } from './theme';
|
||||
|
||||
export type AskPending = { req: AskRequest; resolve: (answers: string[] | undefined) => void };
|
||||
|
||||
@@ -70,8 +71,8 @@ export function AskPanel({ pending }: { pending: AskPending }) {
|
||||
const detailOf = (label: string) => (options ?? []).find((o) => o.label === label)?.detail;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="double" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>
|
||||
<Box flexDirection="column" borderStyle="double" borderColor={accent.warn} paddingX={1}>
|
||||
<Text color={accent.warn} bold>
|
||||
shiro is asking
|
||||
</Text>
|
||||
<Box marginBottom={1}>
|
||||
@@ -80,7 +81,9 @@ export function AskPanel({ pending }: { pending: AskPending }) {
|
||||
|
||||
{typing ? (
|
||||
<Box>
|
||||
<Text color="yellow">{'> '}</Text>
|
||||
<Text color={accent.warn} bold>
|
||||
{`${glyph.user} `}
|
||||
</Text>
|
||||
<PromptInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
@@ -107,7 +110,9 @@ export function AskPanel({ pending }: { pending: AskPending }) {
|
||||
/>
|
||||
{draft.length > 0 && <Text dimColor>{draft}</Text>}
|
||||
<Text dimColor>
|
||||
{multiple ? 'space/enter toggles, pick submit when done' : 'enter to choose'} | esc to skip
|
||||
{multiple
|
||||
? `space/enter toggles, pick submit when done ${glyph.sep} esc to skip`
|
||||
: `enter to choose ${glyph.sep} esc to skip`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
+39
-24
@@ -1,11 +1,15 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import React from 'react';
|
||||
import { accent, glyph } from './theme';
|
||||
|
||||
export type DiffLine = { kind: 'context' | 'add' | 'remove'; text: string };
|
||||
export type DiffLine = { kind: 'context' | 'add' | 'remove'; text: string; at: number };
|
||||
|
||||
/**
|
||||
* Line-level diff by longest common subsequence. O(n*m) is fine here because an
|
||||
* edit_file payload is a handful of lines, not a whole file.
|
||||
*
|
||||
* `at` is the 1-based line number in the file the line came from: the reader's copy
|
||||
* for a removal, the changed copy for an addition, either for context.
|
||||
*/
|
||||
export function diffLines(before: string, after: string): DiffLine[] {
|
||||
const a = before.split('\n');
|
||||
@@ -25,44 +29,59 @@ export function diffLines(before: string, after: string): DiffLine[] {
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ kind: 'context', text: a[i]! });
|
||||
out.push({ kind: 'context', text: a[i]!, at: i + 1 });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
|
||||
out.push({ kind: 'remove', text: a[i]! });
|
||||
out.push({ kind: 'remove', text: a[i]!, at: i + 1 });
|
||||
i++;
|
||||
} else {
|
||||
out.push({ kind: 'add', text: b[j]! });
|
||||
out.push({ kind: 'add', text: b[j]!, at: j + 1 });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) out.push({ kind: 'remove', text: a[i++]! });
|
||||
while (j < m) out.push({ kind: 'add', text: b[j++]! });
|
||||
while (i < n) out.push({ kind: 'remove', text: a[i]!, at: i + 1 }), i++;
|
||||
while (j < m) out.push({ kind: 'add', text: b[j]!, at: j + 1 }), j++;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drops runs of unchanged lines longer than `context` on both sides of a change. */
|
||||
export function collapseContext(lines: DiffLine[], context = 2): (DiffLine | { kind: 'gap'; count: number })[] {
|
||||
/**
|
||||
* Drops runs of unchanged lines longer than `context` on both sides of a change.
|
||||
*
|
||||
* A gap carries the line range it hides rather than only a count: "27 unchanged
|
||||
* lines" says how much was skipped, "lines 1-27" says where in the file the reader
|
||||
* is, which is what they actually need when they go and open it.
|
||||
*/
|
||||
export function collapseContext(
|
||||
lines: DiffLine[],
|
||||
context = 2,
|
||||
): (DiffLine | { kind: 'gap'; count: number; from: number; to: number })[] {
|
||||
const keep = new Set<number>();
|
||||
lines.forEach((line, i) => {
|
||||
if (line.kind === 'context') return;
|
||||
for (let k = i - context; k <= i + context; k++) if (k >= 0 && k < lines.length) keep.add(k);
|
||||
});
|
||||
|
||||
const out: (DiffLine | { kind: 'gap'; count: number })[] = [];
|
||||
let skipped = 0;
|
||||
const out: (DiffLine | { kind: 'gap'; count: number; from: number; to: number })[] = [];
|
||||
let skipped: DiffLine[] = [];
|
||||
lines.forEach((line, i) => {
|
||||
if (keep.has(i)) {
|
||||
if (skipped > 0) {
|
||||
out.push({ kind: 'gap', count: skipped });
|
||||
skipped = 0;
|
||||
if (skipped.length > 0) {
|
||||
const first = skipped[0]!;
|
||||
const last = skipped.at(-1)!;
|
||||
out.push({ kind: 'gap', count: skipped.length, from: first.at, to: last.at });
|
||||
skipped = [];
|
||||
}
|
||||
out.push(line);
|
||||
} else {
|
||||
skipped++;
|
||||
skipped.push(line);
|
||||
}
|
||||
});
|
||||
if (skipped > 0) out.push({ kind: 'gap', count: skipped });
|
||||
if (skipped.length > 0) {
|
||||
const first = skipped[0]!;
|
||||
const last = skipped.at(-1)!;
|
||||
out.push({ kind: 'gap', count: skipped.length, from: first.at, to: last.at });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -79,25 +98,21 @@ export function Diff({ before, after, path }: { before: string; after: string; p
|
||||
<Box flexDirection="column">
|
||||
{path && (
|
||||
<Text>
|
||||
<Text bold>{path}</Text> <Text color="green">+{added}</Text> <Text color="red">-{removed}</Text>
|
||||
<Text bold>{path}</Text> <Text color={accent.ok}>+{added}</Text> <Text color={accent.err}>-{removed}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{shown.map((line, i) =>
|
||||
line.kind === 'gap' ? (
|
||||
<Text key={i} dimColor>
|
||||
{` ... ${line.count} unchanged line${line.count === 1 ? '' : 's'}`}
|
||||
</Text>
|
||||
<Text key={i} dimColor>{` ${glyph.fold} lines ${line.from}-${line.to} unchanged`}</Text>
|
||||
) : (
|
||||
<Text
|
||||
key={i}
|
||||
color={line.kind === 'add' ? 'green' : line.kind === 'remove' ? 'red' : undefined}
|
||||
color={line.kind === 'add' ? accent.ok : line.kind === 'remove' ? accent.err : undefined}
|
||||
dimColor={line.kind === 'context'}
|
||||
>
|
||||
{`${line.kind === 'add' ? ' + ' : line.kind === 'remove' ? ' - ' : ' '}${line.text}`}
|
||||
</Text>
|
||||
>{` ${String(line.at).padStart(3)} ${line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : glyph.sep} ${line.text}`}</Text>
|
||||
),
|
||||
)}
|
||||
{hidden > 0 && <Text dimColor>{` ... ${hidden} more diff lines`}</Text>}
|
||||
{hidden > 0 && <Text dimColor>{` ${glyph.fold} ${hidden} more diff lines`}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import React from 'react';
|
||||
import { SidePanel } from './Panels';
|
||||
import { accent, glyph } from './theme';
|
||||
|
||||
export type HeaderFact = {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Defaults to the quiet metadata colour; set for anything the user must not miss. */
|
||||
tone?: 'warn' | 'err' | 'ok' | 'info';
|
||||
};
|
||||
|
||||
const TONE_COLOR: Record<NonNullable<HeaderFact['tone']>, string> = {
|
||||
warn: accent.warn,
|
||||
err: accent.err,
|
||||
ok: accent.ok,
|
||||
info: accent.info,
|
||||
};
|
||||
|
||||
/**
|
||||
* The welcome dashboard, shown once before the first turn, in OpenCode's grammar.
|
||||
*
|
||||
* The session is introduced by a banner (what this conversation is), then the
|
||||
* environment is grouped into a labelled panel so the eye scans one label rather
|
||||
* than a wall of text. A final meta bar carries cwd and version, the two facts a
|
||||
* bug report needs. Facts that demand attention — a missing provider, a failed
|
||||
* plugin, `--yolo` — are lifted out of the quiet layer with colour, because a
|
||||
* warning rendered dim is a warning nobody reads.
|
||||
*/
|
||||
export function Header({
|
||||
version,
|
||||
provider,
|
||||
model,
|
||||
sessionId,
|
||||
cwd,
|
||||
title,
|
||||
facts,
|
||||
}: {
|
||||
version: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
facts: readonly HeaderFact[];
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<SidePanel label={title ?? 'new session'} tone={accent.user}>
|
||||
<Box>
|
||||
{model ? (
|
||||
<>
|
||||
<Text color={accent.ok}>{`${glyph.ok} `}</Text>
|
||||
<Text bold>
|
||||
{provider ? `${provider}/` : ''}
|
||||
{model}
|
||||
</Text>
|
||||
<Text dimColor>{` ${glyph.sep} session ${sessionId}`}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text color={accent.warn}>{`${glyph.warn} `}</Text>
|
||||
<Text color={accent.warn}>no provider configured</Text>
|
||||
<Text dimColor>{` ${glyph.sep} run /provider to begin`}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</SidePanel>
|
||||
|
||||
{facts.length > 0 && (
|
||||
<SidePanel label="environment" tone={accent.ok}>
|
||||
{facts.map((f, i) => (
|
||||
<Box key={i}>
|
||||
<Text dimColor>{f.label.padEnd(14)}</Text>
|
||||
<Text color={f.tone ? TONE_COLOR[f.tone] : undefined} dimColor={f.tone === undefined}>
|
||||
{f.value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SidePanel>
|
||||
)}
|
||||
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>{cwd}</Text>
|
||||
<Text dimColor>{` ${glyph.sep} `}</Text>
|
||||
<Text dimColor>{`shiro-neko ${version}`}</Text>
|
||||
<Text dimColor>{` ${glyph.sep} `}</Text>
|
||||
<Text color={accent.user}>/help for commands</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+17
-1
@@ -48,7 +48,22 @@ function BlockView({ block, width }: { block: Block; width: number }) {
|
||||
);
|
||||
case 'paragraph':
|
||||
return <Inline spans={block.spans} />;
|
||||
case 'bullet':
|
||||
case 'bullet': {
|
||||
// A markdown task list: `- [x] done`. The checkbox is the marker, and the
|
||||
// text of a done task reads as already read — dimmed and struck through,
|
||||
// the same treatment the todo panel gives a finished entry.
|
||||
const task = /^\[( |x)\]\s+(.*)$/.exec(block.spans.map((s) => s.text).join(''));
|
||||
if (task) {
|
||||
const done = task[1] === 'x';
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{`${' '.repeat(block.indent)}${done ? '[x]' : '[ ]'} `}</Text>
|
||||
<Text strikethrough={done} dimColor={done}>
|
||||
<Inline spans={parseInline(task[2]!)} />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{`${' '.repeat(block.indent)}${block.marker} `}</Text>
|
||||
@@ -57,6 +72,7 @@ function BlockView({ block, width }: { block: Block; width: number }) {
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
case 'quote':
|
||||
return (
|
||||
<Box>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import TextInput from 'ink-text-input';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { McpServerConfig } from '../mcp';
|
||||
import { Frame as Panel, Row } from './Pickers';
|
||||
|
||||
/** The shared frame plus the cancel hint every step of this wizard carries. */
|
||||
function Frame({ children, ...rest }: React.ComponentProps<typeof Panel>) {
|
||||
return (
|
||||
<Panel {...rest}>
|
||||
{children}
|
||||
<Text dimColor>esc to cancel</Text>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export type McpAddResult = { name: string; config: McpServerConfig };
|
||||
|
||||
type Kind = 'local' | 'remote';
|
||||
|
||||
type Step =
|
||||
| { name: 'pick-kind' }
|
||||
| { name: 'server-name'; kind: Kind }
|
||||
| { name: 'command'; server: string }
|
||||
| { name: 'args'; server: string; command: string }
|
||||
| { name: 'url'; server: string }
|
||||
| { name: 'headers'; server: string; url: string };
|
||||
|
||||
/**
|
||||
* A server name has to survive being spliced into a tool name.
|
||||
*
|
||||
* Tools are registered as `mcp__<server>__<tool>`, so a name containing `__` or a
|
||||
* space produces a tool the model cannot reliably call and two servers whose
|
||||
* namespaces can collide. Rejecting it here beats a confusing failure at connect.
|
||||
*/
|
||||
export function invalidName(name: string): string | undefined {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) return 'a name is required';
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) return 'use letters, digits, and hyphens only';
|
||||
if (trimmed.includes('__')) return 'double underscores clash with the mcp__server__tool naming';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** `KEY: value, OTHER: value` into a header object. Empty input means no headers. */
|
||||
export function parseHeaders(raw: string): Record<string, string> | undefined {
|
||||
const pairs = raw
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const at = part.indexOf(':');
|
||||
return at === -1 ? undefined : ([part.slice(0, at).trim(), part.slice(at + 1).trim()] as const);
|
||||
})
|
||||
.filter((p): p is readonly [string, string] => p !== undefined && p[0].length > 0);
|
||||
|
||||
return pairs.length > 0 ? Object.fromEntries(pairs) : undefined;
|
||||
}
|
||||
|
||||
/** Splits a command line on spaces, keeping quoted runs together. */
|
||||
export function splitArgs(raw: string): string[] {
|
||||
const matched = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
||||
return matched.map((a) => a.replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one MCP server, local or remote, without hand-editing config.json.
|
||||
*
|
||||
* The two kinds need different fields — a command and its arguments against a URL
|
||||
* and its headers — so the wizard branches rather than showing a form with half of
|
||||
* it inapplicable. It collects and returns; writing the config is the caller's.
|
||||
*/
|
||||
export function McpAdd({
|
||||
existing,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
existing: string[];
|
||||
onDone: (result: McpAddResult) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<Step>({ name: 'pick-kind' });
|
||||
const [draft, setDraft] = useState('');
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.escape) onCancel();
|
||||
});
|
||||
|
||||
const advance = useCallback((next: Step) => {
|
||||
setDraft('');
|
||||
setError(undefined);
|
||||
setStep(next);
|
||||
}, []);
|
||||
|
||||
const submitName = useCallback(
|
||||
(kind: Kind, value: string) => {
|
||||
const bad = invalidName(value);
|
||||
if (bad) return setError(bad);
|
||||
const server = value.trim();
|
||||
if (existing.includes(server)) return setError(`${server} is already configured`);
|
||||
advance(kind === 'local' ? { name: 'command', server } : { name: 'url', server });
|
||||
},
|
||||
[advance, existing],
|
||||
);
|
||||
|
||||
switch (step.name) {
|
||||
case 'pick-kind':
|
||||
return (
|
||||
<Frame
|
||||
title="Add an MCP server"
|
||||
hint={existing.length > 0 ? `${existing.length} configured: ${existing.join(', ')}` : 'none configured yet'}
|
||||
>
|
||||
<SelectInput
|
||||
items={[
|
||||
{ key: 'local', label: 'local a command on this machine, over stdio', value: 'local' },
|
||||
{ key: 'remote', label: 'remote an http or sse endpoint', value: 'remote' },
|
||||
]}
|
||||
onSelect={(item) => advance({ name: 'server-name', kind: item.value as Kind })}
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'server-name':
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.kind === 'local' ? 'Local' : 'Remote'} server: name`}
|
||||
hint="used in the tool name as mcp__<name>__<tool>"
|
||||
error={error}
|
||||
>
|
||||
<Row label="name">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => submitName(step.kind, v)}
|
||||
placeholder="filesystem"
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'command':
|
||||
return (
|
||||
<Frame title={`${step.server}: command`} hint="the executable to run, e.g. npx or bun" error={error}>
|
||||
<Row label="command">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) =>
|
||||
v.trim() ? advance({ name: 'args', server: step.server, command: v.trim() }) : setError('a command is required')
|
||||
}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'args':
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.server}: arguments`}
|
||||
hint={`${step.command} <args> - enter with nothing to pass none`}
|
||||
>
|
||||
<Row label="args">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => {
|
||||
const args = splitArgs(v.trim());
|
||||
onDone({
|
||||
name: step.server,
|
||||
config: { command: step.command, ...(args.length > 0 ? { args } : {}) },
|
||||
});
|
||||
}}
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem ."
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'url':
|
||||
return (
|
||||
<Frame title={`${step.server}: endpoint URL`} hint="http or https, the server's MCP endpoint" error={error}>
|
||||
<Row label="url">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => {
|
||||
const url = v.trim();
|
||||
if (!/^https?:\/\//i.test(url)) return setError('the URL must start with http:// or https://');
|
||||
advance({ name: 'headers', server: step.server, url });
|
||||
}}
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'headers':
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.server}: headers`}
|
||||
hint="KEY: value, comma separated - enter with nothing for none"
|
||||
>
|
||||
<Row label="headers">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => {
|
||||
const headers = parseHeaders(v);
|
||||
onDone({
|
||||
name: step.server,
|
||||
config: { url: step.url, ...(headers ? { headers } : {}) },
|
||||
});
|
||||
}}
|
||||
placeholder="Authorization: Bearer sk-..."
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-32
@@ -5,6 +5,7 @@ import Spinner from 'ink-spinner';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { Config, ProviderName } from '../config';
|
||||
import { fetchModels, PRESETS, readClaudeCodeSettings, type ProviderPreset } from '../providers';
|
||||
import { Frame, Row } from './Pickers';
|
||||
|
||||
export type OnboardResult = {
|
||||
presetId: string;
|
||||
@@ -215,35 +216,3 @@ export function Onboard({
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Frame({
|
||||
title,
|
||||
hint,
|
||||
warning,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: 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>}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="cyan">{label}: </Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+237
-60
@@ -4,28 +4,30 @@ import React from 'react';
|
||||
import { TODO_MARK, type Todo } from '../notebook';
|
||||
import type { SubagentKind } from '../subagent';
|
||||
import { InlineMarkdown } from './Markdown';
|
||||
import { accent, glyph, meter } from './theme';
|
||||
|
||||
const STATUS_COLOR: Record<Todo['status'], string | undefined> = {
|
||||
pending: undefined,
|
||||
in_progress: 'cyan',
|
||||
done: 'green',
|
||||
blocked: 'red',
|
||||
in_progress: accent.user,
|
||||
done: accent.ok,
|
||||
blocked: accent.err,
|
||||
};
|
||||
|
||||
/** Task list with a progress bar, shown above the input while a list exists. */
|
||||
export function TodoPanel({ todos, width = 40 }: { todos: Todo[]; width?: number }) {
|
||||
/** Task list with a progress meter, shown above the input while a list exists. */
|
||||
export function TodoPanel({ todos, width = 24 }: { todos: Todo[]; width?: number }) {
|
||||
const done = todos.filter((t) => t.status === 'done').length;
|
||||
const blocked = todos.filter((t) => t.status === 'blocked').length;
|
||||
const filled = todos.length === 0 ? 0 : Math.round((done / todos.length) * width);
|
||||
const ratio = todos.length === 0 ? 0 : done / todos.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} marginBottom={1}>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={accent.mute} paddingX={1} marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold>tasks </Text>
|
||||
<Text color="green">{'#'.repeat(filled)}</Text>
|
||||
<Text dimColor>{'.'.repeat(Math.max(0, width - filled))}</Text>
|
||||
<Text bold color={accent.user}>
|
||||
tasks{' '}
|
||||
</Text>
|
||||
<Text color={accent.ok}>{meter(ratio, width)}</Text>
|
||||
<Text dimColor>{` ${done}/${todos.length}`}</Text>
|
||||
{blocked > 0 && <Text color="red">{` ${blocked} blocked`}</Text>}
|
||||
{blocked > 0 && <Text color={accent.err}>{` ${glyph.blocked} ${blocked} blocked`}</Text>}
|
||||
</Box>
|
||||
{todos.map((t, i) => (
|
||||
<Box key={i}>
|
||||
@@ -60,7 +62,7 @@ export type SubagentView = {
|
||||
const KIND_LABEL: Record<SubagentKind, string> = { explore: 'explore', review: 'review', worker: 'worker' };
|
||||
|
||||
/** A worker can write, so its panel entry has to be distinguishable at a glance. */
|
||||
const KIND_COLOUR: Record<SubagentKind, string> = { explore: 'cyan', review: 'blue', worker: 'yellow' };
|
||||
const KIND_COLOUR: Record<SubagentKind, string> = { explore: accent.user, review: accent.info, worker: accent.warn };
|
||||
|
||||
/**
|
||||
* Live view of delegated work.
|
||||
@@ -74,39 +76,66 @@ export function SubagentPanel({ agents, steps = 4 }: { agents: SubagentView[]; s
|
||||
if (agents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} marginBottom={1}>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={accent.tool} paddingX={1} marginBottom={1}>
|
||||
{agents.map((a) => (
|
||||
<Box key={a.id} flexDirection="column">
|
||||
<Box>
|
||||
{a.status === 'running' ? (
|
||||
<Text color="magenta">
|
||||
<Text color={accent.tool}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={a.status === 'done' ? 'green' : 'red'}>{a.status === 'done' ? '*' : 'x'}</Text>
|
||||
<Text color={a.status === 'done' ? accent.ok : accent.err}>
|
||||
{a.status === 'done' ? glyph.ok : glyph.err}
|
||||
</Text>
|
||||
)}
|
||||
<Text bold color={KIND_COLOUR[a.kind]}>{` ${KIND_LABEL[a.kind]}`}</Text>
|
||||
{a.kind === 'worker' && <Text color="yellow">{' (writes)'}</Text>}
|
||||
<Text>{`: ${a.description}`}</Text>
|
||||
{a.kind === 'worker' && <Text color={accent.warn}>{' (writes)'}</Text>}
|
||||
<Text>{` ${glyph.sep} ${a.description}`}</Text>
|
||||
<Text dimColor>{` ${a.steps.length} step${a.steps.length === 1 ? '' : 's'}`}</Text>
|
||||
</Box>
|
||||
{a.steps.slice(-steps).map((s, i) => (
|
||||
<Box key={i} flexDirection="column">
|
||||
<Text dimColor>{` ${s.tool}(${s.summary.slice(0, 58)})`}</Text>
|
||||
{s.outcome !== undefined && (
|
||||
<Text color={s.ok === false ? 'red' : undefined} dimColor={s.ok !== false}>
|
||||
{` ${s.ok === false ? 'x' : '->'} ${s.outcome}`}
|
||||
<Text color={s.ok === false ? accent.err : undefined} dimColor={s.ok !== false}>
|
||||
{` ${s.ok === false ? glyph.err : glyph.result} ${s.outcome}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{a.error && <Text color="red">{` ${a.error}`}</Text>}
|
||||
{a.error && <Text color={accent.err}>{` ${a.error}`}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Elapsed time in the shortest form that still reads as a duration. */
|
||||
const elapsed = (seconds: number) =>
|
||||
seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
|
||||
/** Seconds before the count appears: a timer starting at 0s is noise, not feedback. */
|
||||
const SLOW_AFTER = 10;
|
||||
|
||||
/**
|
||||
* The spinner line while the model works.
|
||||
*
|
||||
* The elapsed count starts only once a turn is slow enough to wonder about. Before
|
||||
* that it is a number nobody reads; after it, it is the difference between "this is
|
||||
* taking a while" and "has this hung?".
|
||||
*/
|
||||
export function Working({ seconds }: { seconds: number }) {
|
||||
return (
|
||||
<Text color={accent.warn}>
|
||||
<Spinner type="dots" />{' '}
|
||||
<Text dimColor>
|
||||
{seconds >= SLOW_AFTER ? `working ${elapsed(seconds)} ${glyph.sep} esc to interrupt` : `working ${glyph.sep} esc to interrupt`}
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** Live tail of a running shell command. */
|
||||
export function OutputPanel({ text, lines = 8 }: { text: string; lines?: number }) {
|
||||
if (text.length === 0) return null;
|
||||
@@ -117,7 +146,7 @@ export function OutputPanel({ text, lines = 8 }: { text: string; lines?: number
|
||||
.slice(-lines)
|
||||
.map((l, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{` | ${l}`}
|
||||
{` ${glyph.sep} ${l}`}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
@@ -136,10 +165,10 @@ export function ActiveTool({ name, detail = [] }: { name: string; detail?: reado
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="magenta">
|
||||
<Text color={accent.tool}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text bold>{` ${name}`}</Text>
|
||||
<Text bold color={accent.tool}>{` ${name}`}</Text>
|
||||
{detail[0] !== undefined && <Text dimColor>{` ${detail[0]}`}</Text>}
|
||||
</Box>
|
||||
{detail.slice(1, 6).map((d, i) => (
|
||||
@@ -147,7 +176,7 @@ export function ActiveTool({ name, detail = [] }: { name: string; detail?: reado
|
||||
{` ${d}`}
|
||||
</Text>
|
||||
))}
|
||||
{detail.length > 6 && <Text dimColor>{` ... ${detail.length - 6} more`}</Text>}
|
||||
{detail.length > 6 && <Text dimColor>{` ${glyph.fold} ${detail.length - 6} more`}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -164,12 +193,12 @@ export function ThinkingPanel({ text, expanded, lines = 8 }: { text: string; exp
|
||||
const tokens = Math.round(text.length / 4);
|
||||
|
||||
if (!expanded) {
|
||||
return <Text dimColor>{`thinking... ~${tokens} tokens ctrl-r to expand`}</Text>;
|
||||
return <Text dimColor>{`thinking ${glyph.fold} ~${tokens} tokens ${glyph.sep} ctrl-r to expand`}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text dimColor>{`thinking ~${tokens} tokens ctrl-r to collapse`}</Text>
|
||||
<Text dimColor>{`thinking ~${tokens} tokens ${glyph.sep} ctrl-r to collapse`}</Text>
|
||||
{text
|
||||
.split('\n')
|
||||
.slice(-lines)
|
||||
@@ -187,10 +216,10 @@ export function QueuePanel({ prompts }: { prompts: readonly string[] }) {
|
||||
if (prompts.length === 0) return null;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="cyan">{`queued: ${prompts.length}`}</Text>
|
||||
<Text color={accent.user}>{`${glyph.queued} queued: ${prompts.length}`}</Text>
|
||||
{prompts.map((p, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{` ${i + 1}. ${p.length > 70 ? `${p.slice(0, 70)}...` : p}`}
|
||||
{` ${i + 1}. ${p.length > 70 ? `${p.slice(0, 70)}…` : p}`}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
@@ -212,7 +241,7 @@ export function FileMenu({
|
||||
if (loading) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>indexing files...</Text>
|
||||
<Text dimColor>indexing files…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -228,17 +257,25 @@ export function FileMenu({
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{paths.map((p, i) => (
|
||||
<Text key={p} color={i === index ? 'cyan' : undefined} dimColor={i !== index}>
|
||||
{i === index ? '> ' : ' '}
|
||||
<Text key={p} color={i === index ? accent.user : undefined} dimColor={i !== index}>
|
||||
{i === index ? `${glyph.user} ` : ' '}
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
<Text dimColor>up/down move | tab or enter insert | esc dismiss</Text>
|
||||
<Text dimColor>{`↑↓ move ${glyph.sep} tab/enter insert ${glyph.sep} esc dismiss`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Status line under the transcript: model, agent, thinking, context, spend. */
|
||||
/**
|
||||
* The persistent status line under the input.
|
||||
*
|
||||
* Three glance groups separated by quiet pipes: what is running (model, agent,
|
||||
* thinking), how full the context is (a meter plus a percentage), and what it has
|
||||
* cost. The context meter is the one piece of live information that changes
|
||||
* mid-session, so it is the only element with a bar; everything else stays text so
|
||||
* the bar is the thing the eye lands on.
|
||||
*/
|
||||
export function StatusBar({
|
||||
model,
|
||||
agent,
|
||||
@@ -259,18 +296,146 @@ export function StatusBar({
|
||||
}) {
|
||||
const pct = contextLimit ? Math.min(100, Math.round((contextTokens / contextLimit) * 100)) : undefined;
|
||||
// Amber from two thirds, red once compaction is imminent: the point is to warn
|
||||
// before a turn silently loses its history, not after.
|
||||
const contextColor = pct === undefined ? undefined : pct >= 90 ? 'red' : pct >= 66 ? 'yellow' : undefined;
|
||||
// before a turn silently loses its history, not after. Past 90 the colour is
|
||||
// backed by words, because a reader watching the transcript is not watching this.
|
||||
const contextColor = pct === undefined ? undefined : pct >= 90 ? accent.err : pct >= 66 ? accent.warn : undefined;
|
||||
const sep = <Text dimColor>{` ${glyph.sep} `}</Text>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{`${model} `}</Text>
|
||||
<Text color="cyan">{agent}</Text>
|
||||
<Text dimColor>{`/${thinking} ${toolCount} tools `}</Text>
|
||||
<Text dimColor>{model}</Text>
|
||||
{sep}
|
||||
<Text color={accent.user}>{agent}</Text>
|
||||
<Text dimColor>{`/${thinking}`}</Text>
|
||||
{sep}
|
||||
<Text dimColor>{`${toolCount} tools`}</Text>
|
||||
{sep}
|
||||
<Text color={contextColor} dimColor={contextColor === undefined}>
|
||||
{pct === undefined ? `~${contextTokens} ctx` : `${pct}% ctx`}
|
||||
{pct === undefined ? `~${contextTokens} ctx` : `${meter(pct / 100, 8)} ${pct}%`}
|
||||
</Text>
|
||||
<Text dimColor>{` ${cost}`}</Text>
|
||||
{pct !== undefined && pct >= 90 && <Text color={accent.err}>{' compacting soon'}</Text>}
|
||||
{sep}
|
||||
<Text dimColor>{cost}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line identity row inside the input box, OpenCode-style.
|
||||
*
|
||||
* Agent and model are the two things a prompt is answered *by*, so they sit
|
||||
* directly under the cursor rather than in a footer the eye has to travel to.
|
||||
* `right` carries the quieter facts (tool count, thinking level) pushed to the
|
||||
* far edge of the box, so the row reads as two anchored groups.
|
||||
*/
|
||||
export function InputStatus({
|
||||
agent,
|
||||
model,
|
||||
right,
|
||||
width,
|
||||
}: {
|
||||
agent: string;
|
||||
model: string;
|
||||
right?: string;
|
||||
width: number;
|
||||
}) {
|
||||
const left = ` ${agent} · ${model}`;
|
||||
const rightText = right ? `${right} ` : '';
|
||||
const gap = Math.max(1, width - left.length - rightText.length);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={accent.user} bold>
|
||||
{agent}
|
||||
</Text>
|
||||
<Text dimColor>{` ${glyph.info} ${model}`}</Text>
|
||||
<Text dimColor>{' '.repeat(gap)}</Text>
|
||||
{right !== undefined && <Text dimColor>{right}</Text>}
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full-width footer beneath the input, OpenCode-style.
|
||||
*
|
||||
* Key hints live on the left (what you can press), the live numbers on the right
|
||||
* (context and cost). Spreading them to opposite edges means neither has to
|
||||
* compete for the same glance, and the meter stays the most saturated thing on
|
||||
* the line so the eye finds it first when a turn runs long.
|
||||
*/
|
||||
export function Footer({
|
||||
busy,
|
||||
contextTokens,
|
||||
contextLimit,
|
||||
cost,
|
||||
width,
|
||||
}: {
|
||||
busy: boolean;
|
||||
contextTokens: number;
|
||||
contextLimit?: number;
|
||||
cost: string;
|
||||
width: number;
|
||||
}) {
|
||||
const pct = contextLimit ? Math.min(100, Math.round((contextTokens / contextLimit) * 100)) : undefined;
|
||||
const contextColor = pct === undefined ? undefined : pct >= 90 ? accent.err : pct >= 66 ? accent.warn : undefined;
|
||||
const hint = busy ? `${glyph.bullet} esc interrupt` : `/ commands ${glyph.sep} @ files ${glyph.sep} ↑ history`;
|
||||
const right =
|
||||
pct === undefined ? `~${contextTokens} ctx ${glyph.sep} ${cost}` : `${meter(pct / 100, 8)} ${pct}% ${glyph.sep} ${cost}`;
|
||||
const gap = Math.max(1, width - hint.length - right.length - 1);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{hint}</Text>
|
||||
<Text>{' '.repeat(gap)}</Text>
|
||||
<Text color={contextColor} dimColor={contextColor === undefined}>
|
||||
{right}
|
||||
</Text>
|
||||
{pct !== undefined && pct >= 90 && <Text color={accent.err}>{' compacting soon'}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bordered panel with a coloured label header, OpenCode's sidebar grammar.
|
||||
*
|
||||
* Used for the welcome dashboard's grouped facts (capabilities, project, meta).
|
||||
* The label carries the accent; the rows stay quiet, so a stack of these reads
|
||||
* as labelled groups rather than as more transcript.
|
||||
*/
|
||||
export function SidePanel({
|
||||
label,
|
||||
children,
|
||||
width,
|
||||
tone = accent.ok,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
tone?: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={accent.mute}
|
||||
paddingX={1}
|
||||
marginBottom={1}
|
||||
{...(width !== undefined ? { width } : {})}
|
||||
>
|
||||
<Text color={tone} bold>
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** One `name value` row inside a SidePanel, name padded so a stack aligns. */
|
||||
export function SideRow({ name, value, nameWidth = 12, dim = true }: { name: string; value: string; nameWidth?: number; dim?: boolean }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor={dim}>{name.padEnd(nameWidth)}</Text>
|
||||
<Text>{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -280,21 +445,24 @@ export type PanelLine = { label: string; value: string };
|
||||
/** Bordered popup for a command's output, e.g. /skills or /cost. */
|
||||
export function InfoPanel({ title, hint, lines }: { title: string; hint?: string; lines: PanelLine[] | string }) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
|
||||
<Text color="cyan" bold>
|
||||
{title}
|
||||
</Text>
|
||||
{hint && <Text dimColor>{hint}</Text>}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={accent.user} paddingX={1} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={accent.user} bold>
|
||||
{title}
|
||||
</Text>
|
||||
{hint && <Text dimColor>{` ${hint}`}</Text>}
|
||||
</Box>
|
||||
{typeof lines === 'string' ? (
|
||||
<InlineMarkdown text={lines} />
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<Box key={i}>
|
||||
<Text color="gray">{l.label.padEnd(14)}</Text>
|
||||
<Text color={accent.mute}>{l.label.padEnd(14)}</Text>
|
||||
<Text>{l.value}</Text>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
<Text dimColor>esc to dismiss</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -307,7 +475,7 @@ export type RegistryRow = {
|
||||
installed?: boolean;
|
||||
};
|
||||
|
||||
const KIND_COLOR: Record<RegistryRow['kind'], string> = { skill: 'green', plugin: 'magenta' };
|
||||
const KIND_COLOR: Record<RegistryRow['kind'], string> = { skill: accent.ok, plugin: accent.tool };
|
||||
|
||||
/**
|
||||
* The registry index as a table.
|
||||
@@ -331,20 +499,22 @@ export function RegistryPanel({
|
||||
const width = Math.min(22, Math.max(...rows.map((r) => r.name.length)) + 1);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
|
||||
<Text color="cyan" bold>
|
||||
{title}
|
||||
</Text>
|
||||
{hint && <Text dimColor>{hint}</Text>}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={accent.user} paddingX={1} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={accent.user} bold>
|
||||
{title}
|
||||
</Text>
|
||||
{hint && <Text dimColor>{` ${hint}`}</Text>}
|
||||
</Box>
|
||||
{rows.map((r) => (
|
||||
<Box key={`${r.kind}:${r.name}`}>
|
||||
<Text color={KIND_COLOR[r.kind]}>{r.kind === 'skill' ? 'S' : 'P'} </Text>
|
||||
<Text bold>{r.name.padEnd(width)}</Text>
|
||||
<Text dimColor>{r.description.length > 58 ? `${r.description.slice(0, 58)}...` : r.description}</Text>
|
||||
{r.installed && <Text color="green">{' installed'}</Text>}
|
||||
<Text dimColor>{r.description.length > 58 ? `${r.description.slice(0, 58)}…` : r.description}</Text>
|
||||
{r.installed && <Text color={accent.ok}>{` ${glyph.ok} installed`}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
<Text dimColor>{'S skill P plugin | /registry add <name>'}</Text>
|
||||
<Text dimColor>{`S skill P plugin ${glyph.sep} /registry add <name> ${glyph.sep} esc to dismiss`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -374,8 +544,8 @@ export function InstallPrompt({
|
||||
const hidden = body.length - shown.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={accent.warn} paddingX={1}>
|
||||
<Text color={accent.warn} bold>
|
||||
{`install ${kind} "${name}"?`}
|
||||
</Text>
|
||||
<Text dimColor>{url}</Text>
|
||||
@@ -385,16 +555,23 @@ export function InstallPrompt({
|
||||
{` ${l}`}
|
||||
</Text>
|
||||
))}
|
||||
{hidden > 0 && <Text dimColor>{` ... ${hidden} more lines`}</Text>}
|
||||
{hidden > 0 && <Text dimColor>{` ${glyph.fold} ${hidden} more lines`}</Text>}
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="yellow">
|
||||
<Text color={accent.warn}>
|
||||
{kind === 'skill'
|
||||
? 'A skill is instructions the agent follows. This text joins your system prompt.'
|
||||
: 'A plugin adds refusal rules. It is data, not code: nothing here is executed.'}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="green">y</Text> install | <Text color="red">n</Text> cancel
|
||||
<Text color={accent.ok} bold>
|
||||
y
|
||||
</Text>
|
||||
<Text dimColor>{` install ${glyph.sep} `}</Text>
|
||||
<Text color={accent.err} bold>
|
||||
n
|
||||
</Text>
|
||||
<Text dimColor> cancel</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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';
|
||||
import { accent, glyph } from './theme';
|
||||
|
||||
/** 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 ? accent.user : undefined}>{i === index ? `${glyph.user} ` : ' '}</Text>
|
||||
<Text color={i === index ? accent.user : undefined} bold={i === index}>
|
||||
{`/${c.name}${c.arg ? ` ${c.arg}` : ''}`.padEnd(width)}
|
||||
</Text>
|
||||
<Text dimColor>{c.summary}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text dimColor>{`↑↓ move ${glyph.sep} tab complete ${glyph.sep} enter run ${glyph.sep} 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={accent.user} paddingX={1}>
|
||||
<Text color={accent.user} bold>
|
||||
{title}
|
||||
</Text>
|
||||
{hint && <Text dimColor>{hint}</Text>}
|
||||
{warning && <Text color={accent.warn}>could not list models: {warning}</Text>}
|
||||
{error && <Text color={accent.err}>{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={accent.user}>{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={accent.user} paddingX={1}>
|
||||
<Text color={accent.user} bold>
|
||||
{title}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{hint ? `${hint} ${glyph.sep} enter to select ${glyph.sep} esc to cancel` : `enter to select ${glyph.sep} 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>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,22 @@ export function PromptInput({
|
||||
setCursor(clamped);
|
||||
};
|
||||
|
||||
/** Position after the next run of spaces, i.e. the start of the following word. */
|
||||
const wordForward = (from: number) => {
|
||||
let at = from;
|
||||
while (at < value.length && value[at] === ' ') at++;
|
||||
while (at < value.length && value[at] !== ' ') at++;
|
||||
return at;
|
||||
};
|
||||
|
||||
/** Position before the run of spaces preceding the current word. */
|
||||
const wordBack = (from: number) => {
|
||||
let at = from;
|
||||
while (at > 0 && value[at - 1] === ' ') at--;
|
||||
while (at > 0 && value[at - 1] !== ' ') at--;
|
||||
return at;
|
||||
};
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (onKey?.(input, key as KeyLike)) return;
|
||||
@@ -98,6 +114,14 @@ export function PromptInput({
|
||||
return;
|
||||
}
|
||||
|
||||
// Word-wise motion. Terminals send ctrl-left/right as a modified arrow, but
|
||||
// Ink reports some of these sequences as a plain input with ctrl held rather
|
||||
// than as key.leftArrow, so both shapes are handled.
|
||||
const wordLeft = key.leftArrow && (key.ctrl || key.meta);
|
||||
const wordRight = key.rightArrow && (key.ctrl || key.meta);
|
||||
if (wordLeft) return setCursor((c) => wordBack(c));
|
||||
if (wordRight) return setCursor((c) => wordForward(c));
|
||||
|
||||
if (key.leftArrow) return setCursor((c) => Math.max(0, c - 1));
|
||||
if (key.rightArrow) return setCursor((c) => Math.min(value.length, c + 1));
|
||||
if (key.home || (key.ctrl && input === 'a')) return setCursor(0);
|
||||
@@ -105,6 +129,9 @@ export function PromptInput({
|
||||
|
||||
if (key.ctrl && input === 'k') return set(value.slice(0, cursor), cursor);
|
||||
if (key.ctrl && input === 'u') return set(value.slice(cursor), 0);
|
||||
// The delete key's forward cousin: without it, fixing a typo ahead of the
|
||||
// cursor means walking to the end or backspacing and retyping the tail.
|
||||
if (key.ctrl && input === 'd') return set(value.slice(0, cursor) + value.slice(cursor + 1), cursor);
|
||||
if (key.ctrl && input === 'w') {
|
||||
const upto = value.slice(0, cursor);
|
||||
const trimmed = upto.replace(/\S+\s*$/, '');
|
||||
|
||||
@@ -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,78 @@
|
||||
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; subagentModel?: string },
|
||||
): Panel {
|
||||
const spend = costOf(info.model, session.inputTokens, session.outputTokens);
|
||||
const lines = [
|
||||
`- model: \`${info.model}\``,
|
||||
`- billed: ${session.inputTokens} in / ${session.outputTokens} out`,
|
||||
`- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`,
|
||||
];
|
||||
|
||||
// Subagent spend is priced against its own model id, which may be the cheaper
|
||||
// one, so it is reported as its own line rather than folded into the parent's.
|
||||
if (session.subagentInputTokens + session.subagentOutputTokens > 0) {
|
||||
const subModel = info.subagentModel ?? info.model;
|
||||
const subSpend = costOf(subModel, session.subagentInputTokens, session.subagentOutputTokens);
|
||||
lines.push(
|
||||
`- subagents: ${session.subagentInputTokens} in / ${session.subagentOutputTokens} out (\`${subModel}\`)${
|
||||
subSpend === undefined ? '' : ` - ${formatUsd(subSpend)}`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const ceiling = session.spend();
|
||||
if (ceiling.ceiling !== undefined) {
|
||||
lines.push(
|
||||
`- ceiling: ${ceiling.usd === undefined ? 'unpriced' : formatUsd(ceiling.usd)} of ${formatUsd(ceiling.ceiling)}`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`- context: ~${session.estimatedTokens()} tokens`, `- agent: \`${info.agent}\` thinking \`${info.thinking}\``);
|
||||
return { title: 'cost', hint: `session ${info.sessionId}`, body: lines.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,65 @@
|
||||
/**
|
||||
* The shared visual language for the whole UI.
|
||||
*
|
||||
* Every glyph, accent colour, and spacing constant lives here once, so a panel,
|
||||
* the transcript, and the status bar agree with each other instead of each having
|
||||
* grown its own dialect. The palette is deliberately narrow: one accent per
|
||||
* *kind* of thing, reused everywhere that kind appears.
|
||||
*
|
||||
* cyan - the user and interactive affordances (you, keys you can press)
|
||||
* green - success, additions, the assistant's done state
|
||||
* magenta - tools and delegated work (the machine acting)
|
||||
* yellow - caution, working state, decisions that need you
|
||||
* red - errors, removals, denials
|
||||
* gray - the quiet layer: hints, metadata, separators
|
||||
*/
|
||||
|
||||
export const accent = {
|
||||
user: 'cyan',
|
||||
ok: 'green',
|
||||
tool: 'magenta',
|
||||
warn: 'yellow',
|
||||
err: 'red',
|
||||
mute: 'gray',
|
||||
info: 'blue',
|
||||
} as const;
|
||||
|
||||
/** Glyphs that read in any monospace font; no emoji, no nerd-font dependency. */
|
||||
export const glyph = {
|
||||
user: '>',
|
||||
assistant: '●',
|
||||
toolOk: '●',
|
||||
toolErr: '✕',
|
||||
ok: '✓',
|
||||
err: '✕',
|
||||
warn: '▲',
|
||||
info: '·',
|
||||
bullet: '•',
|
||||
dash: '─',
|
||||
sep: '│',
|
||||
result: '→',
|
||||
pending: '○',
|
||||
progress: '◆',
|
||||
blocked: '✕',
|
||||
spinWorking: '⠋',
|
||||
meterFull: '█',
|
||||
meterEmpty: '░',
|
||||
queued: '▸',
|
||||
fold: '…',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A thin horizontal rule, for separating a panel header from its body without a
|
||||
* full second border. Width is clamped so a narrow terminal still gets a line.
|
||||
*/
|
||||
export const rule = (width: number) => glyph.dash.repeat(Math.max(4, width));
|
||||
|
||||
/** A tiny inline context meter, e.g. `███░░░░░░░ 34%`. Fixed cell count so the bar never shifts layout. */
|
||||
export function meter(ratio: number, cells = 10): string {
|
||||
const clamped = Math.max(0, Math.min(1, ratio));
|
||||
const filled = Math.round(clamped * cells);
|
||||
return glyph.meterFull.repeat(filled) + glyph.meterEmpty.repeat(Math.max(0, cells - filled));
|
||||
}
|
||||
|
||||
/** Shorten a path or label to `n` columns, keeping the tail (the identifying part). */
|
||||
export const clipTail = (s: string, n = 68) => (s.length > n ? `…${s.slice(-(n - 1))}` : s);
|
||||
@@ -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