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:
Muhammad Zakir Ramadhan
2026-09-02 17:30:18 +07:00
commit 5b8503fcd9
93 changed files with 12775 additions and 0 deletions
+792
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+99
View File
@@ -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)} />;
}
+242
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+143
View File
@@ -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 };