Files
locode/src/ui/ink/StatusBar.tsx
T
kimandClaude Sonnet 5 2a35f40c73 Add plugin/hook/skill support, images, @mentions, dashboard, and stability fixes
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>
2026-07-07 18:17:45 +09:00

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>
);
}