release 1.0.0: cost control, 41 tools, 29 skills, custom commands, auto-load
This commit is contained in:
1 parent
a22d8e13b1
commit
ffa9a02c26
97 files changed
+4791
-788
No files matched your search
+123
-34
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user