Side pane with the session's subagents, loaded skills and changed files (+/- lines)

A side pane that shows what the session is doing at a glance: the main agent card and its subagents, the skills used, the files changed in the repo, and the context window usage. It opens on session start, and /session toggles it.
╭────────────────────────────────────────────────╮
│ * opus-4-5 🧠 ▰▰▰▱▱ high 💰 $1.2345 │
│ ⚡ 23%/41% ↺ 2h15m ⏱ 42m07s │
╰────────────────────────────────────────────────╯
├─ ✓ * Explore find the pane hooks
│ ▰▰▰▰▰▰ 6/6
└─ ● * general-purpose write the README
▰▰▰▱ 3/4
Skills
code-review, simplify
Files
└─ * claude/mods/session/
├─ * README.md +72 ●
└─ * register.tsx +4 -1 ●
2 edited · +76 -1
Context
▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱
* 3k * 17k * 2k * 26k
24% 📊 48k/200k
/session toggle the pane
/context-icons list the Context legend's icons and grid cellshooks/register.tsx 277 lines1// Wiring of the session pane: state, the refreshes that need $, the hooks and the pane's assembly.
2// Everything that receives $ or names a $.state atom must live in this file: the validator does not
3// follow $ or an atom across an import. Pure helpers sit in lib/, each section's drawing in sections/.
4import { atom, read, update } from "claude-code";
5import type { EngineInterface, Register } from "claude-code";
6
7import type { ContextInfo, ModelInfo } from "../types";
8import { visibleAgents } from "./lib/agentTree";
9import { DIFF_ARGS, UNTRACKED_ARGS, parseNumstat, parseUntracked } from "./lib/git";
10import { colors } from "./lib/theme";
11import type { Colors } from "./lib/theme";
12import { AgentsSection } from "./sections/agents";
13import { CONTEXT_LEGEND, ContextSection } from "./sections/context";
14import { FilesSection } from "./sections/files";
15import { SkillsSection } from "./sections/skills";
16import { MainAgentCard } from "./sections/mainAgent";
17
18// ── state ────────────────────────────────────────────────────────────────────
19
20const files = atom({ plugin: "session", key: "files" } as const, []);
21// owned by the agents mod (the single source of the session's agents): read here, never written
22const agents = atom({ plugin: "agents", key: "list" } as const, []);
23const skills = atom({ plugin: "session", key: "skills" } as const, []);
24const info = atom({ plugin: "session", key: "info" } as const, null);
25// bumped on a timer and after each turn so usage, countdown and elapsed time redraw
26const tick = atom({ plugin: "session", key: "tick" } as const, 0);
27const context = atom({ plugin: "session", key: "context" } as const, null);
28
29const PANE = "session";
30const PANE_COLUMNS = 50;
31const TICK_MS = 10_000;
32// a finished agent stays in the pane this long after it ended, then leaves on the next tick
33const FINISHED_AGENT_KEEP_MS = 30_000;
34const GIT_POLL_MS = 10_000;
35const DEBOUNCE_MS = 300;
36const WRITERS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit", "Bash"]);
37
38// bumped by every refresh request: a run that is no longer the latest drops its result
39let refreshGeneration = 0;
40
41// ── refreshes ────────────────────────────────────────────────────────────────
42
43async function isPaneOpen($: EngineInterface) {
44 return (await $.ui.panes()).some((pane) => pane.id === PANE);
45}
46
47async function openPane($: EngineInterface) {
48 await $.ui.open({ id: PANE, title: "Session", columns: PANE_COLUMNS });
49}
50
51async function refresh($: EngineInterface) {
52 const thisGeneration = ++refreshGeneration;
53 // ponytail: HEAD fails in a repo without commits; untracked files still show there
54 const [diff, untracked] = await Promise.all([
55 $.process.run(DIFF_ARGS),
56 $.process.run(UNTRACKED_ARGS),
57 ]);
58 if (thisGeneration !== refreshGeneration) return;
59 const changes = diff.exitCode === 0 ? parseNumstat(diff.stdout) : [];
60 if (untracked.exitCode === 0) changes.push(...parseUntracked(untracked.stdout));
61 await update($, files, () => changes);
62}
63
64// coalesces bursts of tool calls into one git run, and skips it while the pane is closed
65async function scheduleRefresh($: EngineInterface) {
66 const thisGeneration = ++refreshGeneration;
67 await $.clock.sleep(DEBOUNCE_MS);
68 if (thisGeneration !== refreshGeneration || !(await isPaneOpen($))) return;
69 await refresh($);
70}
71
72// the summary breakdown is estimated locally: no API call
73async function refreshContext($: EngineInterface) {
74 const { breakdown } = (await $.session.usage({ breakdown: "summary" }))
75 .context;
76 if (!breakdown) return;
77 const contextInfo: ContextInfo = {
78 slices: breakdown.categories.map(({ name, tokens, kind }) => ({
79 name,
80 tokens,
81 kind,
82 })),
83 maxTokens: breakdown.maxTokens,
84 compactAt: breakdown.autoCompactThreshold ?? null,
85 };
86 await update($, context, () => contextInfo);
87}
88
89async function addSkill($: EngineInterface, name: string) {
90 await update($, skills, (skillNames) =>
91 skillNames.includes(name) ? skillNames : [...skillNames, name],
92 );
93}
94
95async function ticker($: EngineInterface) {
96 for (;;) {
97 await $.clock.sleep(TICK_MS);
98 await update($, tick, (tickCount) => tickCount + 1);
99 }
100}
101
102// git changes made outside Claude (a commit, a checkout in another terminal) raise no event:
103// re-read them on a timer while the pane is open
104// ponytail: polling; a file watcher if git ever gets slow on a big repo
105async function gitPoller($: EngineInterface) {
106 for (;;) {
107 await $.clock.sleep(GIT_POLL_MS);
108 if (!(await isPaneOpen($))) continue;
109 await refresh($).catch(() => {});
110 }
111}
112
113// $.palette is absent for a moment when this mod draws before palette has loaded: default colors then
114async function getTheme($: EngineInterface) {
115 try {
116 return await $.palette.get();
117 } catch {
118 return undefined;
119 }
120}
121
122// ── hooks ────────────────────────────────────────────────────────────────────
123
124export const register: Register = (on) => {
125 on("session.start", async ($, event, next) => {
126 await $.command.register({
127 name: "session",
128 description: "Toggle the session pane: agents, skills, changed files",
129 });
130 await $.command.register({
131 name: "context-icons",
132 description: "What the icons of the session pane's Context legend stand for",
133 });
134 void refresh($).catch(() => {});
135 void refreshContext($).catch(() => {});
136 void ticker($).catch(() => {});
137 void gitPoller($).catch(() => {});
138 void openPane($).catch(() => {});
139
140 return next(event);
141 });
142
143 on("command.run", { command: "session" }, async ($) => {
144 if (await isPaneOpen($)) {
145 await $.ui.close({ id: PANE });
146
147 return { text: "Session pane closed." };
148 }
149 await refresh($).catch(() => {});
150 await openPane($);
151
152 return { text: "Session pane opened." };
153 });
154
155 on("command.run", { command: "context-icons" }, async () => ({
156 text: CONTEXT_LEGEND,
157 }));
158
159 on("tool.call", async ($, event, next) => {
160 const result = await next(event);
161 if (event.tool === "Skill" && typeof event.skill === "string")
162 void addSkill($, event.skill).catch(() => {});
163 if (WRITERS.has(event.tool)) void scheduleRefresh($).catch(() => {});
164
165 return result;
166 });
167
168 on("turn.step", async function* ($, event, next) {
169 if (!event.agentId) {
170 const stepModel: ModelInfo = {
171 model: event.model.replace(/^claude-/, ""),
172 effort: event.effort === undefined ? undefined : String(event.effort),
173 };
174 void update($, info, () => stepModel).catch(() => {});
175 }
176
177 return yield* next(event);
178 });
179
180 on("turn.complete", async ($, event, next) => {
181 const result = await next(event);
182 if (!event.agentId) {
183 void update($, tick, (tickCount) => tickCount + 1).catch(() => {});
184 // the messages grow each turn; the threshold moves with the model (/model) and the settings
185 void refreshContext($).catch(() => {});
186 }
187
188 return result;
189 });
190
191 // skill.prompt did not fire in a live session on 2.1.291: Skill tool calls and typed
192 // /name commands are caught above and here instead
193 on("skill.prompt", async ($, event, next) => {
194 void addSkill($, event.skill).catch(() => {});
195
196 return next(event);
197 });
198
199 // ponytail: any user/plugin command counts as a skill, the engine does not tell them apart
200 on("command.run", async ($, event, next) => {
201 const result = await next(event);
202 if (event.command !== "session" && event.command !== "context-icons") {
203 const commandInfo = (await $.command.list()).find(
204 (command) => command.name === event.command,
205 );
206 if (
207 commandInfo &&
208 (commandInfo.source === "user" || commandInfo.source === "plugin")
209 )
210 await addSkill($, event.command).catch(() => {});
211 }
212
213 return result;
214 });
215
216 // ── pane ───────────────────────────────────────────────────────────────────
217
218 on("ui.render", { component: "Pane", requestId: PANE }, async ($, event) => {
219 const ui = $.ui.resolve(event);
220 const { Box, Text } = ui;
221 await read($, tick);
222 const [
223 changedFiles,
224 agentList,
225 skillList,
226 modelInfo,
227 contextInfo,
228 usage,
229 nowMs,
230 theme,
231 ] = await Promise.all([
232 read($, files),
233 read($, agents),
234 read($, skills),
235 read($, info),
236 read($, context),
237 $.session.usage(),
238 $.clock.now(),
239 getTheme($),
240 ]);
241 const color: Colors = theme ? colors(theme) : {};
242 // docked, fill the pane's height so the grown top block pushes Context to the bottom
243 const minHeight =
244 event.props.placement === "dock" ? event.props.scroll.bodyRows : undefined;
245
246 return (
247 <Box
248 flexDirection="column"
249 minHeight={minHeight}
250 backgroundColor={theme?.background}
251 >
252 <Box flexDirection="column" flexGrow={1}>
253 {MainAgentCard({ ui, color, modelInfo, usage, nowMs })}
254 {AgentsSection({
255 ui,
256 color,
257 agentList: visibleAgents(agentList, nowMs, FINISHED_AGENT_KEEP_MS),
258 bodyColumns: event.props.bodyColumns,
259 })}
260 <Text> </Text>
261 {SkillsSection({ ui, skillList })}
262 <Text> </Text>
263 {FilesSection({ ui, color, changedFiles })}
264 <Text> </Text>
265 </Box>
266 {ContextSection({
267 ui,
268 color,
269 contextInfo,
270 usage,
271 bodyColumns: event.props.bodyColumns,
272 })}
273 </Box>
274 );
275 });
276};
277hooks/lib/agentTree.ts 58 lines1import type { AgentEntry } from "agents";
2
3// one agent of the tree: `guide` is the "│ ├─ " in front of it, `underGuide` what goes in front of
4// a line drawn under it (its steps), carrying the tree's lines down to its siblings and children
5export type AgentRow = { guide: string; underGuide: string; agent: AgentEntry };
6
7export const FINISHED = new Set(["completed", "failed", "killed"]);
8
9// drops agents finished more than `keepMs` ago (no endedAt counts as long ago), but keeps a finished
10// agent while one of its descendants is still shown, so a running child stays nested under it
11export function visibleAgents(
12 agents: readonly AgentEntry[],
13 nowMs: number,
14 keepMs: number,
15): AgentEntry[] {
16 const isRecent = (agent: AgentEntry) =>
17 !FINISHED.has(agent.status) ||
18 (agent.endedAt !== undefined && nowMs - agent.endedAt < keepMs);
19 // ponytail: rescans the list per agent, fine for a session's few dozen agents
20 const isVisible = (agent: AgentEntry): boolean =>
21 isRecent(agent) ||
22 agents.some((child) => child.parentId === agent.id && isVisible(child));
23 return agents.filter(isVisible);
24}
25
26// agents nested under the agent that spawned them, each level in start order;
27// an agent whose parent is unknown (the main conversation, or a parent dropped from the list) is a root
28export function buildAgentTree(agents: readonly AgentEntry[]): AgentRow[] {
29 const knownIds = new Set(agents.map((agent) => agent.id));
30 const byStart = [...agents].sort(
31 (agentA, agentB) => (agentA.startedAt ?? 0) - (agentB.startedAt ?? 0),
32 );
33 const childrenOf = (parentId: string | undefined) =>
34 byStart.filter((agent) =>
35 parentId === undefined
36 ? !agent.parentId || !knownIds.has(agent.parentId)
37 : agent.parentId === parentId,
38 );
39
40 const rows: AgentRow[] = [];
41 const walk = (parentId: string | undefined, guide: string) => {
42 const children = childrenOf(parentId);
43 children.forEach((agent, childIndex) => {
44 const isLast = childIndex === children.length - 1;
45 const childGuide = guide + (isLast ? " " : "│ ");
46 const hasChildren = childrenOf(agent.id).length > 0;
47 rows.push({
48 guide: guide + (isLast ? "└─ " : "├─ "),
49 underGuide: childGuide + (hasChildren ? "│ " : " "),
50 agent,
51 });
52 walk(agent.id, childGuide);
53 });
54 };
55 walk(undefined, "");
56 return rows;
57}
58hooks/lib/git.ts 37 lines1import type { Change } from "../../types";
2
3// quotePath off: accented paths come out as-is instead of "caf\303\251.ts"
4export const GIT = ["git", "-c", "core.quotePath=false"];
5
6// both lists are repo-root relative and cover the whole repo, whatever the cwd
7export const DIFF_ARGS = [...GIT, "diff", "--numstat", "--no-renames", "HEAD"];
8export const UNTRACKED_ARGS = [
9 ...GIT,
10 "ls-files",
11 "--others",
12 "--exclude-standard",
13 "--full-name",
14 ":/",
15];
16
17// `git diff --numstat` line: "<added>\t<deleted>\t<path>", "-" for binary
18export const parseNumstat = (gitOutput: string): Change[] =>
19 gitOutput
20 .split("\n")
21 .filter(Boolean)
22 .map((line) => {
23 const [addedField = "", deletedField = "", ...pathParts] = line.split("\t");
24 return {
25 path: pathParts.join("\t"),
26 added: addedField === "-" ? null : Number(addedField),
27 deleted: deletedField === "-" ? null : Number(deletedField),
28 isNew: false,
29 };
30 });
31
32export const parseUntracked = (gitOutput: string): Change[] =>
33 gitOutput
34 .split("\n")
35 .filter(Boolean)
36 .map((path) => ({ path, added: null, deleted: null, isNew: true }));
37hooks/lib/theme.ts 21 lines1import type { Elements } from "claude-code";
2
3import type { PaletteTheme } from "palette";
4
5// the elements a section draws with: what $.ui.resolve gives, on every surface
6export type Ui = Pick<Elements[keyof Elements], "Box" | "Text">;
7
8// the colors this pane paints with, from the terminal theme the palette mod shares
9export const colors = (paletteTheme: PaletteTheme) => ({
10 ...paletteTheme.ansi,
11 yellowBright: paletteTheme.brights.yellow,
12 gray: paletteTheme.comment,
13});
14export type Role = keyof ReturnType<typeof colors>;
15// empty while palette has not loaded: the terminal's default colors then
16export type Colors = Partial<Record<Role, string>>;
17
18// thresholds of statusline-command.sh: green < 50 <= yellow < 80 <= red
19export const band = (percent: number): Role =>
20 percent >= 80 ? "red" : percent >= 50 ? "yellow" : "green";
21hooks/sections/agents.tsx 69 lines1import type { AgentEntry } from "agents";
2
3import { Meter } from "../components/meter";
4import { FINISHED, buildAgentTree } from "../lib/agentTree";
5import { modelIcon } from "../lib/icons";
6import type { Colors, Role, Ui } from "../lib/theme";
7
8const AGENT_STATUS: Record<string, { icon: string; color: Role }> = {
9 pending: { icon: "●", color: "yellow" },
10 running: { icon: "●", color: "yellow" },
11 waiting: { icon: "●", color: "yellow" },
12 idle: { icon: "○", color: "gray" },
13 completed: { icon: "✓", color: "green" },
14 failed: { icon: "✗", color: "red" },
15 killed: { icon: "✗", color: "red" },
16};
17const UNKNOWN_STATUS = { icon: "?", color: "gray" } as const;
18
19export function AgentsSection({
20 ui: { Box, Text },
21 color,
22 agentList,
23 bodyColumns,
24}: {
25 ui: Ui;
26 color: Colors;
27 agentList: readonly AgentEntry[];
28 // the pane's inner width, which the step strips fill
29 bodyColumns: number;
30}) {
31 return (
32 <Box flexDirection="column">
33 {buildAgentTree(agentList).map(({ guide, underGuide, agent }) => {
34 const agentStatus = AGENT_STATUS[agent.status] ?? UNKNOWN_STATUS;
35 const model = modelIcon(agent.model);
36 const counter = ` ${agent.stepsDone}/${agent.stepsStarted}`;
37 return (
38 <Box key={agent.id} flexDirection="column">
39 <Text wrap="truncate-end">
40 <Text dimColor>{guide}</Text>
41 <Text color={color[agentStatus.color]}>{agentStatus.icon} </Text>
42 <Text color={color[model.color]}>{model.glyph} </Text>
43 <Text color={color.cyan}>{agent.type} </Text>
44 <Text dimColor>{agent.description}</Text>
45 </Text>
46 {agent.stepsStarted > 0 && (
47 <Text wrap="truncate-end">
48 <Text dimColor>{underGuide}</Text>
49 {/* one cell per step; the empty one left is the step running */}
50 {Meter({
51 ui: { Box, Text },
52 filled: agent.stepsDone,
53 total: agent.stepsStarted,
54 maxWidth: bodyColumns - underGuide.length - counter.length,
55 filledColor: FINISHED.has(agent.status)
56 ? color.green
57 : color.blue,
58 emptyColor: color.yellow,
59 })}
60 <Text dimColor>{counter}</Text>
61 </Text>
62 )}
63 </Box>
64 );
65 })}
66 </Box>
67 );
68}
69hooks/sections/context.tsx 112 lines1import type { SessionUsage } from "claude-code";
2
3import type { ContextInfo, ContextSlice } from "../../types";
4import { contextCells, sliceRuns } from "../lib/contextGrid";
5import { fmtTokens } from "../lib/format";
6import type { Colors, Role, Ui } from "../lib/theme";
7
8// share of the auto-compaction threshold past which the context line warns
9const COMPACT_WARN = 0.9;
10const GRID_ROWS = 4;
11// the grid's cells; the legend keeps the plain square for a category it has no icon for
12const USED_CELL = "▰";
13const FREE_CELL = "▱";
14const UNKNOWN_ICON = "■";
15// by /context's row names: the rows have no stable id, only a kind; a row not listed takes the next spare color
16// and the plain cell as its legend icon
17const SLICE_STYLE: Record<string, { role: Role; icon: string }> = {
18 "System prompt": { role: "blue", icon: "" }, // nf-fa-cog
19 "System tools": { role: "cyan", icon: "" }, // nf-fa-wrench
20 "MCP tools": { role: "magenta", icon: "" }, // nf-fa-plug
21 "MCP server instructions": { role: "magenta", icon: "" }, // nf-fa-info_circle
22 "Custom agents": { role: "white", icon: "" }, // nf-fa-users
23 "Memory files": { role: "yellow", icon: "" }, // nf-fa-database
24 Skills: { role: "yellowBright", icon: "" }, // nf-fa-bolt
25 Messages: { role: "green", icon: "" }, // nf-fa-comments
26};
27const SPARE_COLORS: Role[] = ["cyan", "magenta", "yellow", "blue"];
28
29// what /context-icons prints: each icon of the legend and the grid's other cells, by name
30export const CONTEXT_LEGEND = [
31 ...Object.entries(SLICE_STYLE).map(([name, style]) => `${style.icon} ${name}`),
32 `${UNKNOWN_ICON} another category, its name beside it`,
33 `${FREE_CELL} free space (grid)`,
34 `${USED_CELL} autocompact buffer (grid, red)`,
35].join("\n");
36
37export function ContextSection({
38 ui: { Box, Text },
39 color,
40 contextInfo,
41 usage,
42 bodyColumns,
43}: {
44 ui: Ui;
45 color: Colors;
46 contextInfo: ContextInfo | null;
47 usage: SessionUsage;
48 // the pane's inner width, which the grid fills
49 bodyColumns: number;
50}) {
51 const contextPercent = usage.context.percent ?? 0;
52 const contextTokens = usage.context.tokens;
53 const compactTokens = contextInfo?.compactAt;
54 // close to the auto-compaction threshold, which can sit well below the model's window
55 const nearCompact =
56 !!contextTokens &&
57 !!compactTokens &&
58 contextTokens >= compactTokens * COMPACT_WARN;
59 const usedSlices =
60 contextInfo?.slices.filter(
61 (slice) => slice.kind === "used" && slice.tokens > 0,
62 ) ?? [];
63 const sliceColor = (slice: ContextSlice) => {
64 if (slice.kind === "buffer") return color.red;
65 if (slice.kind !== "used") return color.gray;
66 const spareIndex = usedSlices.indexOf(slice) % SPARE_COLORS.length;
67 return color[
68 SLICE_STYLE[slice.name]?.role ?? SPARE_COLORS[spareIndex] ?? "gray"
69 ];
70 };
71 const cells = contextInfo
72 ? contextCells(contextInfo.slices, contextInfo.maxTokens, GRID_ROWS * bodyColumns)
73 : [];
74 const gridRows = Array.from({ length: cells.length ? GRID_ROWS : 0 }, (_, rowIndex) =>
75 sliceRuns(cells.slice(rowIndex * bodyColumns, (rowIndex + 1) * bodyColumns)),
76 );
77
78 return (
79 <Box flexDirection="column">
80 <Text bold>Context</Text>
81 {gridRows.map((runs, rowIndex) => (
82 <Text key={`row${rowIndex}`} wrap="truncate-end">
83 {runs.map((run, runIndex) => (
84 <Text
85 key={`run${runIndex}`}
86 color={sliceColor(run.slice)}
87 dimColor={run.slice.kind !== "used"}
88 >
89 {(run.slice.kind === "free" ? FREE_CELL : USED_CELL).repeat(run.length)}
90 </Text>
91 ))}
92 </Text>
93 ))}
94 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
95 {usedSlices.map((slice) => (
96 <Text key={slice.name} color={sliceColor(slice)}>
97 {SLICE_STYLE[slice.name]?.icon ?? `${UNKNOWN_ICON} ${slice.name}`}{" "}
98 {fmtTokens(slice.tokens)}
99 </Text>
100 ))}
101 </Box>
102 <Text color={nearCompact ? color.red : color.yellow} wrap="truncate-end">
103 {Math.round(contextPercent)}% 📊{" "}
104 {contextTokens
105 ? `${fmtTokens(contextTokens)}/${fmtTokens(usage.context.window)}`
106 : "--/--"}
107 {nearCompact ? " ⚠" : ""}
108 </Text>
109 </Box>
110 );
111}
112hooks/sections/files.tsx 71 lines1import type { Change } from "../../types";
2import { EDITED_MARK, FOLDER_ICON, fileIcon } from "../lib/icons";
3import type { Colors, Ui } from "../lib/theme";
4import { buildTree } from "../lib/tree";
5
6export function FilesSection({
7 ui: { Box, Text },
8 color,
9 changedFiles,
10}: {
11 ui: Ui;
12 color: Colors;
13 changedFiles: readonly Change[];
14}) {
15 const addedTotal = changedFiles.reduce(
16 (total, file) => total + (file.added ?? 0),
17 0,
18 );
19 const deletedTotal = changedFiles.reduce(
20 (total, file) => total + (file.deleted ?? 0),
21 0,
22 );
23
24 return (
25 <Box flexDirection="column">
26 <Text bold>Files</Text>
27 {changedFiles.length === 0 && <Text dimColor>None.</Text>}
28 {buildTree(changedFiles).map((row, rowIndex) => {
29 const change = row.item;
30 const icon = change ? fileIcon(row.label) : undefined;
31 return (
32 <Box key={`file-${rowIndex}`} flexDirection="row" gap={1}>
33 <Text wrap="truncate-end">
34 <Text dimColor>{row.branch}</Text>
35 {icon ? (
36 <Text color={color[icon.color]}>{icon.glyph} </Text>
37 ) : (
38 <Text color={color.yellow}>{FOLDER_ICON} </Text>
39 )}
40 {change ? (
41 row.label
42 ) : (
43 <Text color={color.blue}>{row.label}</Text>
44 )}
45 </Text>
46 {change && change.added !== null && (
47 <Text color={color.green}>+{change.added}</Text>
48 )}
49 {change && change.deleted !== null && change.deleted > 0 && (
50 <Text color={color.red}>-{change.deleted}</Text>
51 )}
52 {change && change.added === null && !change.isNew && (
53 <Text dimColor>bin</Text>
54 )}
55 {change && (
56 <Text color={change.isNew ? color.green : color.yellow}>
57 {EDITED_MARK}
58 </Text>
59 )}
60 </Box>
61 );
62 })}
63 {changedFiles.length > 0 && (
64 <Text dimColor>
65 {changedFiles.length} edited · +{addedTotal} -{deletedTotal}
66 </Text>
67 )}
68 </Box>
69 );
70}
71hooks/sections/skills.tsx 21 lines1import type { Ui } from "../lib/theme";
2
3export function SkillsSection({
4 ui: { Box, Text },
5 skillList,
6}: {
7 ui: Ui;
8 skillList: readonly string[];
9}) {
10 return (
11 <Box flexDirection="column">
12 <Text bold>Skills</Text>
13 {skillList.length === 0 ? (
14 <Text dimColor>None.</Text>
15 ) : (
16 <Text wrap="wrap">{skillList.join(", ")}</Text>
17 )}
18 </Box>
19 );
20}
21hooks/sections/mainAgent.tsx 119 lines1import type { SessionUsage } from "claude-code";
2
3import type { ModelInfo } from "../../types";
4import { Meter } from "../components/meter";
5import { fmtElapsed, fmtLeft } from "../lib/format";
6import { modelIcon } from "../lib/icons";
7import { band } from "../lib/theme";
8import type { Colors, Role, Ui } from "../lib/theme";
9
10// the effort scale, one meter cell per level; a level outside it (a number) shows as text only
11const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
12// low and medium are drawn gray
13const EFFORT_COLOR: Record<string, Role> = {
14 max: "magenta",
15 xhigh: "magenta",
16 high: "cyan",
17};
18
19// the main conversation's card, the root the subagents' tree hangs from: model, effort, cost, limits, time
20export function MainAgentCard({
21 ui: { Box, Text },
22 color,
23 modelInfo,
24 usage,
25 nowMs,
26}: {
27 ui: Ui;
28 color: Colors;
29 modelInfo: ModelInfo | null;
30 usage: SessionUsage;
31 nowMs: number;
32}) {
33 const fiveHourLimit = usage.rateLimits.find(
34 (rateLimit) => rateLimit.kind === "five_hour",
35 );
36 const sevenDayLimit = usage.rateLimits.find(
37 (rateLimit) => rateLimit.kind === "seven_day",
38 );
39 const spendLimit = usage.rateLimits.find(
40 (rateLimit) => rateLimit.kind === "spend_limit",
41 );
42 // one countdown, for the 7d window once it is under pressure, else the 5h one
43 const countdownLimit =
44 sevenDayLimit &&
45 fiveHourLimit &&
46 sevenDayLimit.percentUsed > fiveHourLimit.percentUsed &&
47 sevenDayLimit.percentUsed >= 50
48 ? sevenDayLimit
49 : fiveHourLimit;
50 const secondsUntilReset = countdownLimit?.resetsAt
51 ? Math.floor((Date.parse(countdownLimit.resetsAt) - nowMs) / 1000)
52 : 0;
53 const elapsedSeconds = Math.floor((nowMs - usage.startedAt) / 1000);
54 const effort = modelInfo?.effort;
55 const effortLevel = effort ? EFFORT_LEVELS.indexOf(effort) + 1 : 0;
56 const effortColor = color[(effort && EFFORT_COLOR[effort]) || "gray"];
57 const model = modelIcon(modelInfo?.model);
58
59 return (
60 <Box
61 flexDirection="column"
62 borderStyle="round"
63 borderColor={color.gray}
64 paddingX={1}
65 >
66 <Box flexDirection="row" gap={2}>
67 <Text bold>
68 <Text color={color[model.color]}>{model.glyph} </Text>
69 <Text color={color.cyan}>{modelInfo?.model ?? "Claude"}</Text>
70 </Text>
71 {effort && (
72 <Text color={effortColor}>
73 🧠{" "}
74 {effortLevel > 0 && (
75 <Text>
76 {Meter({
77 ui: { Box, Text },
78 filled: effortLevel,
79 total: EFFORT_LEVELS.length,
80 filledColor: effortColor,
81 })}{" "}
82 </Text>
83 )}
84 {effort}
85 </Text>
86 )}
87 {usage.cost && usage.cost.usd > 0 && (
88 <Text color={color.red}>💰 ${usage.cost.usd.toFixed(4)}</Text>
89 )}
90 </Box>
91 <Box flexDirection="row" gap={2}>
92 {fiveHourLimit && sevenDayLimit && (
93 <Text>
94 <Text color={color.yellow}>⚡ </Text>
95 <Text color={color[band(fiveHourLimit.percentUsed)]}>
96 {Math.round(fiveHourLimit.percentUsed)}%
97 </Text>
98 <Text dimColor>/</Text>
99 <Text color={color[band(sevenDayLimit.percentUsed)]}>
100 {Math.round(sevenDayLimit.percentUsed)}%
101 </Text>
102 {secondsUntilReset > 0 && (
103 <Text color={color.blue}> ↺ {fmtLeft(secondsUntilReset)}</Text>
104 )}
105 </Text>
106 )}
107 {spendLimit && (
108 <Text color={color[band(spendLimit.percentUsed)]}>
109 💳 {Math.round(spendLimit.percentUsed)}%
110 </Text>
111 )}
112 {elapsedSeconds > 0 && (
113 <Text color={color.magenta}>⏱ {fmtElapsed(elapsedSeconds)}</Text>
114 )}
115 </Box>
116 </Box>
117 );
118}
119hooks/components/meter.tsx 54 lines1import type { Ui } from "../lib/theme";
2
3// the meter's glyphs, in one place: a style change touches this file only
4const FILLED_CELL = "▰";
5const EMPTY_CELL = "▱";
6const OVERFLOW = "…";
7
8// `filled` cells out of `total`, as text. Past `maxWidth` cells only the last ones show, after "…"
9export function meterCells(filled: number, total: number, maxWidth = Infinity) {
10 const shownTotal = Math.max(0, Math.round(total));
11 const shownFilled = Math.min(shownTotal, Math.max(0, Math.round(filled)));
12 const room = Math.max(1, Math.floor(maxWidth));
13 const isOverflowing = shownTotal > room;
14 // the tail of the row: drop cells from the filled side first
15 const kept = isOverflowing ? room - 1 : shownTotal;
16 const emptyCount = Math.min(shownTotal - shownFilled, kept);
17 return {
18 overflow: isOverflowing ? OVERFLOW : "",
19 filled: FILLED_CELL.repeat(kept - emptyCount),
20 empty: EMPTY_CELL.repeat(emptyCount),
21 };
22}
23
24// a gauge of cells: the context fill, an agent's steps, the effort level...
25// `emptyColor` absent draws the empty cells dim
26export function Meter({
27 ui: { Text },
28 filled,
29 total,
30 maxWidth,
31 filledColor,
32 emptyColor,
33}: {
34 ui: Ui;
35 filled: number;
36 total: number;
37 maxWidth?: number;
38 filledColor?: string;
39 emptyColor?: string;
40}) {
41 const cells = meterCells(filled, total, maxWidth);
42 return (
43 <Text>
44 {cells.overflow && <Text dimColor>{cells.overflow}</Text>}
45 <Text color={filledColor}>{cells.filled}</Text>
46 {emptyColor ? (
47 <Text color={emptyColor}>{cells.empty}</Text>
48 ) : (
49 <Text dimColor>{cells.empty}</Text>
50 )}
51 </Text>
52 );
53}
54hooks/lib/icons.ts 54 lines1import type { Role } from "./theme";
2
3// Nerd Font glyphs and colors after nvim-web-devicons, so the tree reads like neo-tree
4export const FOLDER_ICON = ""; // nf-custom-folder_open
5const DEFAULT_FILE_ICON = { glyph: "", color: "white" } as const; // nf-fa-file
6const FILE_ICONS: Record<string, { glyph: string; color: Role }> = {
7 ts: { glyph: "", color: "blue" }, // nf-seti-typescript
8 tsx: { glyph: "", color: "cyan" }, // nf-dev-react
9 js: { glyph: "", color: "yellow" }, // nf-dev-javascript
10 jsx: { glyph: "", color: "cyan" },
11 json: { glyph: "", color: "yellow" }, // nf-seti-json
12 md: { glyph: "", color: "white" }, // nf-oct-markdown
13 lua: { glyph: "", color: "blue" }, // nf-seti-lua
14 sh: { glyph: "", color: "green" }, // nf-dev-terminal
15 bash: { glyph: "", color: "green" },
16 zsh: { glyph: "", color: "green" },
17 py: { glyph: "", color: "yellow" }, // nf-seti-python
18 toml: { glyph: "", color: "gray" }, // nf-seti-toml
19 yml: { glyph: "", color: "magenta" }, // nf-seti-yml
20 yaml: { glyph: "", color: "magenta" },
21 html: { glyph: "", color: "red" }, // nf-dev-html5
22 css: { glyph: "", color: "blue" }, // nf-dev-css3
23 rs: { glyph: "", color: "red" }, // nf-dev-rust
24 go: { glyph: "", color: "cyan" }, // nf-seti-go
25 vim: { glyph: "", color: "green" }, // nf-seti-vim
26 gitignore: { glyph: "", color: "red" }, // nf-dev-git
27 lock: { glyph: "", color: "gray" }, // nf-fa-lock
28};
29
30// by extension, or by the whole name for dotfiles such as .gitignore
31export const fileIcon = (fileName: string) => {
32 const extension = fileName.includes(".")
33 ? fileName.slice(fileName.lastIndexOf(".") + 1).toLowerCase()
34 : "";
35 return FILE_ICONS[extension] ?? DEFAULT_FILE_ICON;
36};
37
38// a model family by a Nerd Font glyph: the magnum opus' crown, a sonnet's quill, a haiku's leaf, a fable's open book
39const MODEL_ICONS: { family: string; glyph: string; color: Role }[] = [
40 { family: "opus", glyph: "", color: "magenta" }, // nf-md-crown
41 { family: "sonnet", glyph: "", color: "blue" }, // nf-md-feather
42 { family: "haiku", glyph: "", color: "green" }, // nf-md-leaf
43 { family: "fable", glyph: "", color: "yellow" }, // nf-md-book_open_variant
44];
45const UNKNOWN_MODEL_ICON = { glyph: "", color: "gray" } as const; // nf-fa-robot
46
47// from an id (claude-haiku-4-5-...) or an alias (haiku)
48export const modelIcon = (model: string | undefined) =>
49 MODEL_ICONS.find((icon) => model?.toLowerCase().includes(icon.family)) ??
50 UNKNOWN_MODEL_ICON;
51
52// end-of-line mark of an edited file: yellow, green when new
53export const EDITED_MARK = "●";
54hooks/lib/contextGrid.ts 46 lines1import type { ContextSlice } from "../../types";
2
3const FREE: ContextSlice = { name: "Free space", tokens: 0, kind: "free" };
4
5// lays the window out over cellCount cells, one slice per cell: what is used first in /context's order,
6// then the free space, the compaction buffer last; a used slice too small for a cell still gets one
7export function contextCells(
8 slices: readonly ContextSlice[],
9 maxTokens: number,
10 cellCount: number,
11): ContextSlice[] {
12 const tokensPerCell = maxTokens / cellCount;
13 const cells: ContextSlice[] = [];
14 let usedTokens = 0;
15 for (const slice of slices) {
16 if (slice.kind !== "used" || slice.tokens <= 0) continue;
17 usedTokens += slice.tokens;
18 const lastCell = Math.min(
19 cellCount,
20 Math.max(Math.round(usedTokens / tokensPerCell), cells.length + 1),
21 );
22 while (cells.length < lastCell) cells.push(slice);
23 }
24 const buffer = slices.find((slice) => slice.kind === "buffer");
25 const bufferCount = buffer
26 ? Math.min(cellCount - cells.length, Math.round(buffer.tokens / tokensPerCell))
27 : 0;
28 const free = slices.find((slice) => slice.kind === "free") ?? FREE;
29 while (cells.length < cellCount - bufferCount) cells.push(free);
30 while (buffer && cells.length < cellCount) cells.push(buffer);
31
32 return cells;
33}
34
35// consecutive cells of one slice, so a row draws as a few colored runs
36export function sliceRuns(cells: readonly ContextSlice[]) {
37 const runs: { slice: ContextSlice; length: number }[] = [];
38 for (const cell of cells) {
39 const lastRun = runs[runs.length - 1];
40 if (lastRun?.slice === cell) lastRun.length++;
41 else runs.push({ slice: cell, length: 1 });
42 }
43
44 return runs;
45}
46