release 1.0.0: cost control, 41 tools, 29 skills, custom commands, auto-load

This commit is contained in:
Muhammad Zakir Ramadhan committed 2026-09-07 19:59:58 +07:00
1 parent a22d8e13b1
commit ffa9a02c26
97 files changed
+4791 -788

No files matched your search

+123 -34
View File
@@ -1,6 +1,7 @@
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { parseCommand, matchCommands } from '../commands';
import { expandCommand, type CustomCommand } from '../custom-commands';
import { THINKING_LEVELS, VARIANTS } from '../agents';
import { completePath, matchPaths, pathToken } from '../complete';
import type { Config } from '../config';
@@ -19,6 +20,8 @@ import {
OutputPanel,
QueuePanel,
RegistryPanel,
Footer,
InputStatus,
StatusBar,
SubagentPanel,
ThinkingPanel,
@@ -32,6 +35,7 @@ import {
import { CommandMenu, InstallConfirm, Picker } from './Pickers';
import { contextPanel, costPanel, todosPanel, toolsPanel } from './panel-bodies';
import { PromptInput } from './PromptInput';
import { accent, glyph } from './theme';
import { nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript';
export { createApprovalBridge, createNoticeBus, createSubagentBus, applySubagentEvent };
@@ -40,6 +44,8 @@ export type { ApprovalBridge, NoticeBus, SubagentBus };
/** Everything the slash commands need from the outside world. */
export type AppHooks = {
sessionId: string;
/** Session title for the welcome dashboard; absent in tests. */
title?: string;
config: () => Config;
switchModel: (id: string) => string;
switchAgent: (name: string) => string;
@@ -59,6 +65,8 @@ export type AppHooks = {
instructionFiles: () => string[];
/** Ignore-aware workspace paths for `@` completion, loaded on first use. */
listPaths: () => Promise<string[]>;
/** Custom slash commands from markdown files, for the menu and the parser. */
customCommands?: () => readonly CustomCommand[];
/** Registry index, installed set, and the install/remove actions. */
registry: {
list: () => Promise<RegistryRow[]>;
@@ -85,6 +93,8 @@ export function App({
session,
bridge,
header,
headerNode,
version,
hooks,
notices,
askBridge,
@@ -94,6 +104,10 @@ export function App({
session: Session;
bridge: ApprovalBridge;
header: string;
/** Rich welcome screen; when present it replaces the plain `header` string. */
headerNode?: React.ReactNode;
/** Build version, shown in the welcome dashboard's meta panel. */
version?: string;
hooks: AppHooks;
notices?: NoticeBus;
askBridge?: AskBridge;
@@ -101,7 +115,19 @@ export function App({
needsProvider?: boolean;
}) {
const { exit } = useApp();
const { write } = useStdout();
const { write, stdout } = useStdout();
// The footer splits hints left from context/cost right, and the input box and
// dashboards lay out against the real terminal width, so it is tracked and
// kept current on resize rather than read once.
const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80);
useEffect(() => {
if (!stdout) return;
const onResize = () => setTermWidth(stdout.columns ?? 80);
stdout.on('resize', onResize);
return () => {
stdout.off('resize', onResize);
};
}, [stdout]);
const [history, setHistory] = useState<Line[]>([]);
const [draft, setDraft] = useState('');
const [live, setLive] = useState('');
@@ -141,7 +167,7 @@ export function App({
const modal =
pending !== undefined || asking !== undefined || onboarding || installing !== undefined || addingMcp;
const anyPicker = modelPicker !== undefined || agentPicker || thinkPicker;
const matches = matchCommands(draft);
const matches = matchCommands(draft, hooks.customCommands?.() ?? []);
const menuOpen = matches.length > 0 && !menuDismissed && !busy && !modal && !anyPicker && !panel;
const highlighted = matches[Math.min(menuIndex, matches.length - 1)];
@@ -439,7 +465,7 @@ export function App({
// Enter on an open menu runs the highlighted entry, so `/mo` + enter works.
const chosen = menuOpen && highlighted ? `/${highlighted.name}` : raw;
const action = parseCommand(chosen);
const action = parseCommand(chosen, hooks.customCommands?.() ?? []);
switch (action.type) {
case 'none':
@@ -495,6 +521,7 @@ export function App({
model: hooks.config().model,
agent: hooks.agentName(),
thinking: hooks.thinkingLevel(),
...(hooks.config().subagentModel ? { subagentModel: hooks.config().subagentModel! } : {}),
}),
);
return;
@@ -610,7 +637,8 @@ export function App({
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);
@@ -681,6 +709,37 @@ export function App({
setRecall((h) => (h.at(-1) === action.text ? h : [...h, action.text]));
await runTurn(action.text);
return;
case 'custom': {
const typed = chosen.trim();
push({ kind: 'user', text: typed });
setWorking(true);
try {
// A command may pin an agent; it runs the prompt under that variant
// and restores afterwards, so one command does not leak its agent into
// the rest of the session.
const previous = hooks.agentName();
if (action.command.agent && action.command.agent !== previous) {
try {
hooks.switchAgent(action.command.agent);
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
}
const prompt = await expandCommand(action.command, action.args);
await runTurn(prompt);
if (action.command.agent && action.command.agent !== previous) {
try {
hooks.switchAgent(previous);
} catch {
// Restoring the agent is best-effort; the next /agent sets it explicitly.
}
}
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
setWorking(false);
return;
}
}
},
[exit, highlighted, hooks, menuOpen, push, runTurn, session, setWorking, unconfigured, write],
@@ -695,13 +754,24 @@ export function App({
<Static items={history}>
{(line) => (
<Box key={line.key} flexDirection="column" marginBottom={1}>
{line.kind === 'user' && <Text color="cyan">{`> ${line.text}`}</Text>}
{line.kind === 'assistant' && <Markdown text={line.text} />}
{line.kind === 'user' && (
<Text color={accent.user} bold>
{`${glyph.user} ${line.text}`}
</Text>
)}
{line.kind === 'assistant' && (
<Box>
<Text color={accent.ok}>{`${glyph.assistant} `}</Text>
<Box flexGrow={1} flexDirection="column">
<Markdown text={line.text} />
</Box>
</Box>
)}
{line.kind === 'tool' && (
<Box flexDirection="column">
<Box>
<Text color={line.ok ? 'magenta' : 'red'}>{line.ok ? '*' : 'x'} </Text>
<Text color={line.ok ? 'magenta' : 'red'} bold>
<Text color={line.ok ? accent.tool : accent.err}>{line.ok ? glyph.toolOk : glyph.toolErr} </Text>
<Text color={line.ok ? accent.tool : accent.err} bold>
{line.name}
</Text>
{line.detail[0] !== undefined && <Text dimColor>{` ${line.detail[0]}`}</Text>}
@@ -712,23 +782,26 @@ export function App({
</Text>
))}
{line.result !== undefined && line.result.length > 0 && (
<Text color={line.ok ? undefined : 'red'} dimColor={line.ok}>
{` ${line.ok ? '->' : 'x'} ${line.result}`}
<Text color={line.ok ? undefined : accent.err} dimColor={line.ok}>
{` ${line.ok ? glyph.result : glyph.err} ${line.result}`}
</Text>
)}
</Box>
)}
{line.kind === 'info' && <Text dimColor>{line.text}</Text>}
{line.kind === 'error' && <Text color="red">error: {line.text}</Text>}
{line.kind === 'info' && <Text dimColor>{`${glyph.info} ${line.text}`}</Text>}
{line.kind === 'error' && <Text color={accent.err}>{`${glyph.err} ${line.text}`}</Text>}
</Box>
)}
</Static>
{history.length === 0 && (
<Box marginBottom={1}>
<Text dimColor>{header}</Text>
</Box>
)}
{history.length === 0 &&
(headerNode !== undefined ? (
headerNode
) : (
<Box flexDirection="column" marginBottom={1}>
<Text dimColor>{header}</Text>
</Box>
))}
{agents.length > 0 && <SubagentPanel agents={agents} />}
@@ -869,19 +942,37 @@ export function App({
)}
{!modal && !anyPicker && (
<Box flexDirection="column">
<Box flexDirection="column" marginTop={1} width={termWidth}>
<QueuePanel prompts={queue} />
<Box>
<Text color="cyan">{'> '}</Text>
<PromptInput
key={inputGeneration}
value={draft}
initialCursor={inputCursor}
onChange={onDraftChange}
onSubmit={submit}
history={recall}
onKey={handleInputKey}
placeholder={busy ? 'type to queue for the next turn...' : 'ask shiro-neko... (/ commands, @ files)'}
<Box
flexDirection="column"
width={termWidth}
borderStyle="round"
borderColor={accent.mute}
borderLeftColor={busy ? accent.warn : accent.user}
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={accent.user} bold>
{`${glyph.user} `}
</Text>
<PromptInput
key={inputGeneration}
value={draft}
initialCursor={inputCursor}
onChange={onDraftChange}
onSubmit={submit}
history={recall}
onKey={handleInputKey}
placeholder={busy ? 'type to queue for the next turn…' : 'ask shiro-neko… (/ commands, @ files)'}
/>
</Box>
<InputStatus
agent={hooks.agentName()}
model={hooks.config().model}
right={`${hooks.thinkingLevel()} ${glyph.info} ${session.activeTools().length} tools`}
width={termWidth - 6}
/>
</Box>
{fileOpen ? (
@@ -894,17 +985,15 @@ export function App({
) : (
menuOpen && <CommandMenu matches={matches} index={Math.min(menuIndex, matches.length - 1)} />
)}
<StatusBar
model={hooks.config().model}
agent={hooks.agentName()}
thinking={hooks.thinkingLevel()}
<Footer
busy={busy}
width={termWidth}
contextTokens={session.estimatedTokens()}
contextLimit={session.compactThreshold()}
cost={(() => {
const spend = costOf(hooks.config().model, session.inputTokens, session.outputTokens);
return spend === undefined ? 'unpriced' : formatUsd(spend);
})()}
toolCount={session.activeTools().length}
/>
</Box>
)}
+20 -7
View File
@@ -2,6 +2,7 @@ import { Box, Text, useInput } from 'ink';
import React from 'react';
import type { ApprovalDecision, ApprovalRequest } from '../session';
import { Diff } from './Diff';
import { accent, glyph } from './theme';
import { toolDetail } from './transcript';
export type Pending = { req: ApprovalRequest; resolve: (d: ApprovalDecision) => void };
@@ -85,9 +86,9 @@ export function Approval({ pending }: { pending: Pending }) {
const grant = req.suggestedPattern === '*' ? req.toolName : `${req.toolName} ${req.suggestedPattern}`;
return (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>
{reason(req)}
<Box flexDirection="column" borderStyle="round" borderColor={accent.warn} paddingX={1}>
<Text color={accent.warn} bold>
{`${glyph.warn} ${reason(req)}`}
</Text>
{req.repeated && <Text dimColor>allowed by the rules, but this is the third identical call this turn</Text>}
{req.subagent && !req.repeated && (
@@ -97,10 +98,22 @@ export function Approval({ pending }: { pending: Pending }) {
<Text dimColor>{`matched ${req.toolName}: "${req.matchedPattern}"`}</Text>
)}
<ApprovalDetail name={req.toolName} input={req.input} />
<Text>
<Text color="green">y</Text> allow once | <Text color="green">a</Text> always allow {grant} |{' '}
<Text color="red">n</Text> deny
</Text>
<Box marginTop={1}>
<Text>
<Text color={accent.ok} bold>
y
</Text>
<Text dimColor>{` allow once ${glyph.sep} `}</Text>
<Text color={accent.ok} bold>
a
</Text>
<Text dimColor>{` always allow ${grant} ${glyph.sep} `}</Text>
<Text color={accent.err} bold>
n
</Text>
<Text dimColor> deny</Text>
</Text>
</Box>
</Box>
);
}
+9 -4
View File
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import type { AskRequest } from '../ask';
import { InlineMarkdown } from './Markdown';
import { PromptInput } from './PromptInput';
import { accent, glyph } from './theme';
export type AskPending = { req: AskRequest; resolve: (answers: string[] | undefined) => void };
@@ -70,8 +71,8 @@ export function AskPanel({ pending }: { pending: AskPending }) {
const detailOf = (label: string) => (options ?? []).find((o) => o.label === label)?.detail;
return (
<Box flexDirection="column" borderStyle="double" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>
<Box flexDirection="column" borderStyle="double" borderColor={accent.warn} paddingX={1}>
<Text color={accent.warn} bold>
shiro is asking
</Text>
<Box marginBottom={1}>
@@ -80,7 +81,9 @@ export function AskPanel({ pending }: { pending: AskPending }) {
{typing ? (
<Box>
<Text color="yellow">{'> '}</Text>
<Text color={accent.warn} bold>
{`${glyph.user} `}
</Text>
<PromptInput
value={draft}
onChange={setDraft}
@@ -107,7 +110,9 @@ export function AskPanel({ pending }: { pending: AskPending }) {
/>
{draft.length > 0 && <Text dimColor>{draft}</Text>}
<Text dimColor>
{multiple ? 'space/enter toggles, pick submit when done' : 'enter to choose'} | esc to skip
{multiple
? `space/enter toggles, pick submit when done ${glyph.sep} esc to skip`
: `enter to choose ${glyph.sep} esc to skip`}
</Text>
</Box>
)}
+6 -5
View File
@@ -1,5 +1,6 @@
import { Box, Text } from 'ink';
import React from 'react';
import { accent, glyph } from './theme';
export type DiffLine = { kind: 'context' | 'add' | 'remove'; text: string; at: number };
@@ -97,21 +98,21 @@ export function Diff({ before, after, path }: { before: string; after: string; p
<Box flexDirection="column">
{path && (
<Text>
<Text bold>{path}</Text> <Text color="green">+{added}</Text> <Text color="red">-{removed}</Text>
<Text bold>{path}</Text> <Text color={accent.ok}>+{added}</Text> <Text color={accent.err}>-{removed}</Text>
</Text>
)}
{shown.map((line, i) =>
line.kind === 'gap' ? (
<Text key={i} dimColor>{` ... lines ${line.from}-${line.to} unchanged`}</Text>
<Text key={i} dimColor>{` ${glyph.fold} lines ${line.from}-${line.to} unchanged`}</Text>
) : (
<Text
key={i}
color={line.kind === 'add' ? 'green' : line.kind === 'remove' ? 'red' : undefined}
color={line.kind === 'add' ? accent.ok : line.kind === 'remove' ? accent.err : undefined}
dimColor={line.kind === 'context'}
>{` ${String(line.at).padStart(3)} ${line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' '} ${line.text}`}</Text>
>{` ${String(line.at).padStart(3)} ${line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : glyph.sep} ${line.text}`}</Text>
),
)}
{hidden > 0 && <Text dimColor>{` ... ${hidden} more diff lines`}</Text>}
{hidden > 0 && <Text dimColor>{` ${glyph.fold} ${hidden} more diff lines`}</Text>}
</Box>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { Box, Text } from 'ink';
import React from 'react';
import { SidePanel } from './Panels';
import { accent, glyph } from './theme';
export type HeaderFact = {
label: string;
value: string;
/** Defaults to the quiet metadata colour; set for anything the user must not miss. */
tone?: 'warn' | 'err' | 'ok' | 'info';
};
const TONE_COLOR: Record<NonNullable<HeaderFact['tone']>, string> = {
warn: accent.warn,
err: accent.err,
ok: accent.ok,
info: accent.info,
};
/**
* The welcome dashboard, shown once before the first turn, in OpenCode's grammar.
*
* The session is introduced by a banner (what this conversation is), then the
* environment is grouped into a labelled panel so the eye scans one label rather
* than a wall of text. A final meta bar carries cwd and version, the two facts a
* bug report needs. Facts that demand attention — a missing provider, a failed
* plugin, `--yolo` — are lifted out of the quiet layer with colour, because a
* warning rendered dim is a warning nobody reads.
*/
export function Header({
version,
provider,
model,
sessionId,
cwd,
title,
facts,
}: {
version: string;
provider?: string;
model?: string;
sessionId: string;
cwd: string;
title?: string;
facts: readonly HeaderFact[];
}) {
return (
<Box flexDirection="column" marginBottom={1}>
<SidePanel label={title ?? 'new session'} tone={accent.user}>
<Box>
{model ? (
<>
<Text color={accent.ok}>{`${glyph.ok} `}</Text>
<Text bold>
{provider ? `${provider}/` : ''}
{model}
</Text>
<Text dimColor>{` ${glyph.sep} session ${sessionId}`}</Text>
</>
) : (
<>
<Text color={accent.warn}>{`${glyph.warn} `}</Text>
<Text color={accent.warn}>no provider configured</Text>
<Text dimColor>{` ${glyph.sep} run /provider to begin`}</Text>
</>
)}
</Box>
</SidePanel>
{facts.length > 0 && (
<SidePanel label="environment" tone={accent.ok}>
{facts.map((f, i) => (
<Box key={i}>
<Text dimColor>{f.label.padEnd(14)}</Text>
<Text color={f.tone ? TONE_COLOR[f.tone] : undefined} dimColor={f.tone === undefined}>
{f.value}
</Text>
</Box>
))}
</SidePanel>
)}
<Box paddingX={1}>
<Text dimColor>{cwd}</Text>
<Text dimColor>{` ${glyph.sep} `}</Text>
<Text dimColor>{`shiro-neko ${version}`}</Text>
<Text dimColor>{` ${glyph.sep} `}</Text>
<Text color={accent.user}>/help for commands</Text>
</Box>
</Box>
);
}
+211 -62
View File
@@ -4,28 +4,30 @@ import React from 'react';
import { TODO_MARK, type Todo } from '../notebook';
import type { SubagentKind } from '../subagent';
import { InlineMarkdown } from './Markdown';
import { accent, glyph, meter } from './theme';
const STATUS_COLOR: Record<Todo['status'], string | undefined> = {
pending: undefined,
in_progress: 'cyan',
done: 'green',
blocked: 'red',
in_progress: accent.user,
done: accent.ok,
blocked: accent.err,
};
/** Task list with a progress bar, shown above the input while a list exists. */
export function TodoPanel({ todos, width = 40 }: { todos: Todo[]; width?: number }) {
/** Task list with a progress meter, shown above the input while a list exists. */
export function TodoPanel({ todos, width = 24 }: { todos: Todo[]; width?: number }) {
const done = todos.filter((t) => t.status === 'done').length;
const blocked = todos.filter((t) => t.status === 'blocked').length;
const filled = todos.length === 0 ? 0 : Math.round((done / todos.length) * width);
const ratio = todos.length === 0 ? 0 : done / todos.length;
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} marginBottom={1}>
<Box flexDirection="column" borderStyle="round" borderColor={accent.mute} paddingX={1} marginBottom={1}>
<Box>
<Text bold>tasks </Text>
<Text color="green">{'#'.repeat(filled)}</Text>
<Text dimColor>{'.'.repeat(Math.max(0, width - filled))}</Text>
<Text bold color={accent.user}>
tasks{' '}
</Text>
<Text color={accent.ok}>{meter(ratio, width)}</Text>
<Text dimColor>{` ${done}/${todos.length}`}</Text>
{blocked > 0 && <Text color="red">{` ${blocked} blocked`}</Text>}
{blocked > 0 && <Text color={accent.err}>{` ${glyph.blocked} ${blocked} blocked`}</Text>}
</Box>
{todos.map((t, i) => (
<Box key={i}>
@@ -60,7 +62,7 @@ export type SubagentView = {
const KIND_LABEL: Record<SubagentKind, string> = { explore: 'explore', review: 'review', worker: 'worker' };
/** A worker can write, so its panel entry has to be distinguishable at a glance. */
const KIND_COLOUR: Record<SubagentKind, string> = { explore: 'cyan', review: 'blue', worker: 'yellow' };
const KIND_COLOUR: Record<SubagentKind, string> = { explore: accent.user, review: accent.info, worker: accent.warn };
/**
* Live view of delegated work.
@@ -74,33 +76,35 @@ export function SubagentPanel({ agents, steps = 4 }: { agents: SubagentView[]; s
if (agents.length === 0) return null;
return (
<Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} marginBottom={1}>
<Box flexDirection="column" borderStyle="round" borderColor={accent.tool} paddingX={1} marginBottom={1}>
{agents.map((a) => (
<Box key={a.id} flexDirection="column">
<Box>
{a.status === 'running' ? (
<Text color="magenta">
<Text color={accent.tool}>
<Spinner type="dots" />
</Text>
) : (
<Text color={a.status === 'done' ? 'green' : 'red'}>{a.status === 'done' ? '*' : 'x'}</Text>
<Text color={a.status === 'done' ? accent.ok : accent.err}>
{a.status === 'done' ? glyph.ok : glyph.err}
</Text>
)}
<Text bold color={KIND_COLOUR[a.kind]}>{` ${KIND_LABEL[a.kind]}`}</Text>
{a.kind === 'worker' && <Text color="yellow">{' (writes)'}</Text>}
<Text>{`: ${a.description}`}</Text>
{a.kind === 'worker' && <Text color={accent.warn}>{' (writes)'}</Text>}
<Text>{` ${glyph.sep} ${a.description}`}</Text>
<Text dimColor>{` ${a.steps.length} step${a.steps.length === 1 ? '' : 's'}`}</Text>
</Box>
{a.steps.slice(-steps).map((s, i) => (
<Box key={i} flexDirection="column">
<Text dimColor>{` ${s.tool}(${s.summary.slice(0, 58)})`}</Text>
{s.outcome !== undefined && (
<Text color={s.ok === false ? 'red' : undefined} dimColor={s.ok !== false}>
{` ${s.ok === false ? 'x' : '->'} ${s.outcome}`}
<Text color={s.ok === false ? accent.err : undefined} dimColor={s.ok !== false}>
{` ${s.ok === false ? glyph.err : glyph.result} ${s.outcome}`}
</Text>
)}
</Box>
))}
{a.error && <Text color="red">{` ${a.error}`}</Text>}
{a.error && <Text color={accent.err}>{` ${a.error}`}</Text>}
</Box>
))}
</Box>
@@ -123,10 +127,10 @@ const SLOW_AFTER = 10;
*/
export function Working({ seconds }: { seconds: number }) {
return (
<Text color="yellow">
<Text color={accent.warn}>
<Spinner type="dots" />{' '}
<Text dimColor>
{seconds >= SLOW_AFTER ? `working ${elapsed(seconds)}... esc to interrupt` : 'working... esc to interrupt'}
{seconds >= SLOW_AFTER ? `working ${elapsed(seconds)} ${glyph.sep} esc to interrupt` : `working ${glyph.sep} esc to interrupt`}
</Text>
</Text>
);
@@ -142,7 +146,7 @@ export function OutputPanel({ text, lines = 8 }: { text: string; lines?: number
.slice(-lines)
.map((l, i) => (
<Text key={i} dimColor>
{` | ${l}`}
{` ${glyph.sep} ${l}`}
</Text>
))}
</Box>
@@ -161,10 +165,10 @@ export function ActiveTool({ name, detail = [] }: { name: string; detail?: reado
return (
<Box flexDirection="column">
<Box>
<Text color="magenta">
<Text color={accent.tool}>
<Spinner type="dots" />
</Text>
<Text bold>{` ${name}`}</Text>
<Text bold color={accent.tool}>{` ${name}`}</Text>
{detail[0] !== undefined && <Text dimColor>{` ${detail[0]}`}</Text>}
</Box>
{detail.slice(1, 6).map((d, i) => (
@@ -172,7 +176,7 @@ export function ActiveTool({ name, detail = [] }: { name: string; detail?: reado
{` ${d}`}
</Text>
))}
{detail.length > 6 && <Text dimColor>{` ... ${detail.length - 6} more`}</Text>}
{detail.length > 6 && <Text dimColor>{` ${glyph.fold} ${detail.length - 6} more`}</Text>}
</Box>
);
}
@@ -189,12 +193,12 @@ export function ThinkingPanel({ text, expanded, lines = 8 }: { text: string; exp
const tokens = Math.round(text.length / 4);
if (!expanded) {
return <Text dimColor>{`thinking... ~${tokens} tokens ctrl-r to expand`}</Text>;
return <Text dimColor>{`thinking ${glyph.fold} ~${tokens} tokens ${glyph.sep} ctrl-r to expand`}</Text>;
}
return (
<Box flexDirection="column" marginBottom={1}>
<Text dimColor>{`thinking ~${tokens} tokens ctrl-r to collapse`}</Text>
<Text dimColor>{`thinking ~${tokens} tokens ${glyph.sep} ctrl-r to collapse`}</Text>
{text
.split('\n')
.slice(-lines)
@@ -212,10 +216,10 @@ export function QueuePanel({ prompts }: { prompts: readonly string[] }) {
if (prompts.length === 0) return null;
return (
<Box flexDirection="column">
<Text color="cyan">{`queued: ${prompts.length}`}</Text>
<Text color={accent.user}>{`${glyph.queued} queued: ${prompts.length}`}</Text>
{prompts.map((p, i) => (
<Text key={i} dimColor>
{` ${i + 1}. ${p.length > 70 ? `${p.slice(0, 70)}...` : p}`}
{` ${i + 1}. ${p.length > 70 ? `${p.slice(0, 70)}…` : p}`}
</Text>
))}
</Box>
@@ -237,7 +241,7 @@ export function FileMenu({
if (loading) {
return (
<Box marginTop={1}>
<Text dimColor>indexing files...</Text>
<Text dimColor>indexing files…</Text>
</Box>
);
}
@@ -253,17 +257,25 @@ export function FileMenu({
return (
<Box flexDirection="column" marginTop={1}>
{paths.map((p, i) => (
<Text key={p} color={i === index ? 'cyan' : undefined} dimColor={i !== index}>
{i === index ? '> ' : ' '}
<Text key={p} color={i === index ? accent.user : undefined} dimColor={i !== index}>
{i === index ? `${glyph.user} ` : ' '}
{p}
</Text>
))}
<Text dimColor>up/down move | tab or enter insert | esc dismiss</Text>
<Text dimColor>{`↑↓ move ${glyph.sep} tab/enter insert ${glyph.sep} esc dismiss`}</Text>
</Box>
);
}
/** Status line under the transcript: model, agent, thinking, context, spend. */
/**
* The persistent status line under the input.
*
* Three glance groups separated by quiet pipes: what is running (model, agent,
* thinking), how full the context is (a meter plus a percentage), and what it has
* cost. The context meter is the one piece of live information that changes
* mid-session, so it is the only element with a bar; everything else stays text so
* the bar is the thing the eye lands on.
*/
export function StatusBar({
model,
agent,
@@ -286,18 +298,144 @@ export function StatusBar({
// Amber from two thirds, red once compaction is imminent: the point is to warn
// before a turn silently loses its history, not after. Past 90 the colour is
// backed by words, because a reader watching the transcript is not watching this.
const contextColor = pct === undefined ? undefined : pct >= 90 ? 'red' : pct >= 66 ? 'yellow' : undefined;
const contextColor = pct === undefined ? undefined : pct >= 90 ? accent.err : pct >= 66 ? accent.warn : undefined;
const sep = <Text dimColor>{` ${glyph.sep} `}</Text>;
return (
<Box>
<Text dimColor>{`${model} `}</Text>
<Text color="cyan">{agent}</Text>
<Text dimColor>{`/${thinking} ${toolCount} tools `}</Text>
<Text dimColor>{model}</Text>
{sep}
<Text color={accent.user}>{agent}</Text>
<Text dimColor>{`/${thinking}`}</Text>
{sep}
<Text dimColor>{`${toolCount} tools`}</Text>
{sep}
<Text color={contextColor} dimColor={contextColor === undefined}>
{pct === undefined ? `~${contextTokens} ctx` : `${pct}% ctx`}
{pct === undefined ? `~${contextTokens} ctx` : `${meter(pct / 100, 8)} ${pct}%`}
</Text>
{pct !== undefined && pct >= 90 && <Text color="red">{' compacting soon'}</Text>}
<Text dimColor>{` ${cost}`}</Text>
{pct !== undefined && pct >= 90 && <Text color={accent.err}>{' compacting soon'}</Text>}
{sep}
<Text dimColor>{cost}</Text>
</Box>
);
}
/**
* The one-line identity row inside the input box, OpenCode-style.
*
* Agent and model are the two things a prompt is answered *by*, so they sit
* directly under the cursor rather than in a footer the eye has to travel to.
* `right` carries the quieter facts (tool count, thinking level) pushed to the
* far edge of the box, so the row reads as two anchored groups.
*/
export function InputStatus({
agent,
model,
right,
width,
}: {
agent: string;
model: string;
right?: string;
width: number;
}) {
const left = ` ${agent} · ${model}`;
const rightText = right ? `${right} ` : '';
const gap = Math.max(1, width - left.length - rightText.length);
return (
<Box>
<Text color={accent.user} bold>
{agent}
</Text>
<Text dimColor>{` ${glyph.info} ${model}`}</Text>
<Text dimColor>{' '.repeat(gap)}</Text>
{right !== undefined && <Text dimColor>{right}</Text>}
<Text> </Text>
</Box>
);
}
/**
* The full-width footer beneath the input, OpenCode-style.
*
* Key hints live on the left (what you can press), the live numbers on the right
* (context and cost). Spreading them to opposite edges means neither has to
* compete for the same glance, and the meter stays the most saturated thing on
* the line so the eye finds it first when a turn runs long.
*/
export function Footer({
busy,
contextTokens,
contextLimit,
cost,
width,
}: {
busy: boolean;
contextTokens: number;
contextLimit?: number;
cost: string;
width: number;
}) {
const pct = contextLimit ? Math.min(100, Math.round((contextTokens / contextLimit) * 100)) : undefined;
const contextColor = pct === undefined ? undefined : pct >= 90 ? accent.err : pct >= 66 ? accent.warn : undefined;
const hint = busy ? `${glyph.bullet} esc interrupt` : `/ commands ${glyph.sep} @ files ${glyph.sep} ↑ history`;
const right =
pct === undefined ? `~${contextTokens} ctx ${glyph.sep} ${cost}` : `${meter(pct / 100, 8)} ${pct}% ${glyph.sep} ${cost}`;
const gap = Math.max(1, width - hint.length - right.length - 1);
return (
<Box>
<Text dimColor>{hint}</Text>
<Text>{' '.repeat(gap)}</Text>
<Text color={contextColor} dimColor={contextColor === undefined}>
{right}
</Text>
{pct !== undefined && pct >= 90 && <Text color={accent.err}>{' compacting soon'}</Text>}
</Box>
);
}
/**
* A bordered panel with a coloured label header, OpenCode's sidebar grammar.
*
* Used for the welcome dashboard's grouped facts (capabilities, project, meta).
* The label carries the accent; the rows stay quiet, so a stack of these reads
* as labelled groups rather than as more transcript.
*/
export function SidePanel({
label,
children,
width,
tone = accent.ok,
}: {
label: string;
children: React.ReactNode;
width?: number;
tone?: string;
}) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={accent.mute}
paddingX={1}
marginBottom={1}
{...(width !== undefined ? { width } : {})}
>
<Text color={tone} bold>
{label}
</Text>
{children}
</Box>
);
}
/** One `name value` row inside a SidePanel, name padded so a stack aligns. */
export function SideRow({ name, value, nameWidth = 12, dim = true }: { name: string; value: string; nameWidth?: number; dim?: boolean }) {
return (
<Box>
<Text dimColor={dim}>{name.padEnd(nameWidth)}</Text>
<Text>{value}</Text>
</Box>
);
}
@@ -307,17 +445,19 @@ export type PanelLine = { label: string; value: string };
/** Bordered popup for a command's output, e.g. /skills or /cost. */
export function InfoPanel({ title, hint, lines }: { title: string; hint?: string; lines: PanelLine[] | string }) {
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
<Text color="cyan" bold>
{title}
</Text>
{hint && <Text dimColor>{hint}</Text>}
<Box flexDirection="column" borderStyle="round" borderColor={accent.user} paddingX={1} marginBottom={1}>
<Box>
<Text color={accent.user} bold>
{title}
</Text>
{hint && <Text dimColor>{` ${hint}`}</Text>}
</Box>
{typeof lines === 'string' ? (
<InlineMarkdown text={lines} />
) : (
lines.map((l, i) => (
<Box key={i}>
<Text color="gray">{l.label.padEnd(14)}</Text>
<Text color={accent.mute}>{l.label.padEnd(14)}</Text>
<Text>{l.value}</Text>
</Box>
))
@@ -335,7 +475,7 @@ export type RegistryRow = {
installed?: boolean;
};
const KIND_COLOR: Record<RegistryRow['kind'], string> = { skill: 'green', plugin: 'magenta' };
const KIND_COLOR: Record<RegistryRow['kind'], string> = { skill: accent.ok, plugin: accent.tool };
/**
* The registry index as a table.
@@ -359,20 +499,22 @@ export function RegistryPanel({
const width = Math.min(22, Math.max(...rows.map((r) => r.name.length)) + 1);
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
<Text color="cyan" bold>
{title}
</Text>
{hint && <Text dimColor>{hint}</Text>}
<Box flexDirection="column" borderStyle="round" borderColor={accent.user} paddingX={1} marginBottom={1}>
<Box>
<Text color={accent.user} bold>
{title}
</Text>
{hint && <Text dimColor>{` ${hint}`}</Text>}
</Box>
{rows.map((r) => (
<Box key={`${r.kind}:${r.name}`}>
<Text color={KIND_COLOR[r.kind]}>{r.kind === 'skill' ? 'S' : 'P'} </Text>
<Text bold>{r.name.padEnd(width)}</Text>
<Text dimColor>{r.description.length > 58 ? `${r.description.slice(0, 58)}...` : r.description}</Text>
{r.installed && <Text color="green">{' installed'}</Text>}
<Text dimColor>{r.description.length > 58 ? `${r.description.slice(0, 58)}…` : r.description}</Text>
{r.installed && <Text color={accent.ok}>{` ${glyph.ok} installed`}</Text>}
</Box>
))}
<Text dimColor>{'S skill P plugin | /registry add <name> | esc to dismiss'}</Text>
<Text dimColor>{`S skill P plugin ${glyph.sep} /registry add <name> ${glyph.sep} esc to dismiss`}</Text>
</Box>
);
}
@@ -402,8 +544,8 @@ export function InstallPrompt({
const hidden = body.length - shown.length;
return (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>
<Box flexDirection="column" borderStyle="round" borderColor={accent.warn} paddingX={1}>
<Text color={accent.warn} bold>
{`install ${kind} "${name}"?`}
</Text>
<Text dimColor>{url}</Text>
@@ -413,16 +555,23 @@ export function InstallPrompt({
{` ${l}`}
</Text>
))}
{hidden > 0 && <Text dimColor>{` ... ${hidden} more lines`}</Text>}
{hidden > 0 && <Text dimColor>{` ${glyph.fold} ${hidden} more lines`}</Text>}
</Box>
<Box marginTop={1} flexDirection="column">
<Text color="yellow">
<Text color={accent.warn}>
{kind === 'skill'
? 'A skill is instructions the agent follows. This text joins your system prompt.'
: 'A plugin adds refusal rules. It is data, not code: nothing here is executed.'}
</Text>
<Text>
<Text color="green">y</Text> install | <Text color="red">n</Text> cancel
<Text color={accent.ok} bold>
y
</Text>
<Text dimColor>{` install ${glyph.sep} `}</Text>
<Text color={accent.err} bold>
n
</Text>
<Text dimColor> cancel</Text>
</Text>
</Box>
</Box>
+14 -11
View File
@@ -3,6 +3,7 @@ import SelectInput from 'ink-select-input';
import React from 'react';
import type { CommandSpec } from '../commands';
import { InstallPrompt, type RegistryRow } from './Panels';
import { accent, glyph } from './theme';
/** The `/` menu, narrowing as the name is typed. */
export function CommandMenu({ matches, index }: { matches: readonly CommandSpec[]; index: number }) {
@@ -11,14 +12,14 @@ export function CommandMenu({ matches, index }: { matches: readonly CommandSpec[
<Box flexDirection="column" marginTop={1}>
{matches.map((c, i) => (
<Box key={c.name}>
<Text color={i === index ? 'cyan' : undefined}>{i === index ? '> ' : ' '}</Text>
<Text color={i === index ? 'cyan' : undefined} bold={i === index}>
<Text color={i === index ? accent.user : undefined}>{i === index ? `${glyph.user} ` : ' '}</Text>
<Text color={i === index ? accent.user : undefined} bold={i === index}>
{`/${c.name}${c.arg ? ` ${c.arg}` : ''}`.padEnd(width)}
</Text>
<Text dimColor>{c.summary}</Text>
</Box>
))}
<Text dimColor>up/down move | tab complete | enter run | esc dismiss</Text>
<Text dimColor>{`↑↓ move ${glyph.sep} tab complete ${glyph.sep} enter run ${glyph.sep} esc dismiss`}</Text>
</Box>
);
}
@@ -63,13 +64,13 @@ export function Frame({
children: React.ReactNode;
}) {
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text color="cyan" bold>
<Box flexDirection="column" borderStyle="round" borderColor={accent.user} paddingX={1}>
<Text color={accent.user} bold>
{title}
</Text>
{hint && <Text dimColor>{hint}</Text>}
{warning && <Text color="yellow">could not list models: {warning}</Text>}
{error && <Text color="red">{error}</Text>}
{warning && <Text color={accent.warn}>could not list models: {warning}</Text>}
{error && <Text color={accent.err}>{error}</Text>}
{children}
</Box>
);
@@ -79,7 +80,7 @@ export function Frame({
export function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<Box>
<Text color="cyan">{label}: </Text>
<Text color={accent.user}>{label}: </Text>
{children}
</Box>
);
@@ -109,11 +110,13 @@ export function Picker({
onSelect: (value: string) => void;
}) {
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text color="cyan" bold>
<Box flexDirection="column" borderStyle="round" borderColor={accent.user} paddingX={1}>
<Text color={accent.user} bold>
{title}
</Text>
<Text dimColor>{hint ? `${hint} - enter to select, esc to cancel` : 'enter to select, esc to cancel'}</Text>
<Text dimColor>
{hint ? `${hint} ${glyph.sep} enter to select ${glyph.sep} esc to cancel` : `enter to select ${glyph.sep} esc to cancel`}
</Text>
<SelectInput
items={options.map((o) => ({ key: o.value, label: o.label, value: o.value }))}
limit={limit}
+28 -12
View File
@@ -30,20 +30,36 @@ export function toolsPanel(session: Session): Panel {
export function costPanel(
session: Session,
info: { sessionId: string; model: string; agent: string; thinking: string },
info: { sessionId: string; model: string; agent: string; thinking: string; subagentModel?: string },
): Panel {
const spend = costOf(info.model, session.inputTokens, session.outputTokens);
return {
title: 'cost',
hint: `session ${info.sessionId}`,
body: [
`- model: \`${info.model}\``,
`- billed: ${session.inputTokens} in / ${session.outputTokens} out`,
`- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`,
`- context: ~${session.estimatedTokens()} tokens`,
`- agent: \`${info.agent}\` thinking \`${info.thinking}\``,
].join('\n'),
};
const lines = [
`- model: \`${info.model}\``,
`- billed: ${session.inputTokens} in / ${session.outputTokens} out`,
`- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`,
];
// Subagent spend is priced against its own model id, which may be the cheaper
// one, so it is reported as its own line rather than folded into the parent's.
if (session.subagentInputTokens + session.subagentOutputTokens > 0) {
const subModel = info.subagentModel ?? info.model;
const subSpend = costOf(subModel, session.subagentInputTokens, session.subagentOutputTokens);
lines.push(
`- subagents: ${session.subagentInputTokens} in / ${session.subagentOutputTokens} out (\`${subModel}\`)${
subSpend === undefined ? '' : ` - ${formatUsd(subSpend)}`
}`,
);
}
const ceiling = session.spend();
if (ceiling.ceiling !== undefined) {
lines.push(
`- ceiling: ${ceiling.usd === undefined ? 'unpriced' : formatUsd(ceiling.usd)} of ${formatUsd(ceiling.ceiling)}`,
);
}
lines.push(`- context: ~${session.estimatedTokens()} tokens`, `- agent: \`${info.agent}\` thinking \`${info.thinking}\``);
return { title: 'cost', hint: `session ${info.sessionId}`, body: lines.join('\n') };
}
export function contextPanel(files: readonly string[]): Panel {
+65
View File
@@ -0,0 +1,65 @@
/**
* The shared visual language for the whole UI.
*
* Every glyph, accent colour, and spacing constant lives here once, so a panel,
* the transcript, and the status bar agree with each other instead of each having
* grown its own dialect. The palette is deliberately narrow: one accent per
* *kind* of thing, reused everywhere that kind appears.
*
* cyan - the user and interactive affordances (you, keys you can press)
* green - success, additions, the assistant's done state
* magenta - tools and delegated work (the machine acting)
* yellow - caution, working state, decisions that need you
* red - errors, removals, denials
* gray - the quiet layer: hints, metadata, separators
*/
export const accent = {
user: 'cyan',
ok: 'green',
tool: 'magenta',
warn: 'yellow',
err: 'red',
mute: 'gray',
info: 'blue',
} as const;
/** Glyphs that read in any monospace font; no emoji, no nerd-font dependency. */
export const glyph = {
user: '>',
assistant: '●',
toolOk: '●',
toolErr: '✕',
ok: '✓',
err: '✕',
warn: '▲',
info: '·',
bullet: '•',
dash: '─',
sep: '│',
result: '→',
pending: '○',
progress: '◆',
blocked: '✕',
spinWorking: '⠋',
meterFull: '█',
meterEmpty: '░',
queued: '▸',
fold: '…',
} as const;
/**
* A thin horizontal rule, for separating a panel header from its body without a
* full second border. Width is clamped so a narrow terminal still gets a line.
*/
export const rule = (width: number) => glyph.dash.repeat(Math.max(4, width));
/** A tiny inline context meter, e.g. `███░░░░░░░ 34%`. Fixed cell count so the bar never shifts layout. */
export function meter(ratio: number, cells = 10): string {
const clamped = Math.max(0, Math.min(1, ratio));
const filled = Math.round(clamped * cells);
return glyph.meterFull.repeat(filled) + glyph.meterEmpty.repeat(Math.max(0, cells - filled));
}
/** Shorten a path or label to `n` columns, keeping the tail (the identifying part). */
export const clipTail = (s: string, n = 68) => (s.length > n ? `…${s.slice(-(n - 1))}` : s);