Initial commit: shiro-neko 0.1.0-beta.1
Agentic coding CLI on Bun, Ink, and the AI SDK. Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff. Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming. Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model. Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents. State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies. Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems. 404 tests, typecheck clean.
This commit is contained in:
+792
@@ -0,0 +1,792 @@
|
||||
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { parseCommand, matchCommands, type CommandSpec } from '../commands';
|
||||
import { THINKING_LEVELS, VARIANTS } from '../agents';
|
||||
import type { Config } from '../config';
|
||||
import { TODO_MARK, type NotebookState } from '../notebook';
|
||||
import { costOf, formatUsd, usageLine } from '../pricing';
|
||||
import type { ApprovalDecision, ApprovalRequest, Session } from '../session';
|
||||
import type { SubagentEvent } from '../subagent';
|
||||
import { AskPanel, type AskBridge, type AskPending } from './Ask';
|
||||
import { Diff } from './Diff';
|
||||
import { Markdown } from './Markdown';
|
||||
import { Onboard, type OnboardResult } from './Onboard';
|
||||
import { InfoPanel, OutputPanel, StatusBar, SubagentPanel, TodoPanel, type SubagentView } from './Panels';
|
||||
import { PromptInput } from './PromptInput';
|
||||
|
||||
type Line =
|
||||
| { key: string; kind: 'user'; text: string }
|
||||
| { key: string; kind: 'assistant'; text: string }
|
||||
| { key: string; kind: 'tool'; name: string; summary: 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 '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));
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the slash commands need from the outside world. */
|
||||
export type AppHooks = {
|
||||
sessionId: string;
|
||||
config: () => Config;
|
||||
switchModel: (id: string) => string;
|
||||
switchAgent: (name: string) => string;
|
||||
switchThinking: (level: string) => string;
|
||||
agentName: () => string;
|
||||
thinkingLevel: () => string;
|
||||
applyProvider: (result: OnboardResult) => Promise<string>;
|
||||
listModels: () => Promise<{ models: string[]; warning?: string }>;
|
||||
listSessions: () => Promise<string>;
|
||||
listSkills: () => string;
|
||||
listPlugins: () => string;
|
||||
listMemory: () => Promise<string>;
|
||||
summarizeMemory: () => Promise<string>;
|
||||
resumeSession: (idOrPrefix: string) => Promise<string>;
|
||||
saveSession: () => Promise<string>;
|
||||
/** Loaded AGENTS.md-style files, for /context. */
|
||||
instructionFiles: () => 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['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(', ');
|
||||
}
|
||||
|
||||
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.toolName} wants to run
|
||||
</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.toolName} |{' '}
|
||||
<Text color="red">n</Text> deny
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandMenu({ matches, index }: { matches: CommandSpec[]; index: number }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{matches.map((c, i) => (
|
||||
<Text key={c.name} color={i === index ? 'cyan' : undefined} dimColor={i !== index}>
|
||||
{i === index ? '> ' : ' '}
|
||||
{`/${c.name}${c.arg ? ` ${c.arg}` : ''}`.padEnd(18)} {c.summary}
|
||||
</Text>
|
||||
))}
|
||||
<Text dimColor>up/down move | tab complete | enter run | esc dismiss</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function App({
|
||||
session,
|
||||
bridge,
|
||||
header,
|
||||
hooks,
|
||||
notices,
|
||||
askBridge,
|
||||
subagents,
|
||||
needsProvider = false,
|
||||
}: {
|
||||
session: Session;
|
||||
bridge: ApprovalBridge;
|
||||
header: string;
|
||||
hooks: AppHooks;
|
||||
notices?: NoticeBus;
|
||||
askBridge?: AskBridge;
|
||||
subagents?: SubagentBus;
|
||||
needsProvider?: boolean;
|
||||
}) {
|
||||
const { exit } = useApp();
|
||||
const { write } = useStdout();
|
||||
const [history, setHistory] = useState<Line[]>([]);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [live, setLive] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pending, setPending] = useState<Pending | undefined>();
|
||||
const [asking, setAsking] = useState<AskPending | undefined>();
|
||||
const [onboarding, setOnboarding] = useState(needsProvider);
|
||||
const [unconfigured, setUnconfigured] = useState(needsProvider);
|
||||
const [modelPicker, setModelPicker] = useState<string[] | undefined>();
|
||||
const [agentPicker, setAgentPicker] = useState(false);
|
||||
const [thinkPicker, setThinkPicker] = useState(false);
|
||||
const [menuIndex, setMenuIndex] = useState(0);
|
||||
const [menuDismissed, setMenuDismissed] = useState(false);
|
||||
const [inputGeneration, setInputGeneration] = useState(0);
|
||||
const [toolOutput, setToolOutput] = useState('');
|
||||
const [recall, setRecall] = useState<string[]>(hooks.history);
|
||||
const [notebook, setNotebook] = useState<NotebookState>(session.notebook.state());
|
||||
const [agents, setAgents] = useState<SubagentView[]>([]);
|
||||
const [panel, setPanel] = useState<{ title: string; hint?: string; body: string } | undefined>();
|
||||
|
||||
const modal = pending !== undefined || asking !== undefined || onboarding;
|
||||
const anyPicker = modelPicker !== undefined || agentPicker || thinkPicker;
|
||||
const matches = matchCommands(draft);
|
||||
const menuOpen = matches.length > 0 && !menuDismissed && !busy && !modal && !anyPicker && !panel;
|
||||
const highlighted = matches[Math.min(menuIndex, matches.length - 1)];
|
||||
|
||||
useEffect(() => bridge.bind(setPending), [bridge]);
|
||||
useEffect(() => askBridge?.bind(setAsking), [askBridge]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subagents?.bind((event) => {
|
||||
setAgents((current) => applySubagentEvent(current, event));
|
||||
}),
|
||||
[subagents],
|
||||
);
|
||||
|
||||
// Ink re-renders the whole tree per setState, so deltas accumulate in a ref
|
||||
// and are flushed on a timer instead of once per token.
|
||||
const text = useRef('');
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
setLive((s) => (s === text.current ? s : text.current));
|
||||
}, 60);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const push = useCallback((line: NewLine) => {
|
||||
setHistory((h) => [...h, { ...line, key: nextKey() }]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => notices?.bind((text) => push({ kind: 'info', text })), [notices, push]);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) session.abort();
|
||||
},
|
||||
{ isActive: busy && !modal },
|
||||
);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!key.escape) return;
|
||||
setModelPicker(undefined);
|
||||
setAgentPicker(false);
|
||||
setThinkPicker(false);
|
||||
},
|
||||
{ isActive: anyPicker },
|
||||
);
|
||||
|
||||
// PromptInput hands up/down/tab/esc to us first, so the menu and any open panel
|
||||
// can claim them before the input treats them as editing keys.
|
||||
const handleInputKey = useCallback(
|
||||
(_input: string, key: { upArrow: boolean; downArrow: boolean; tab: boolean; escape: boolean }) => {
|
||||
if (key.escape && panel) {
|
||||
setPanel(undefined);
|
||||
return true;
|
||||
}
|
||||
if (!menuOpen) return false;
|
||||
if (key.escape) {
|
||||
setMenuDismissed(true);
|
||||
return true;
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setMenuIndex((i) => (i - 1 + matches.length) % matches.length);
|
||||
return true;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setMenuIndex((i) => (i + 1) % matches.length);
|
||||
return true;
|
||||
}
|
||||
if (key.tab && highlighted) {
|
||||
setDraft(highlighted.arg ? `/${highlighted.name} ` : `/${highlighted.name}`);
|
||||
setMenuIndex(0);
|
||||
setMenuDismissed(true);
|
||||
setInputGeneration((g) => g + 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[highlighted, matches.length, menuOpen, panel],
|
||||
);
|
||||
|
||||
const onDraftChange = useCallback((value: string) => {
|
||||
setDraft(value);
|
||||
setMenuIndex(0);
|
||||
setMenuDismissed(false);
|
||||
}, []);
|
||||
|
||||
const runTurn = useCallback(
|
||||
async (value: string) => {
|
||||
setBusy(true);
|
||||
text.current = '';
|
||||
|
||||
for await (const ev of session.send(value)) {
|
||||
switch (ev.type) {
|
||||
case 'text':
|
||||
text.current += ev.text;
|
||||
break;
|
||||
case 'tool-call':
|
||||
push({ kind: 'tool', name: ev.name, summary: preview(ev.input), ok: true });
|
||||
break;
|
||||
case 'tool-output':
|
||||
setToolOutput((s) => `${s}${ev.chunk}`.slice(-2000));
|
||||
break;
|
||||
case 'tool-error':
|
||||
push({ kind: 'tool', name: ev.name, summary: String(ev.error), ok: false });
|
||||
break;
|
||||
case 'tool-result':
|
||||
setToolOutput('');
|
||||
setNotebook(session.notebook.state());
|
||||
break;
|
||||
case 'tool-denied':
|
||||
push({ kind: 'info', text: `denied ${ev.name}` });
|
||||
break;
|
||||
case 'notice':
|
||||
push({ kind: 'info', text: ev.text });
|
||||
break;
|
||||
case 'compacted':
|
||||
push({ kind: 'info', text: `context compacted: ${ev.before} messages pruned to ${ev.after} on the wire` });
|
||||
break;
|
||||
case 'error':
|
||||
push({ kind: 'error', text: ev.error instanceof Error ? ev.error.message : String(ev.error) });
|
||||
break;
|
||||
case 'done': {
|
||||
const full = text.current.trim();
|
||||
text.current = '';
|
||||
setLive('');
|
||||
setToolOutput('');
|
||||
setAgents([]);
|
||||
setHistory((h) => {
|
||||
const merged: Line[] = [...h];
|
||||
if (full) merged.push({ kind: 'assistant', text: full, key: nextKey() });
|
||||
if (ev.inputTokens !== undefined) {
|
||||
merged.push({
|
||||
kind: 'info',
|
||||
text: `${usageLine(hooks.config().model, ev.inputTokens, ev.outputTokens ?? 0)} (~${session.estimatedTokens()} in context)`,
|
||||
key: nextKey(),
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
},
|
||||
[hooks, push, session],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async (raw: string) => {
|
||||
setDraft('');
|
||||
setMenuIndex(0);
|
||||
setMenuDismissed(false);
|
||||
setPanel(undefined);
|
||||
|
||||
// Enter on an open menu runs the highlighted entry, so `/mo` + enter works.
|
||||
const chosen = menuOpen && highlighted ? `/${highlighted.name}` : raw;
|
||||
const action = parseCommand(chosen);
|
||||
|
||||
switch (action.type) {
|
||||
case 'none':
|
||||
return;
|
||||
case 'exit':
|
||||
return exit();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Nothing can reach the model until a provider is configured.
|
||||
if (unconfigured && action.type !== 'provider' && action.type !== 'info') {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
push({ kind: 'error', text: 'no provider configured yet - run /provider' });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'clear':
|
||||
session.reset();
|
||||
setHistory([]);
|
||||
setNotebook(session.notebook.state());
|
||||
// <Static> lines are already committed to the scrollback, so clearing
|
||||
// React state alone leaves them on screen. Wipe screen + scrollback.
|
||||
write('\u001B[2J\u001B[3J\u001B[H');
|
||||
return;
|
||||
case 'info':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({ title: 'commands', hint: 'type / for the menu', body: action.text });
|
||||
return;
|
||||
case 'unknown':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
push({ kind: 'error', text: `unknown command /${action.name} - try /help` });
|
||||
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) => `- \`${t}\``)
|
||||
.join('\n'),
|
||||
});
|
||||
return;
|
||||
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'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
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.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
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.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'notes': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({ title: 'project memory', body: await hooks.listMemory() });
|
||||
return;
|
||||
}
|
||||
case 'agent': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
if (action.agent) {
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchAgent(action.agent) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setAgentPicker(true);
|
||||
return;
|
||||
}
|
||||
case 'think': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
if (action.level) {
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchThinking(action.level) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setThinkPicker(true);
|
||||
return;
|
||||
}
|
||||
case 'skills':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({ title: 'skills', hint: 'the agent loads one with the skill tool', body: hooks.listSkills() });
|
||||
return;
|
||||
case 'plugins':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({ title: 'plugins', body: hooks.listPlugins() });
|
||||
return;
|
||||
case 'memory': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setBusy(true);
|
||||
try {
|
||||
push({ kind: 'info', text: await hooks.summarizeMemory() });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
case 'init':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
await runTurn(hooks.initPrompt);
|
||||
return;
|
||||
case 'model':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchModel(action.model) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return;
|
||||
case 'sessions':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
push({ kind: 'info', text: await hooks.listSessions() });
|
||||
return;
|
||||
case 'save':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
push({ kind: 'info', text: await hooks.saveSession() });
|
||||
return;
|
||||
case 'resume':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
try {
|
||||
const msg = await hooks.resumeSession(action.id);
|
||||
setHistory([]);
|
||||
push({ kind: 'info', text: msg });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return;
|
||||
case 'provider':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setOnboarding(true);
|
||||
return;
|
||||
case 'models': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setBusy(true);
|
||||
const { models, warning } = await hooks.listModels();
|
||||
setBusy(false);
|
||||
if (warning) push({ kind: 'info', text: `could not list models: ${warning}` });
|
||||
if (models.length === 0) {
|
||||
push({ kind: 'error', text: 'no models to choose from - use /model <id> or /provider' });
|
||||
return;
|
||||
}
|
||||
setModelPicker(models);
|
||||
return;
|
||||
}
|
||||
case 'compact': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setBusy(true);
|
||||
try {
|
||||
const { before, after } = await session.summarize();
|
||||
push({ kind: 'info', text: `compacted ${before} messages into ${after}` });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
case 'prompt':
|
||||
push({ kind: 'user', text: action.text });
|
||||
hooks.recordPrompt(action.text);
|
||||
setRecall((h) => (h.at(-1) === action.text ? h : [...h, action.text]));
|
||||
await runTurn(action.text);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[exit, highlighted, hooks, menuOpen, push, runTurn, session, unconfigured, write],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<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 === 'tool' && (
|
||||
<Text color={line.ok ? 'magenta' : 'red'}>
|
||||
{line.ok ? '*' : 'x'} {line.name}({line.summary})
|
||||
</Text>
|
||||
)}
|
||||
{line.kind === 'info' && <Text dimColor>{line.text}</Text>}
|
||||
{line.kind === 'error' && <Text color="red">error: {line.text}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
</Static>
|
||||
|
||||
{history.length === 0 && (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>{header}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{agents.length > 0 && <SubagentPanel agents={agents} />}
|
||||
|
||||
{notebook.todos.length > 0 && <TodoPanel todos={notebook.todos} />}
|
||||
|
||||
{live.length > 0 && (
|
||||
<Box marginBottom={1}>
|
||||
<Markdown text={live} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{panel && (
|
||||
<InfoPanel title={panel.title} {...(panel.hint ? { hint: panel.hint } : {})} lines={panel.body} />
|
||||
)}
|
||||
|
||||
{asking && <AskPanel pending={asking} />}
|
||||
|
||||
{pending && <Approval pending={pending} />}
|
||||
|
||||
{onboarding && (
|
||||
<Onboard
|
||||
current={hooks.config()}
|
||||
onCancel={() => {
|
||||
setOnboarding(false);
|
||||
push({ kind: 'info', text: 'provider setup cancelled' });
|
||||
}}
|
||||
onDone={async (result) => {
|
||||
setOnboarding(false);
|
||||
try {
|
||||
push({ kind: 'info', text: await hooks.applyProvider(result) });
|
||||
setUnconfigured(false);
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{busy && !modal && (
|
||||
<Box flexDirection="column">
|
||||
<OutputPanel text={toolOutput} />
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" /> <Text dimColor>working... esc to interrupt</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!busy && !modal && !anyPicker && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="cyan">{'> '}</Text>
|
||||
<PromptInput
|
||||
key={inputGeneration}
|
||||
value={draft}
|
||||
onChange={onDraftChange}
|
||||
onSubmit={submit}
|
||||
history={recall}
|
||||
onKey={handleInputKey}
|
||||
placeholder="ask shiro-neko... (/ for commands)"
|
||||
/>
|
||||
</Box>
|
||||
{menuOpen && <CommandMenu matches={matches} index={Math.min(menuIndex, matches.length - 1)} />}
|
||||
<StatusBar
|
||||
model={hooks.config().model}
|
||||
agent={hooks.agentName()}
|
||||
thinking={hooks.thinkingLevel()}
|
||||
contextTokens={session.estimatedTokens()}
|
||||
cost={(() => {
|
||||
const spend = costOf(hooks.config().model, session.inputTokens, session.outputTokens);
|
||||
return spend === undefined ? 'unpriced' : formatUsd(spend);
|
||||
})()}
|
||||
toolCount={session.activeTools().length}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import React, { useState } from 'react';
|
||||
import type { AskRequest } from '../ask';
|
||||
import { InlineMarkdown } from './Markdown';
|
||||
import { PromptInput } from './PromptInput';
|
||||
|
||||
export type AskPending = { req: AskRequest; resolve: (answers: string[] | undefined) => void };
|
||||
|
||||
/** Bridges the ask tool's promise into React state, the same shape as the approval bridge. */
|
||||
export type AskBridge = {
|
||||
bind: (fn: (p: AskPending | undefined) => void) => void;
|
||||
ask: (req: AskRequest) => Promise<string[] | undefined>;
|
||||
};
|
||||
|
||||
export function createAskBridge(): AskBridge {
|
||||
let setter: ((p: AskPending | undefined) => void) | undefined;
|
||||
return {
|
||||
bind(fn) {
|
||||
setter = fn;
|
||||
},
|
||||
ask(req) {
|
||||
return new Promise((resolve) => {
|
||||
// No UI mounted means no one can answer; resolving undefined lets the tool
|
||||
// tell the model to decide for itself rather than hanging forever.
|
||||
if (!setter) return resolve(undefined);
|
||||
setter({
|
||||
req,
|
||||
resolve: (answers) => {
|
||||
setter?.(undefined);
|
||||
resolve(answers);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TYPE_YOUR_OWN = '__own__';
|
||||
|
||||
/**
|
||||
* The question popup.
|
||||
*
|
||||
* Options become a picker; no options, or "type your own", falls back to free text.
|
||||
* Escape resolves undefined rather than leaving the tool waiting.
|
||||
*/
|
||||
export function AskPanel({ pending }: { pending: AskPending }) {
|
||||
const { question, options, multiple } = pending.req;
|
||||
const [chosen, setChosen] = useState<string[]>([]);
|
||||
const [typing, setTyping] = useState(!options || options.length === 0);
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) pending.resolve(undefined);
|
||||
},
|
||||
{ isActive: !typing },
|
||||
);
|
||||
|
||||
const items = [
|
||||
...(options ?? []).map((o) => ({
|
||||
key: o.label,
|
||||
label: chosen.includes(o.label) ? `[x] ${o.label}` : multiple ? `[ ] ${o.label}` : o.label,
|
||||
value: o.label,
|
||||
})),
|
||||
...(multiple && chosen.length > 0 ? [{ key: '__done__', label: `-- submit ${chosen.length} --`, value: '__done__' }] : []),
|
||||
{ key: TYPE_YOUR_OWN, label: 'type your own answer...', value: TYPE_YOUR_OWN },
|
||||
];
|
||||
|
||||
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>
|
||||
shiro is asking
|
||||
</Text>
|
||||
<Box marginBottom={1}>
|
||||
<InlineMarkdown text={question} />
|
||||
</Box>
|
||||
|
||||
{typing ? (
|
||||
<Box>
|
||||
<Text color="yellow">{'> '}</Text>
|
||||
<PromptInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => pending.resolve(v.trim() ? [v.trim()] : undefined)}
|
||||
placeholder="type your answer, enter to send"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
<SelectInput
|
||||
items={items}
|
||||
limit={10}
|
||||
onSelect={(item) => {
|
||||
if (item.value === TYPE_YOUR_OWN) return setTyping(true);
|
||||
if (item.value === '__done__') return pending.resolve(chosen);
|
||||
if (!multiple) return pending.resolve([item.value]);
|
||||
setChosen((c) => (c.includes(item.value) ? c.filter((x) => x !== item.value) : [...c, item.value]));
|
||||
}}
|
||||
onHighlight={(item) => {
|
||||
const detail = detailOf(item.value);
|
||||
if (detail) setDraft(detail);
|
||||
else setDraft('');
|
||||
}}
|
||||
/>
|
||||
{draft.length > 0 && <Text dimColor>{draft}</Text>}
|
||||
<Text dimColor>
|
||||
{multiple ? 'space/enter toggles, pick submit when done' : 'enter to choose'} | esc to skip
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import React from 'react';
|
||||
|
||||
export type DiffLine = { kind: 'context' | 'add' | 'remove'; text: string };
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function diffLines(before: string, after: string): DiffLine[] {
|
||||
const a = before.split('\n');
|
||||
const b = after.split('\n');
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
|
||||
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
|
||||
}
|
||||
}
|
||||
|
||||
const out: DiffLine[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ kind: 'context', text: a[i]! });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
|
||||
out.push({ kind: 'remove', text: a[i]! });
|
||||
i++;
|
||||
} else {
|
||||
out.push({ kind: 'add', text: b[j]! });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) out.push({ kind: 'remove', text: a[i++]! });
|
||||
while (j < m) out.push({ kind: 'add', text: b[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 })[] {
|
||||
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;
|
||||
lines.forEach((line, i) => {
|
||||
if (keep.has(i)) {
|
||||
if (skipped > 0) {
|
||||
out.push({ kind: 'gap', count: skipped });
|
||||
skipped = 0;
|
||||
}
|
||||
out.push(line);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
});
|
||||
if (skipped > 0) out.push({ kind: 'gap', count: skipped });
|
||||
return out;
|
||||
}
|
||||
|
||||
const MAX_RENDERED = 40;
|
||||
|
||||
export function Diff({ before, after, path }: { before: string; after: string; path?: string }) {
|
||||
const all = collapseContext(diffLines(before, after));
|
||||
const shown = all.slice(0, MAX_RENDERED);
|
||||
const hidden = all.length - shown.length;
|
||||
const added = all.filter((l) => l.kind === 'add').length;
|
||||
const removed = all.filter((l) => l.kind === 'remove').length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{path && (
|
||||
<Text>
|
||||
<Text bold>{path}</Text> <Text color="green">+{added}</Text> <Text color="red">-{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}
|
||||
color={line.kind === 'add' ? 'green' : line.kind === 'remove' ? 'red' : undefined}
|
||||
dimColor={line.kind === 'context'}
|
||||
>
|
||||
{`${line.kind === 'add' ? ' + ' : line.kind === 'remove' ? ' - ' : ' '}${line.text}`}
|
||||
</Text>
|
||||
),
|
||||
)}
|
||||
{hidden > 0 && <Text dimColor>{` ... ${hidden} more diff lines`}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import React from 'react';
|
||||
import { parseInline, parseMarkdown, type Block, type Span } from '../markdown';
|
||||
|
||||
const HEADING_COLOR = ['cyan', 'cyan', 'blue', 'blue', 'gray', 'gray'] as const;
|
||||
|
||||
function Inline({ spans }: { spans: Span[] }) {
|
||||
return (
|
||||
<Text>
|
||||
{spans.map((s, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
bold={s.bold}
|
||||
italic={s.italic}
|
||||
strikethrough={s.strike}
|
||||
underline={s.link}
|
||||
color={s.code ? 'yellow' : s.link ? 'blue' : undefined}
|
||||
>
|
||||
{s.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ language, lines }: { language: string; lines: string[] }) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
{language.length > 0 && <Text dimColor>{language}</Text>}
|
||||
{lines.map((l, i) => (
|
||||
<Text key={i} color="green">
|
||||
{l.length > 0 ? l : ' '}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockView({ block, width }: { block: Block; width: number }) {
|
||||
switch (block.kind) {
|
||||
case 'heading':
|
||||
return (
|
||||
<Box marginTop={block.level === 1 ? 1 : 0}>
|
||||
<Text bold color={HEADING_COLOR[block.level - 1] ?? 'gray'}>
|
||||
<Inline spans={block.spans} />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
case 'paragraph':
|
||||
return <Inline spans={block.spans} />;
|
||||
case 'bullet':
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{`${' '.repeat(block.indent)}${block.marker} `}</Text>
|
||||
<Box flexGrow={1}>
|
||||
<Inline spans={block.spans} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
case 'quote':
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">{'| '}</Text>
|
||||
<Text dimColor italic>
|
||||
<Inline spans={block.spans} />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
case 'code':
|
||||
return <CodeBlock language={block.language} lines={block.lines} />;
|
||||
case 'rule':
|
||||
return <Text dimColor>{'-'.repeat(Math.max(4, Math.min(width, 60)))}</Text>;
|
||||
case 'blank':
|
||||
return <Text> </Text>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders agent output as styled terminal markdown.
|
||||
*
|
||||
* Parsing happens here rather than in the transcript because a partial stream is
|
||||
* re-parsed on every flush; an unclosed fence simply renders as a code block that
|
||||
* grows, which is what a reader expects while text is still arriving.
|
||||
*/
|
||||
export function Markdown({ text, width = 80 }: { text: string; width?: number }) {
|
||||
const blocks = parseMarkdown(text);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{blocks.map((b, i) => (
|
||||
<BlockView key={i} block={b} width={width} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** One line of inline-styled markdown, for labels and summaries. */
|
||||
export function InlineMarkdown({ text }: { text: string }) {
|
||||
return <Inline spans={parseInline(text)} />;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import TextInput from 'ink-text-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { Config, ProviderName } from '../config';
|
||||
import { fetchModels, PRESETS, type ProviderPreset } from '../providers';
|
||||
|
||||
export type OnboardResult = {
|
||||
presetId: string;
|
||||
provider: ProviderName;
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type Step =
|
||||
| { name: 'pick-provider' }
|
||||
| { name: 'base-url'; preset: ProviderPreset }
|
||||
| { name: 'api-key'; preset: ProviderPreset; baseURL: string }
|
||||
| { name: 'loading'; preset: ProviderPreset; baseURL: string; apiKey: string }
|
||||
| { name: 'pick-model'; preset: ProviderPreset; baseURL: string; apiKey: string; models: string[]; warning?: string }
|
||||
| { name: 'type-model'; preset: ProviderPreset; baseURL: string; apiKey: string; warning?: string };
|
||||
|
||||
const mask = (key: string) => (key.length <= 8 ? '*'.repeat(key.length) : `${key.slice(0, 4)}...${key.slice(-4)}`);
|
||||
|
||||
const MANUAL_ENTRY = '__type_it__';
|
||||
|
||||
/**
|
||||
* Provider onboarding: pick a preset, supply a key, then choose a model from the
|
||||
* server's own /models list. Rendered in place of the prompt input, so it owns
|
||||
* the keyboard while open.
|
||||
*/
|
||||
export function Onboard({
|
||||
current,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
current: Config;
|
||||
onDone: (result: OnboardResult) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<Step>({ name: 'pick-provider' });
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) onCancel();
|
||||
},
|
||||
{ isActive: step.name !== 'loading' },
|
||||
);
|
||||
|
||||
const loadModels = useCallback(
|
||||
async (preset: ProviderPreset, baseURL: string, apiKey: string) => {
|
||||
setStep({ name: 'loading', preset, baseURL, apiKey });
|
||||
const { models, warning } = await fetchModels({ ...preset, baseURL }, apiKey);
|
||||
setDraft('');
|
||||
if (models.length === 0) {
|
||||
setStep({ name: 'type-model', preset, baseURL, apiKey, ...(warning ? { warning } : {}) });
|
||||
} else {
|
||||
setStep({ name: 'pick-model', preset, baseURL, apiKey, models, ...(warning ? { warning } : {}) });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const afterBaseUrl = useCallback(
|
||||
(preset: ProviderPreset, baseURL: string) => {
|
||||
const fromEnv = preset.envKey ? process.env[preset.envKey] : undefined;
|
||||
const key = preset.keyless ? 'local' : (fromEnv ?? '');
|
||||
if (key) return void loadModels(preset, baseURL, key);
|
||||
setDraft('');
|
||||
setStep({ name: 'api-key', preset, baseURL });
|
||||
},
|
||||
[loadModels],
|
||||
);
|
||||
|
||||
const pickProvider = useCallback(
|
||||
(preset: ProviderPreset) => {
|
||||
if (preset.baseURL) return afterBaseUrl(preset, preset.baseURL);
|
||||
setDraft('');
|
||||
setStep({ name: 'base-url', preset });
|
||||
},
|
||||
[afterBaseUrl],
|
||||
);
|
||||
|
||||
switch (step.name) {
|
||||
case 'pick-provider': {
|
||||
const items = PRESETS.map((p) => ({
|
||||
key: p.id,
|
||||
label: p.id === current.presetId ? `${p.label} (current)` : p.label,
|
||||
value: p.id,
|
||||
}));
|
||||
return (
|
||||
<Frame title="Choose a provider" hint="up/down to move, enter to select, esc to cancel">
|
||||
<SelectInput
|
||||
items={items}
|
||||
limit={12}
|
||||
initialIndex={Math.max(
|
||||
0,
|
||||
PRESETS.findIndex((p) => p.id === (current.presetId ?? current.provider)),
|
||||
)}
|
||||
onSelect={(item) => {
|
||||
const preset = PRESETS.find((p) => p.id === item.value);
|
||||
if (preset) pickProvider(preset);
|
||||
}}
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
case 'base-url':
|
||||
return (
|
||||
<Frame title={`${step.preset.label}: endpoint URL`} hint="e.g. https://host/v1 - enter to continue">
|
||||
<Row label="URL">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => v.trim() && afterBaseUrl(step.preset, v.trim())}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'api-key':
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.preset.label}: API key`}
|
||||
hint={`stored in the shiro config file${step.preset.envKey ? `, or set ${step.preset.envKey} instead` : ''}`}
|
||||
>
|
||||
<Row label="key">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
mask="*"
|
||||
onSubmit={(v) => v.trim() && void loadModels(step.preset, step.baseURL, v.trim())}
|
||||
placeholder={step.preset.keyHint ?? 'paste it here'}
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'loading':
|
||||
return (
|
||||
<Frame title={`${step.preset.label}: fetching models`} hint={step.baseURL}>
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" /> <Text dimColor>GET {step.baseURL}/models</Text>
|
||||
</Text>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'pick-model': {
|
||||
const items = [
|
||||
...step.models.map((m) => ({ key: m, label: m, value: m })),
|
||||
{ key: MANUAL_ENTRY, label: 'type a model id myself...', value: MANUAL_ENTRY },
|
||||
];
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.preset.label}: choose a model`}
|
||||
hint={`${step.models.length} models - key ${mask(step.apiKey)}`}
|
||||
warning={step.warning}
|
||||
>
|
||||
<SelectInput
|
||||
items={items}
|
||||
limit={10}
|
||||
initialIndex={Math.max(0, step.models.indexOf(current.model))}
|
||||
onSelect={(item) => {
|
||||
if (item.value === MANUAL_ENTRY) {
|
||||
setDraft('');
|
||||
setStep({ name: 'type-model', preset: step.preset, baseURL: step.baseURL, apiKey: step.apiKey });
|
||||
return;
|
||||
}
|
||||
onDone({
|
||||
presetId: step.preset.id,
|
||||
provider: step.preset.kind,
|
||||
baseURL: step.baseURL,
|
||||
apiKey: step.apiKey,
|
||||
model: item.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
case 'type-model':
|
||||
return (
|
||||
<Frame title={`${step.preset.label}: model id`} hint="enter to finish, esc to cancel" warning={step.warning}>
|
||||
<Row label="model">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) =>
|
||||
v.trim() &&
|
||||
onDone({
|
||||
presetId: step.preset.id,
|
||||
provider: step.preset.kind,
|
||||
baseURL: step.baseURL,
|
||||
apiKey: step.apiKey,
|
||||
model: v.trim(),
|
||||
})
|
||||
}
|
||||
placeholder="model-id"
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import React from 'react';
|
||||
import { TODO_MARK, type Todo } from '../notebook';
|
||||
import type { SubagentKind } from '../subagent';
|
||||
import { InlineMarkdown } from './Markdown';
|
||||
|
||||
const STATUS_COLOR: Record<Todo['status'], string | undefined> = {
|
||||
pending: undefined,
|
||||
in_progress: 'cyan',
|
||||
done: 'green',
|
||||
blocked: 'red',
|
||||
};
|
||||
|
||||
/** Task list with a progress bar, shown above the input while a list exists. */
|
||||
export function TodoPanel({ todos, width = 40 }: { 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);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" 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 dimColor>{` ${done}/${todos.length}`}</Text>
|
||||
{blocked > 0 && <Text color="red">{` ${blocked} blocked`}</Text>}
|
||||
</Box>
|
||||
{todos.map((t, i) => (
|
||||
<Box key={i}>
|
||||
<Text color={STATUS_COLOR[t.status]}>{`${TODO_MARK[t.status]} `}</Text>
|
||||
<Text dimColor={t.status === 'done'} strikethrough={t.status === 'done'}>
|
||||
{t.content}
|
||||
</Text>
|
||||
{t.note && <Text dimColor>{` (${t.note})`}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export type SubagentView = {
|
||||
id: string;
|
||||
kind: SubagentKind;
|
||||
description: string;
|
||||
steps: { tool: string; summary: string }[];
|
||||
status: 'running' | 'done' | 'failed';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<SubagentKind, string> = { explore: 'explore', review: 'review' };
|
||||
|
||||
/**
|
||||
* Live view of delegated work.
|
||||
*
|
||||
* A subagent can run for a minute over many files; without this the parent's spinner
|
||||
* is the only feedback and the user cannot tell progress from a hang.
|
||||
*/
|
||||
export function SubagentPanel({ agents }: { agents: SubagentView[] }) {
|
||||
if (agents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} marginBottom={1}>
|
||||
{agents.map((a) => (
|
||||
<Box key={a.id} flexDirection="column">
|
||||
<Box>
|
||||
{a.status === 'running' ? (
|
||||
<Text color="magenta">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={a.status === 'done' ? 'green' : 'red'}>{a.status === 'done' ? '*' : 'x'}</Text>
|
||||
)}
|
||||
<Text bold>{` ${KIND_LABEL[a.kind]}`}</Text>
|
||||
<Text>{`: ${a.description}`}</Text>
|
||||
<Text dimColor>{` ${a.steps.length} step${a.steps.length === 1 ? '' : 's'}`}</Text>
|
||||
</Box>
|
||||
{a.steps.slice(-3).map((s, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{` ${s.tool}(${s.summary.slice(0, 60)})`}
|
||||
</Text>
|
||||
))}
|
||||
{a.error && <Text color="red">{` ${a.error}`}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Live tail of a running shell command. */
|
||||
export function OutputPanel({ text, lines = 8 }: { text: string; lines?: number }) {
|
||||
if (text.length === 0) return null;
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{text
|
||||
.split('\n')
|
||||
.slice(-lines)
|
||||
.map((l, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{` | ${l}`}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Status line under the transcript: model, agent, thinking, context, spend. */
|
||||
export function StatusBar({
|
||||
model,
|
||||
agent,
|
||||
thinking,
|
||||
contextTokens,
|
||||
cost,
|
||||
toolCount,
|
||||
}: {
|
||||
model: string;
|
||||
agent: string;
|
||||
thinking: string;
|
||||
contextTokens: number;
|
||||
cost: string;
|
||||
toolCount: number;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{`${model} `}</Text>
|
||||
<Text color="cyan">{agent}</Text>
|
||||
<Text dimColor>{`/${thinking} ${toolCount} tools ~${contextTokens} ctx ${cost}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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>}
|
||||
{typeof lines === 'string' ? (
|
||||
<InlineMarkdown text={lines} />
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<Box key={i}>
|
||||
<Text color="gray">{l.label.padEnd(14)}</Text>
|
||||
<Text>{l.value}</Text>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Text, useInput } from 'ink';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export type PromptInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
mask?: string;
|
||||
/** Newest-last list of previously submitted prompts, walked by up/down. */
|
||||
history?: readonly string[];
|
||||
/** Intercept a key before the input consumes it. Return true to swallow it. */
|
||||
onKey?: (input: string, key: KeyLike) => boolean;
|
||||
};
|
||||
|
||||
type KeyLike = {
|
||||
upArrow: boolean;
|
||||
downArrow: boolean;
|
||||
leftArrow: boolean;
|
||||
rightArrow: boolean;
|
||||
return: boolean;
|
||||
escape: boolean;
|
||||
tab: boolean;
|
||||
backspace: boolean;
|
||||
delete: boolean;
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
home?: boolean;
|
||||
end?: boolean;
|
||||
};
|
||||
|
||||
const INVERSE_ON = '\u001B[7m';
|
||||
const INVERSE_OFF = '\u001B[27m';
|
||||
const invert = (s: string) => `${INVERSE_ON}${s}${INVERSE_OFF}`;
|
||||
|
||||
/**
|
||||
* Text input with a real cursor and shell-style history recall.
|
||||
*
|
||||
* ink-text-input cannot do this: it discards up/down before its own handler and
|
||||
* only ever shrinks its internal cursor offset, so an externally driven value
|
||||
* leaves the cursor stranded. Owning the cursor here also gives us home/end and
|
||||
* ctrl-a/e/k/u/w for free.
|
||||
*/
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = '',
|
||||
focus = true,
|
||||
mask,
|
||||
history = [],
|
||||
onKey,
|
||||
}: PromptInputProps) {
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
// -1 means "editing a fresh line"; 0+ indexes back from the newest entry.
|
||||
const [recall, setRecall] = useState(-1);
|
||||
const [stash, setStash] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setCursor((c) => Math.min(c, value.length));
|
||||
}, [value]);
|
||||
|
||||
const set = (next: string, nextCursor = next.length) => {
|
||||
onChange(next);
|
||||
setCursor(Math.max(0, Math.min(nextCursor, next.length)));
|
||||
};
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (onKey?.(input, key as KeyLike)) return;
|
||||
|
||||
if (key.return) {
|
||||
setRecall(-1);
|
||||
setStash('');
|
||||
setCursor(0);
|
||||
onSubmit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow || key.downArrow) {
|
||||
if (history.length === 0) return;
|
||||
if (key.upArrow) {
|
||||
const next = Math.min(recall + 1, history.length - 1);
|
||||
if (recall === -1) setStash(value);
|
||||
setRecall(next);
|
||||
set(history[history.length - 1 - next] ?? value);
|
||||
} else {
|
||||
const next = recall - 1;
|
||||
setRecall(next);
|
||||
set(next < 0 ? stash : (history[history.length - 1 - next] ?? ''));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
if (key.end || (key.ctrl && input === 'e')) return setCursor(value.length);
|
||||
|
||||
if (key.ctrl && input === 'k') return set(value.slice(0, cursor), cursor);
|
||||
if (key.ctrl && input === 'u') return set(value.slice(cursor), 0);
|
||||
if (key.ctrl && input === 'w') {
|
||||
const upto = value.slice(0, cursor);
|
||||
const trimmed = upto.replace(/\S+\s*$/, '');
|
||||
return set(trimmed + value.slice(cursor), trimmed.length);
|
||||
}
|
||||
|
||||
if (key.backspace || key.delete) {
|
||||
if (cursor === 0) return;
|
||||
return set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
|
||||
}
|
||||
|
||||
// Ignore remaining control sequences; a paste arrives as one multi-char input.
|
||||
if (!input || key.tab || key.escape || key.meta || key.ctrl) return;
|
||||
set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
|
||||
},
|
||||
{ isActive: focus },
|
||||
);
|
||||
|
||||
if (value.length === 0) {
|
||||
if (!placeholder) return <Text>{focus ? invert(' ') : ' '}</Text>;
|
||||
return (
|
||||
<Text dimColor>
|
||||
{focus ? invert(placeholder.slice(0, 1)) : placeholder.slice(0, 1)}
|
||||
{placeholder.slice(1)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const shown = mask ? mask.repeat(value.length) : value;
|
||||
if (!focus) return <Text>{shown}</Text>;
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{shown.slice(0, cursor)}
|
||||
{invert(shown.slice(cursor, cursor + 1) || ' ')}
|
||||
{shown.slice(cursor + 1)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export type { KeyLike };
|
||||
Reference in New Issue
Block a user