Merge upstream zakirkun/shiro-neko (v1.0.0: cost control, 41 tools, 29 skills, custom commands, auto-load)
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s

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:
asepharyana committed 2026-09-08 20:11:55 +07:00
commit b1ff00d840
119 files changed
+7726 -1169

No files matched your search

+266 -523
View File
@@ -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>
)}
+119
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+92
View File
@@ -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
View File
@@ -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>
+222
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+131
View File
@@ -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>
);
}
+27
View File
@@ -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*$/, '');
+75
View File
@@ -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));
}
}
+78
View File
@@ -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),
});
+65
View File
@@ -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);
+202
View File
@@ -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.';