From 2dc7b91c29aea6e23667edaa3481d3bab17dc6c4 Mon Sep 17 00:00:00 2001 From: Muhammad Zakir Ramadhan <61570975+zakirkun@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:32:14 +0700 Subject: [PATCH] Wire the new tools, the mcp hooks, and the farewell into the app Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli.tsx | 71 +++++- src/ui/App.tsx | 638 +++++++++++-------------------------------------- 2 files changed, 213 insertions(+), 496 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index 49471fe..7ec69ca 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -5,10 +5,12 @@ import type { LanguageModel, ModelMessage } from 'ai'; import { resolveAgent, VARIANTS, isThinkingLevel, type AgentVariant } from './agents'; import { configPath, loadConfig, missingKeyMessage, resolveModel, writeConfigFile, type Config } from './config'; import type { FallbackEvent } from './fallback'; +import { farewell } from './farewell'; import { readStdin, runHeadless } from './headless'; import { INIT_PROMPT, loadInstructions } from './instructions'; import { walk } from './ignore'; import { connectMcp } from './mcp'; +import { createCommitMessageTool } from './commit'; import { Memory, KIND_LABEL } from './memory'; import { costOf } from './pricing'; import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin'; @@ -32,7 +34,6 @@ const HELP = `shiro-neko ${VERSION} - agentic coding CLI usage: shiro [options] shiro -p "prompt" headless, prints to stdout cat file | shiro -p prompt read from stdin - options: -p, --print [prompt] headless mode; requires --yolo for tool use --json with -p, emit one JSON event per line @@ -67,6 +68,7 @@ env: SHIRO_PROVIDER SHIRO_MODEL SHIRO_BASE_URL SHIRO_API_KEY skills: builtin, plus ~/.shiro-neko/skills/*.md and .shiro/skills/*.md registry: /registry to browse and install external skills and plugins +mcp: /mcp to add a local or remote server, or list what is configured sessions: ${store.sessionsDir()} in-session: /help for the command list`; @@ -276,6 +278,10 @@ const session = new Session({ ...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}), extraTools: { ...(mcp?.tools ?? {}), + git_commit_message: createCommitMessageTool({ + model: languageModel ?? unconfiguredModel, + ...(headless ? {} : { cwd: process.cwd() }), + }), ...(has('--no-subagent') ? {} : { @@ -289,7 +295,7 @@ const session = new Session({ }), }), }, - autoApprove: ['task'], + autoApprove: ['task', 'git_commit_message'], messages: [...record.messages], onChange: (messages) => { // Debounced so a long tool loop does not hit the disk on every step. @@ -306,7 +312,6 @@ async function shutdown(code: number): Promise { await mcp?.close(); process.exit(code); } - const printArg = flag('-p', '--print'); if (printArg !== undefined) { const prompt = printArg || (await readStdin()); @@ -388,6 +393,51 @@ const hooks: AppHooks = { throw new Error(`nothing installed under the name "${bare}"`); }, }, + mcp: { + names: () => Object.keys(cfg.mcpServers ?? {}), + list: () => { + const servers = Object.entries(cfg.mcpServers ?? {}); + if (servers.length === 0) return 'no MCP servers configured\n\n`/mcp add` sets one up.'; + + const live = new Map(); + for (const name of Object.keys(mcp?.tools ?? {})) { + const server = /^mcp__([^_]+(?:_[^_]+)*)__/.exec(name)?.[1]; + if (server) live.set(server, (live.get(server) ?? 0) + 1); + } + const failed = new Map((mcp?.errors ?? []).map((e) => [e.server, e.message])); + + const rows = servers.map(([name, config]) => { + const where = 'url' in config ? config.url : [config.command, ...(config.args ?? [])].join(' '); + const state = failed.has(name) + ? `failed: ${failed.get(name)}` + : live.has(name) + ? `${live.get(name)} tools` + : has('--no-mcp') + ? 'not connected (--no-mcp)' + : 'not connected this session'; + return `- \`${name}\` (${'url' in config ? 'remote' : 'local'}) - ${state}\n ${where}`; + }); + + return [...rows, '', `configured in ${configPath()}`].join('\n'); + }, + add: async (result) => { + const servers = { ...(cfg.mcpServers ?? {}), [result.name]: result.config }; + cfg = { ...cfg, mcpServers: servers }; + const path = await writeConfigFile({ mcpServers: servers }); + const where = 'url' in result.config ? result.config.url : result.config.command; + // Connected at boot, like the servers already in the file: a mid-turn connect + // would change the tool list under a turn that is already running. + return `added mcp server ${result.name} (${where})\nsaved to ${path}\nrestart shiro to connect it`; + }, + remove: async (name) => { + const servers = { ...(cfg.mcpServers ?? {}) }; + if (!(name in servers)) throw new Error(`no MCP server named "${name}"`); + delete servers[name]; + cfg = { ...cfg, mcpServers: servers }; + const path = await writeConfigFile({ mcpServers: servers }); + return `removed mcp server ${name}\nsaved to ${path}\nrestart shiro to disconnect it`; + }, + }, initPrompt: INIT_PROMPT, history: promptHistory, recordPrompt: (text) => void store.appendHistory(text), @@ -513,12 +563,15 @@ const header = [ ...plugins.errors.map((e) => `plugin ${e.plugin}: ${e.message}`), memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined, mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined, + !mcp && cfg.mcpServers && Object.keys(cfg.mcpServers).length > 0 + ? `mcp: ${Object.keys(cfg.mcpServers).length} configured, not connected (--no-mcp)` + : undefined, ...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`), yolo ? 'approvals: OFF (--yolo), but deny rules and the guard still apply' : cfg.permission ? `approvals: rules for ${Object.keys(cfg.permission).join(', ')}, defaults elsewhere` - : 'approvals: ask for write_file, edit_file, multi_edit, bash, mcp__*', + : 'approvals: ask for write_file, edit_file, multi_edit, apply_patch, move_file, delete_file, bash, web_fetch, mcp__*', cfg.toolSets ? `tool sets: core, ${cfg.toolSets.join(', ')}` : undefined, '/help for commands', ] @@ -541,4 +594,14 @@ const app = render( { exitOnCtrlC: false }, ); await app.waitUntilExit(); +// Printed after Ink has released the screen, so it survives the final repaint. The +// title comes from the messages rather than `record`, whose own title is only +// refreshed by the debounced save and may not have run yet. +console.log( + farewell({ + id: record.id, + messages: session.messages.length, + title: store.titleOf(session.messages), + }), +); await shutdown(0); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8fbbe7a..759d627 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,133 +1,41 @@ -import { Box, Static, Text, useApp, useInput, useStdout } from 'ink'; -import SelectInput from 'ink-select-input'; -import Spinner from 'ink-spinner'; +import { Box, Static, Text, useApp, useInput, useStdout } from 'ink'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { parseCommand, matchCommands, type CommandSpec } from '../commands'; +import { parseCommand, matchCommands } from '../commands'; import { THINKING_LEVELS, VARIANTS } from '../agents'; import { completePath, matchPaths, pathToken } from '../complete'; import type { Config } from '../config'; -import { TODO_MARK, type NotebookState } from '../notebook'; +import { type NotebookState } from '../notebook'; import { costOf, formatUsd, usageLine } from '../pricing'; -import type { ApprovalDecision, ApprovalRequest, Session } from '../session'; -import type { SubagentEvent } from '../subagent'; +import type { Session } from '../session'; import { interruptBash, toolSetOf } from '../tools'; import { AskPanel, type AskBridge, type AskPending } from './Ask'; -import { Diff } from './Diff'; +import { Approval, createApprovalBridge, type ApprovalBridge, type Pending } from './Approval'; +import { applySubagentEvent, createNoticeBus, createSubagentBus, type NoticeBus, type SubagentBus } from './buses'; import { Markdown } from './Markdown'; +import { McpAdd, type McpAddResult } from './McpAdd'; import { Onboard, type OnboardResult } from './Onboard'; -import { InfoPanel, OutputPanel, QueuePanel, RegistryPanel, InstallPrompt, StatusBar, SubagentPanel, ThinkingPanel, TodoPanel, ActiveTool, FileMenu, type RegistryRow, type SubagentView } from './Panels'; +import { + InfoPanel, + OutputPanel, + QueuePanel, + RegistryPanel, + StatusBar, + SubagentPanel, + ThinkingPanel, + TodoPanel, + ActiveTool, + FileMenu, + Working, + type RegistryRow, + type SubagentView, +} from './Panels'; +import { CommandMenu, InstallConfirm, Picker } from './Pickers'; +import { contextPanel, costPanel, todosPanel, toolsPanel } from './panel-bodies'; import { PromptInput } from './PromptInput'; +import { nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript'; -type Line = - | { key: string; kind: 'user'; text: string } - | { key: string; kind: 'assistant'; text: string } - | { key: string; kind: 'tool'; name: string; detail: string[]; result?: string; ok: boolean } - | { key: string; kind: 'info'; text: string } - | { key: string; kind: 'error'; text: string }; - -type NewLine = Line extends infer T ? (T extends Line ? Omit : 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; -}; - -export function createApprovalBridge(): ApprovalBridge { - let setter: ((p: Pending | undefined) => void) | undefined; - return { - bind(fn) { - setter = fn; - }, - ask(req) { - return new Promise((resolve) => { - if (!setter) return resolve('deny'); // UI not mounted: fail closed - setter({ - req, - resolve: (d) => { - setter?.(undefined); - resolve(d); - }, - }); - }); - }, - }; -} - -/** One-way channel for out-of-band notices, e.g. an endpoint fallback. */ -export type NoticeBus = { - bind: (fn: (text: string) => void) => void; - emit: (text: string) => void; -}; - -export function createNoticeBus(): NoticeBus { - const queued: string[] = []; - let sink: ((text: string) => void) | undefined; - return { - bind(fn) { - sink = fn; - for (const text of queued.splice(0)) fn(text); - }, - emit(text) { - if (sink) sink(text); - else queued.push(text); - }, - }; -} - -/** Subagent progress, from the task tool to the panel. */ -export type SubagentBus = { - bind: (fn: (event: SubagentEvent) => void) => void; - emit: (event: SubagentEvent) => void; -}; - -export function createSubagentBus(): SubagentBus { - const queued: SubagentEvent[] = []; - let sink: ((event: SubagentEvent) => void) | undefined; - return { - bind(fn) { - sink = fn; - for (const event of queued.splice(0)) fn(event); - }, - emit(event) { - if (sink) sink(event); - else queued.push(event); - }, - }; -} - -/** Folds a subagent event into the panel's view, keeping finished agents visible. */ -export function applySubagentEvent(current: SubagentView[], event: SubagentEvent): SubagentView[] { - switch (event.type) { - case 'start': - return [ - ...current, - { id: event.id, kind: event.kind, description: event.description, steps: [], status: 'running' }, - ]; - case 'step': - return current.map((a) => - a.id === event.id ? { ...a, steps: [...a.steps, { tool: event.tool, summary: event.summary }] } : a, - ); - case 'result': - // Attaches to the step it answers rather than appending, so a subagent's - // step count stays the number of calls it made. - return current.map((a) => { - if (a.id !== event.id) return a; - const last = a.steps.at(-1); - if (!last || last.tool !== event.tool || last.outcome !== undefined) return a; - return { - ...a, - steps: [...a.steps.slice(0, -1), { ...last, outcome: event.summary, ok: event.ok }], - }; - }); - case 'end': - return current.map((a) => (a.id === event.id ? { ...a, status: event.ok ? 'done' : 'failed' } : a)); - case 'error': - return current.map((a) => (a.id === event.id ? { ...a, status: 'failed', error: event.message } : a)); - } -} +export { createApprovalBridge, createNoticeBus, createSubagentBus, applySubagentEvent }; +export type { ApprovalBridge, NoticeBus, SubagentBus }; /** Everything the slash commands need from the outside world. */ export type AppHooks = { @@ -160,270 +68,19 @@ export type AppHooks = { install: (name: string) => Promise; remove: (name: string) => Promise; }; + /** Configured MCP servers, and the add/remove actions that write config.json. */ + mcp: { + names: () => string[]; + list: () => string; + add: (result: McpAddResult) => Promise; + remove: (name: string) => Promise; + }; /** 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; - const first = o['command'] ?? o['path'] ?? o['pattern'] ?? o['url'] ?? o['description'] ?? o['question'] ?? o['name']; - if (typeof first === 'string') return first.length > 90 ? `${first.slice(0, 90)}...` : first; - - // A tool with no obvious label, e.g. todo_write, gets a shape rather than a - // JSON dump; the panels below already show the content. - const todos = o['todos']; - if (Array.isArray(todos)) return `${todos.length} task${todos.length === 1 ? '' : 's'}`; - const keys = Object.keys(o); - return keys.length === 0 ? '' : keys.slice(0, 3).join(', '); -} - -const clip = (s: string, n = 68) => (s.length > n ? `${s.slice(0, n)}...` : s); - -/** - * The arguments that matter for one call, one per line. - * - * `preview` picks a single field, which loses exactly the information a reader - * wants: a `read_file` with an offset, a `grep` scoped by `include`, the twenty - * paths a batch read is about to pull in. This is what goes under the tool line in - * the transcript and beside the spinner while a call is in flight. - */ -export function toolDetail(name: string, input: unknown): string[] { - if (input === null || typeof input !== 'object') return []; - const o = input as Record; - const str = (k: string) => (typeof o[k] === 'string' ? (o[k] as string) : undefined); - const num = (k: string) => (typeof o[k] === 'number' ? (o[k] as number) : undefined); - const bool = (k: string) => o[k] === true; - - switch (name) { - case 'read_file': { - const range = num('offset') ? `lines ${num('offset')}${num('limit') ? `-${num('offset')! + num('limit')! - 1}` : '+'}` : undefined; - return [clip(str('path') ?? ''), ...(range ? [range] : [])]; - } - case 'read_many_files': { - const files = Array.isArray(o['files']) ? (o['files'] as { path?: unknown }[]) : []; - const paths = files.map((f) => (typeof f.path === 'string' ? f.path : '?')); - // Every path, not a count: the point of showing this is knowing what is - // about to enter the context. - return paths.slice(0, 8).map(clip).concat(paths.length > 8 ? [`... ${paths.length - 8} more`] : []); - } - case 'write_file': { - const content = str('content') ?? ''; - return [clip(str('path') ?? ''), `${content.split('\n').length} lines, ${content.length} chars`]; - } - case 'edit_file': { - const old = str('oldString') ?? ''; - return [ - clip(str('path') ?? ''), - `- ${clip(old.split('\n')[0] ?? '', 60)}${old.includes('\n') ? ` (+${old.split('\n').length - 1} lines)` : ''}`, - ...(bool('replaceAll') ? ['every occurrence'] : []), - ]; - } - case 'multi_edit': { - const edits = Array.isArray(o['edits']) ? (o['edits'] as { oldString?: unknown }[]) : []; - return [ - clip(str('path') ?? ''), - ...edits.slice(0, 5).map((e, i) => { - const old = typeof e.oldString === 'string' ? e.oldString : ''; - return `${i + 1}. - ${clip(old.split('\n')[0] ?? '', 58)}`; - }), - ...(edits.length > 5 ? [`... ${edits.length - 5} more edits`] : []), - ]; - } - case 'apply_patch': { - const patch = str('patch') ?? ''; - const ops = [...patch.matchAll(/^\*\*\* (Add|Update|Delete) File: (.+)$/gm)].map( - (m) => `${m[1]!.toLowerCase()} ${m[2]!.trim()}`, - ); - const moves = [...patch.matchAll(/^\*\*\* Move to: (.+)$/gm)].map((m) => `move to ${m[1]!.trim()}`); - return [...ops, ...moves].slice(0, 10).map(clip); - } - case 'bash': { - const timeout = num('timeout'); - return [ - ...(str('command') ?? '').split('\n').slice(0, 4).map((l) => clip(l)), - ...(timeout ? [`timeout ${Math.round(timeout / 1000)}s`] : []), - ]; - } - case 'grep': { - const parts = [`/${str('pattern') ?? ''}/`]; - if (str('include')) parts.push(`in ${str('include')}`); - if (bool('ignoreCase')) parts.push('case-insensitive'); - if (bool('includeIgnored')) parts.push('including ignored files'); - return [clip(parts.join(' '), 90)]; - } - case 'glob': - return [clip(str('pattern') ?? ''), ...(bool('includeIgnored') ? ['including ignored files'] : [])]; - case 'list_dir': - return [clip(str('path') ?? '.'), `depth ${num('depth') ?? 2}`]; - case 'web_fetch': - return [clip(str('url') ?? '', 90)]; - case 'task': { - const kind = str('kind') ?? 'explore'; - return [`${kind}${kind === 'worker' ? ' (writes)' : ''}: ${clip(str('description') ?? '')}`]; - } - case 'todo_write': { - const todos = Array.isArray(o['todos']) ? (o['todos'] as { content?: unknown; status?: unknown }[]) : []; - return todos.slice(0, 6).map((t) => `${String(t.status ?? '')}: ${clip(String(t.content ?? ''), 56)}`); - } - case 'git_show': - return [str('ref') ?? '', ...(str('path') ? [clip(str('path')!)] : [])]; - case 'git_log': - return [`${num('limit') ?? 15} commits`, ...(str('path') ? [clip(str('path')!)] : [])]; - case 'git_diff': - return [bool('staged') ? 'staged' : 'working tree', ...(str('path') ? [clip(str('path')!)] : [])]; - case 'git_blame': { - const from = num('startLine'); - return [clip(str('path') ?? ''), ...(from ? [`lines ${from}-${num('endLine') ?? from + 40}`] : [])]; - } - case 'remember': - return [`${str('kind') ?? 'fact'}: ${clip(str('text') ?? '', 60)}`]; - case 'recall': - case 'forget': - return [clip(str('query') ?? str('text') ?? '')]; - case 'skill': - return [str('name') ?? '']; - default: { - const label = preview(input); - return label ? [clip(label, 90)] : []; - } - } -} - -/** First line of a tool result, so the transcript shows an outcome not just a call. */ -export function resultSummary(name: string, output: unknown): string { - const text = typeof output === 'string' ? output : JSON.stringify(output ?? ''); - if (!text) return ''; - - const lines = text.split('\n').filter((l) => l.trim().length > 0); - const first = lines[0] ?? ''; - - // grep and glob return one hit per line, so the count is the useful summary. - if (name === 'grep' || name === 'glob') { - if (/^No (matches|files matched)/.test(first)) return first; - return `${lines.length} ${name === 'grep' ? 'hit' : 'path'}${lines.length === 1 ? '' : 's'}`; - } - if (name === 'read_file' || name === 'read_many_files') return `${lines.length} lines`; - if (name === 'bash') { - const exit = /^exit: (\d+)/.exec(first); - return exit ? `exit ${exit[1]}${lines.length > 1 ? `, ${lines.length - 1} lines out` : ''}` : clip(first); - } - return clip(first, 78); -} - -/** - * Attaches a result to the most recent unanswered call of that tool. - * - * Matched on name rather than call id because the transcript is a flat list of - * committed lines, and a parallel pair of calls to the same tool is rare enough - * that "the newest one still waiting" is right in practice and cheap. - */ -function withResult(lines: Line[], name: string, result: string, ok: boolean): Line[] { - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i]!; - if (line.kind !== 'tool' || line.name !== name || line.result !== undefined) continue; - const next = [...lines]; - next[i] = { ...line, result, ok }; - return next; - } - return lines; -} - -function ApprovalDetail({ name, input }: { name: string; input: unknown }) { - const o = (input ?? {}) as Record; - if (name === 'bash') return {String(o['command'] ?? '')}; - if (name === 'write_file') { - const content = String(o['content'] ?? ''); - return ; - } - if (name === 'edit_file') { - return ; - } - return {JSON.stringify(input, null, 2)}; -} - -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 ( - - - {pending.req.repeated - ? `${pending.req.toolName} is repeating the same call` - : pending.req.subagent - ? `a worker subagent wants to run ${pending.req.toolName}` - : `${pending.req.toolName} wants to run`} - - {pending.req.repeated && ( - - allowed by the rules, but this is the third identical call this turn - - )} - {pending.req.subagent && !pending.req.repeated && ( - delegated work, gated by your rules exactly as a direct call is - )} - {!pending.req.repeated && pending.req.matchedPattern && pending.req.matchedPattern !== '*' && ( - {`matched ${pending.req.toolName}: "${pending.req.matchedPattern}"`} - )} - - - y allow once | a always allow{' '} - {pending.req.suggestedPattern === '*' - ? pending.req.toolName - : `${pending.req.toolName} ${pending.req.suggestedPattern}`}{' '} - | n deny - - - ); -} - -function CommandMenu({ matches, index }: { matches: CommandSpec[]; index: number }) { - const width = Math.max(...matches.map((c) => `/${c.name}${c.arg ? ` ${c.arg}` : ''}`.length)) + 1; - return ( - - {matches.map((c, i) => ( - - {i === index ? '> ' : ' '} - - {`/${c.name}${c.arg ? ` ${c.arg}` : ''}`.padEnd(width)} - - {c.summary} - - ))} - up/down move | tab complete | enter run | esc dismiss - - ); -} - -/** Keyboard wrapper around InstallPrompt, so the prompt itself stays presentational. */ -function InstallConfirm({ - staged, - onDone, -}: { - staged: { row: RegistryRow; url: string; preview: string }; - onDone: (yes: boolean) => void; -}) { - useInput((input, key) => { - const c = input.toLowerCase(); - if (c === 'y' || key.return) onDone(true); - else if (c === 'n' || key.escape) onDone(false); - }); - - return ( - - ); -} - export function App({ session, bridge, @@ -477,8 +134,12 @@ export function App({ const [installing, setInstalling] = useState< { row: RegistryRow; url: string; preview: string } | undefined >(); + const [addingMcp, setAddingMcp] = useState(false); + const [seconds, setElapsed] = useState(0); + const startedAt = useRef(undefined); - const modal = pending !== undefined || asking !== undefined || onboarding || installing !== undefined; + const modal = + pending !== undefined || asking !== undefined || onboarding || installing !== undefined || addingMcp; const anyPicker = modelPicker !== undefined || agentPicker || thinkPicker; const matches = matchCommands(draft); const menuOpen = matches.length > 0 && !menuDismissed && !busy && !modal && !anyPicker && !panel; @@ -536,8 +197,22 @@ export function App({ const setWorking = useCallback((value: boolean) => { busyRef.current = value; setBusy(value); + setElapsed(0); + startedAt.current = value ? Date.now() : undefined; }, []); + // One tick per second while busy, so a long turn reports how long it has been + // going. Derived from a timestamp rather than counted, because Ink's render loop + // is not a clock and a dropped tick would drift. + useEffect(() => { + if (!busy) return; + const t = setInterval(() => { + const from = startedAt.current; + if (from !== undefined) setElapsed(Math.floor((Date.now() - from) / 1000)); + }, 1000); + return () => clearInterval(t); + }, [busy]); + const push = useCallback((line: NewLine) => { setHistory((h) => [...h, { ...line, key: nextKey() }]); }, []); @@ -810,58 +485,27 @@ export function App({ return; case 'tools': push({ kind: 'user', text: chosen.trim() }); - setPanel({ - title: 'tools', - hint: `${session.activeTools().length} offered this turn of ${Object.keys(session.tools).length} registered`, - body: session - .activeTools() - .sort() - .map((t) => { - const set = toolSetOf(t); - return `- \`${t}\`${set ? ` ${set}` : ''}`; - }) - .join('\n'), - }); + setPanel(toolsPanel(session)); return; - case 'cost': { + case 'cost': push({ kind: 'user', text: chosen.trim() }); - const model = hooks.config().model; - const spend = costOf(model, session.inputTokens, session.outputTokens); - setPanel({ - title: 'cost', - hint: `session ${hooks.sessionId}`, - body: [ - `- model: \`${model}\``, - `- billed: ${session.inputTokens} in / ${session.outputTokens} out`, - `- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`, - `- context: ~${session.estimatedTokens()} tokens`, - `- agent: \`${hooks.agentName()}\` thinking \`${hooks.thinkingLevel()}\``, - ].join('\n'), - }); + setPanel( + costPanel(session, { + sessionId: hooks.sessionId, + model: hooks.config().model, + agent: hooks.agentName(), + thinking: hooks.thinkingLevel(), + }), + ); return; - } - case 'context': { + case 'context': push({ kind: 'user', text: chosen.trim() }); - const files = hooks.instructionFiles(); - setPanel({ - title: 'project instructions', - body: files.length - ? files.map((f) => `- \`${f}\``).join('\n') - : 'No `AGENTS.md`, `CLAUDE.md`, or `.shiro.md` found. Run `/init` to write one.', - }); + setPanel(contextPanel(hooks.instructionFiles())); return; - } - case 'todos': { + case 'todos': push({ kind: 'user', text: chosen.trim() }); - const { todos } = session.notebook.state(); - setPanel({ - title: 'task list', - body: todos.length - ? todos.map((t) => `- ${TODO_MARK[t.status]} ${t.content}${t.note ? ` (${t.note})` : ''}`).join('\n') - : 'No task list yet.', - }); + setPanel(todosPanel(session)); return; - } case 'notes': { push({ kind: 'user', text: chosen.trim() }); setPanel({ title: 'project memory', body: await hooks.listMemory() }); @@ -945,12 +589,28 @@ export function App({ } return; } + case 'mcp': { + push({ kind: 'user', text: chosen.trim() }); + if (action.action === 'add') { + setAddingMcp(true); + return; + } + if (action.action === 'remove') { + try { + push({ kind: 'info', text: await hooks.mcp.remove(action.arg!) }); + } catch (e) { + push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); + } + return; + } + setPanel({ title: 'mcp servers', hint: '/mcp add to add one', body: hooks.mcp.list() }); + return; + } case 'memory': { push({ kind: 'user', text: chosen.trim() }); setWorking(true); try { - push({ kind: 'info', text: await hooks.summarizeMemory() }); - } catch (e) { + push({ kind: 'info', text: await hooks.summarizeMemory() }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } setWorking(false); @@ -1109,6 +769,24 @@ export function App({ {asking && } + {addingMcp && ( + { + setAddingMcp(false); + push({ kind: 'info', text: 'mcp setup cancelled' }); + }} + onDone={async (result) => { + setAddingMcp(false); + try { + push({ kind: 'info', text: await hooks.mcp.add(result) }); + } catch (e) { + push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); + } + }} + /> + )} + {pending && } {onboarding && ( @@ -1131,76 +809,54 @@ export function App({ )} {modelPicker && ( - - - Choose a model ({modelPicker.length} available) - - enter to select, esc to cancel - ({ 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) }); - } - }} - /> - + ({ value: m, label: m }))} + current={hooks.config().model} + onSelect={(value) => { + setModelPicker(undefined); + try { + push({ kind: 'info', text: hooks.switchModel(value) }); + } catch (e) { + push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); + } + }} + /> )} {agentPicker && ( - - - Choose an agent - - enter to select, esc to cancel - ({ - 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) }); - } - }} - /> - + ({ value: v.name, label: `${v.name.padEnd(8)} ${v.summary}` }))} + current={hooks.agentName()} + limit={8} + onSelect={(value) => { + setAgentPicker(false); + try { + push({ kind: 'info', text: hooks.switchAgent(value) }); + } catch (e) { + push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); + } + }} + /> )} {thinkPicker && ( - - - Thinking level - - higher costs more and is slower; enter to select, esc to cancel - ({ 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) }); - } - }} - /> - + ({ value: l, label: l }))} + current={hooks.thinkingLevel()} + limit={8} + onSelect={(value) => { + setThinkPicker(false); + try { + push({ kind: 'info', text: hooks.switchThinking(value) }); + } catch (e) { + push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); + } + }} + /> )} {busy && !modal && ( @@ -1208,9 +864,7 @@ export function App({ {active && } - - working... esc to interrupt - + )}