Builds out locode's Claude Code plugin parity: MCP servers, slash commands, sub-agents, hooks (12 lifecycle events), and skills, all loadable from a local path or git URL. Also adds image support (read_file, /import), @-mention file autocomplete, a /dashboard stats view, /export with an editable filename prompt, an expanded git tool (reset/stash/merge/rebase/delete_branch), and a redesigned status bar. On top of that, a review pass found and fixed 10 correctness/stability bugs: argument-injection in git reset/merge/rebase (a ref like "--hard" was parsed as a flag), tool-call image results interleaving with native tool_call_id messages and breaking OpenAI-compatible message ordering, backgrounded bash jobs still being silently killed by their original timeout with the kill masked as a clean exit, a SubagentStart hook's block being ignored, the sub-agent timeout clock starting before the hook it should exclude, a template-expansion bug that could re-substitute $1..$9 placeholders, unbounded background-job output buffers, a missing directory-target fallback in /export, and image size caps checked after reading the whole file instead of before. Session saves and exports now go through a shared atomic write-then-rename helper so a crash can't leave a truncated file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
132 lines
4.4 KiB
TypeScript
132 lines
4.4 KiB
TypeScript
import { Box, Text } from "ink";
|
|
import { useEffect, useState } from "react";
|
|
import { ACCENT_HEX } from "../theme.js";
|
|
import type { PermissionMode } from "../../permissions/types.js";
|
|
import type { GitInfo } from "../../utils/gitInfo.js";
|
|
|
|
const MODE_LABELS: Record<PermissionMode, string> = {
|
|
default: "default",
|
|
"auto-edit": "auto-edit",
|
|
"auto-accept": "auto-accept",
|
|
};
|
|
|
|
// Distinct per mode so cycling modes (Shift+Tab / /perm) is visibly reflected here — default and
|
|
// auto-edit previously shared "yellow", making it look like the mode hadn't changed at all.
|
|
const MODE_COLORS: Record<PermissionMode, string> = {
|
|
default: "gray",
|
|
"auto-edit": "green",
|
|
"auto-accept": "yellowBright",
|
|
};
|
|
|
|
interface Props {
|
|
model: string;
|
|
mode: string;
|
|
permMode: PermissionMode;
|
|
cwd: string;
|
|
contextTokens: number;
|
|
contextWindow: number;
|
|
contextIsEstimate: boolean;
|
|
sessionId: string;
|
|
createdAt: string;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
gitInfo: GitInfo | null;
|
|
}
|
|
|
|
// Always a legible, visible color — never dimmed, since this is one of the most-referenced fields.
|
|
function contextColor(percent: number): string {
|
|
if (percent >= 85) return "red";
|
|
if (percent >= 60) return "yellow";
|
|
return "green";
|
|
}
|
|
|
|
function formatTokenCount(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
|
|
return String(n);
|
|
}
|
|
|
|
function renderBar(percent: number, width = 10): string {
|
|
const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)));
|
|
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
}
|
|
|
|
// Compact — "57m", "1h23m" — distinct from HistoryItemView's formatDuration (which keeps seconds
|
|
// for the /dashboard detail view); the status bar only has room for the coarse shape.
|
|
function formatElapsed(ms: number): string {
|
|
const totalMinutes = Math.floor(ms / 60_000);
|
|
if (totalMinutes < 1) return `${Math.floor(ms / 1000)}s`;
|
|
const h = Math.floor(totalMinutes / 60);
|
|
const m = totalMinutes % 60;
|
|
return h > 0 ? `${h}h${m}m` : `${m}m`;
|
|
}
|
|
|
|
export function StatusBar({
|
|
model,
|
|
mode,
|
|
permMode,
|
|
cwd,
|
|
contextTokens,
|
|
contextWindow,
|
|
contextIsEstimate,
|
|
sessionId,
|
|
createdAt,
|
|
inputTokens,
|
|
outputTokens,
|
|
gitInfo,
|
|
}: Props) {
|
|
// Ticks every 30s purely to keep "elapsed"/burn-rate live while otherwise idle — no other state
|
|
// here changes on its own, so without this the clock would freeze between messages.
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), 30_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const shortCwd = cwd.split(/[/\\]/).pop() ?? cwd;
|
|
const percent = Math.round((contextTokens / contextWindow) * 100);
|
|
const approx = contextIsEstimate ? "~" : "";
|
|
|
|
const elapsedMs = Math.max(0, now - new Date(createdAt).getTime());
|
|
const totalTokens = inputTokens + outputTokens;
|
|
const elapsedMinutes = elapsedMs / 60_000;
|
|
const burnRate = elapsedMinutes >= 0.1 ? totalTokens / elapsedMinutes : null;
|
|
|
|
return (
|
|
<Box flexDirection="column" width="100%" paddingX={1}>
|
|
<Box gap={1}>
|
|
<Text color={ACCENT_HEX}>
|
|
◆ {model} ({mode})
|
|
</Text>
|
|
<Text dimColor>│</Text>
|
|
<Text color={contextColor(percent)}>{renderBar(percent)}</Text>
|
|
<Text dimColor>│</Text>
|
|
<Text color={contextColor(percent)}>{percent}%</Text>
|
|
<Text dimColor>│</Text>
|
|
<Text dimColor>
|
|
{approx}
|
|
{formatTokenCount(contextTokens)}/{formatTokenCount(contextWindow)}
|
|
</Text>
|
|
</Box>
|
|
<Box gap={1}>
|
|
<Text dimColor>
|
|
📁 {shortCwd}
|
|
{gitInfo ? ` (${gitInfo.branch}${gitInfo.dirty ? "*" : ""})` : ""}
|
|
</Text>
|
|
<Text dimColor>│</Text>
|
|
<Text dimColor>🔑 {sessionId.slice(0, 8)}</Text>
|
|
<Text dimColor>│</Text>
|
|
<Text dimColor>⏱ {formatElapsed(elapsedMs)}</Text>
|
|
<Text dimColor>│</Text>
|
|
<Text dimColor>🔥 {burnRate === null ? "—" : `${formatTokenCount(burnRate)}/min`}</Text>
|
|
</Box>
|
|
<Box gap={1} justifyContent="space-between">
|
|
<Text color={MODE_COLORS[permMode]}>
|
|
⏵⏵ {MODE_LABELS[permMode]} (shift+tab to cycle)
|
|
</Text>
|
|
<Text color="yellow">/export · /compact · /help · /exit</Text>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|