diff --git a/src/ui/Approval.tsx b/src/ui/Approval.tsx new file mode 100644 index 0000000..dff8e59 --- /dev/null +++ b/src/ui/Approval.tsx @@ -0,0 +1,106 @@ +import { Box, Text, useInput } from 'ink'; +import React from 'react'; +import type { ApprovalDecision, ApprovalRequest } from '../session'; +import { Diff } from './Diff'; +import { toolDetail } from './transcript'; + +export type Pending = { req: ApprovalRequest; resolve: (d: ApprovalDecision) => void }; + +/** Bridges Session's promise-based approval callback into React state. */ +export type ApprovalBridge = { + bind: (fn: (p: Pending | undefined) => void) => void; + ask: (req: ApprovalRequest) => Promise; +}; + +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; + + if (name === 'write_file') { + const content = String(o['content'] ?? ''); + return ; + } + if (name === 'edit_file') { + return ; + } + + const detail = toolDetail(name, input); + if (detail.length === 0) return {JSON.stringify(input)}; + return ( + + {detail.slice(0, 10).map((d, i) => ( + + {` ${d}`} + + ))} + {detail.length > 10 && {` ... ${detail.length - 10} more`}} + + ); +} + +/** 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 ( + + + {reason(req)} + + {req.repeated && allowed by the rules, but this is the third identical call this turn} + {req.subagent && !req.repeated && ( + delegated work, gated by your rules exactly as a direct call is + )} + {!req.repeated && req.matchedPattern && req.matchedPattern !== '*' && ( + {`matched ${req.toolName}: "${req.matchedPattern}"`} + )} + + + y allow once | a always allow {grant} |{' '} + n deny + + + ); +} diff --git a/src/ui/Pickers.tsx b/src/ui/Pickers.tsx new file mode 100644 index 0000000..a8c3add --- /dev/null +++ b/src/ui/Pickers.tsx @@ -0,0 +1,128 @@ +import { Box, Text, useInput } from 'ink'; +import SelectInput from 'ink-select-input'; +import React from 'react'; +import type { CommandSpec } from '../commands'; +import { InstallPrompt, type RegistryRow } from './Panels'; + +/** The `/` menu, narrowing as the name is typed. */ +export function CommandMenu({ matches, index }: { matches: readonly CommandSpec[]; index: number }) { + const width = Math.max(...matches.map((c) => `/${c.name}${c.arg ? ` ${c.arg}` : ''}`.length)) + 1; + return ( + + {matches.map((c, i) => ( + + {i === index ? '> ' : ' '} + + {`/${c.name}${c.arg ? ` ${c.arg}` : ''}`.padEnd(width)} + + {c.summary} + + ))} + up/down move | tab complete | enter run | esc dismiss + + ); +} + +/** 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 ; +} + +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 ( + + + {title} + + {hint && {hint}} + {warning && could not list models: {warning}} + {error && {error}} + {children} + + ); +} + +/** 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 ( + + {label}: + {children} + + ); +} + +/** + * 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 ( + + + {title} + + {hint ? `${hint} - enter to select, esc to cancel` : 'enter to select, esc to cancel'} + ({ 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)} + /> + + ); +} diff --git a/src/ui/buses.ts b/src/ui/buses.ts new file mode 100644 index 0000000..c7e1830 --- /dev/null +++ b/src/ui/buses.ts @@ -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)); + } +} diff --git a/src/ui/panel-bodies.ts b/src/ui/panel-bodies.ts new file mode 100644 index 0000000..4885339 --- /dev/null +++ b/src/ui/panel-bodies.ts @@ -0,0 +1,62 @@ +import { costOf, formatUsd } from '../pricing'; +import type { Session } from '../session'; +import { toolSetOf } from '../tools'; +import { todoLines } from './transcript'; + +export type Panel = { title: string; hint?: string; body: string }; + +/** + * The read-only panel bodies, as pure functions of session and hook state. + * + * These were inline inside App's submit switch, where each one added a `setPanel` + * call wrapped around string assembly and pushed the switch past the point a reader + * can follow it. Out here they are testable without mounting Ink, and the switch + * reads as routing rather than as formatting. + */ + +export function toolsPanel(session: Session): Panel { + const offered = session.activeTools().sort(); + return { + title: 'tools', + hint: `${offered.length} offered this turn of ${Object.keys(session.tools).length} registered`, + body: offered + .map((t) => { + const set = toolSetOf(t); + return `- \`${t}\`${set ? ` ${set}` : ''}`; + }) + .join('\n'), + }; +} + +export function costPanel( + session: Session, + info: { sessionId: string; model: string; agent: string; thinking: string }, +): Panel { + const spend = costOf(info.model, session.inputTokens, session.outputTokens); + return { + title: 'cost', + hint: `session ${info.sessionId}`, + body: [ + `- model: \`${info.model}\``, + `- billed: ${session.inputTokens} in / ${session.outputTokens} out`, + `- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`, + `- context: ~${session.estimatedTokens()} tokens`, + `- agent: \`${info.agent}\` thinking \`${info.thinking}\``, + ].join('\n'), + }; +} + +export function contextPanel(files: readonly string[]): Panel { + return { + title: 'project instructions', + body: + files.length > 0 + ? files.map((f) => `- \`${f}\``).join('\n') + : 'No `AGENTS.md`, `CLAUDE.md`, or `.shiro.md` found. Run `/init` to write one.', + }; +} + +export const todosPanel = (session: Session): Panel => ({ + title: 'task list', + body: todoLines(session.notebook.state().todos), +}); diff --git a/src/ui/transcript.ts b/src/ui/transcript.ts new file mode 100644 index 0000000..f22afc0 --- /dev/null +++ b/src/ui/transcript.ts @@ -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 : 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; + 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; + 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.';