SLOPSHOPPER

session

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

newpaneguardcommandpromptprocess
v0.1.0no licenseupdated 2026-10-09djavrell/dotfile/claude/mods/session
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session
│ ┃ session ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │  Claude 💰 $0.4200 │ ⏺ Read(src/auth.ts) │ ┃ │ ⏱ 36m42s │ ⎿ Read 6 lines │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Skills ⏺ Bash(bun test) │ ┃ None. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Files ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ None. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Context │ ┃ 49% 📊 97k/200k › /session │ ┃ ⎿ session: Session pane closed. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · session
╭──────────────────────────────────────────────────────────╮ │  Claude 💰 $0.4200 │ │ ⏱ 36m42s │ ╰──────────────────────────────────────────────────────────╯ Skills None. Files None. Context 49% 📊 97k/200k
README

session

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.

Example

╭────────────────────────────────────────────────╮
│ * 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 cells
Source 15 files
hooks/register.tsx 277 lines
1// 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};
277
hooks/lib/agentTree.ts 58 lines
1import 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}
58
hooks/lib/git.ts 37 lines
1import 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 }));
37
hooks/lib/theme.ts 21 lines
1import 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";
21
hooks/sections/agents.tsx 69 lines
1import 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}
69
hooks/sections/context.tsx 112 lines
1import 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}
112
hooks/sections/files.tsx 71 lines
1import 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}
71
hooks/sections/skills.tsx 21 lines
1import 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}
21
hooks/sections/mainAgent.tsx 119 lines
1import 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}
119
hooks/components/meter.tsx 54 lines
1import 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}
54
hooks/lib/icons.ts 54 lines
1import 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 = "●";
54
hooks/lib/contextGrid.ts 46 lines
1import 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