Claude Code mod (plugin) that adds a side pane with Activity, Files, Agents, Context, MCP, Skills and Spend tabs, a status line above the prompt, a restyled…


paneline is a Claude Code mod (plugin) that adds a side pane with Activity, Files, Agents, Context, MCP, Skills and Spend tabs, a status line above the prompt, a restyled chat, terminal Mermaid diagrams, tables, code panels and diff panels. Colours follow your session /color and /theme.
/plugin marketplace add markneonin/paneline
/plugin install paneline@paneline
Needs Claude Code 2.1.289+ and a terminal at least 110 columns wide.
Third-party marketplaces do not update by themselves. Turn on auto-update in /plugin > Marketplaces to get new versions.














paneline works only on your machine. It sends nothing over the network and asks for no keys or tokens.
git (rev-parse, diff --numstat, log -g) in the folders of the session and its subagents, for the branch names and the line counts in the Files tab and on agent cards; grep on the session transcript, to find the colour set with /color.~/.claude.json and the project .mcp.json for the MCP tab; SKILL.md and command files of your skills and ~/.claude/plugins/installed_plugins.json for the Skills tab; the session transcript, only through the grep above./mcp enable <server> or /mcp disable <server>, only when you press that button in the MCP tab./color, /theme and /config changes. It passes every event on unchanged and never answers a permission prompt./session opens the pane again after you close it.hooks/vendor/mermaid-text.js is a build of beautiful-mermaid (MIT) that draws Mermaid diagrams as text.CLAUDE_CODE_NO_FLICKER=0) it opens inline above the prompt at any width./session to open it again after you close it.To run from a clone instead, pass the folder with --plugin-dir for one session:
git clone https://github.com/markneonin/paneline
claude --plugin-dir /path/to/paneline
To load it every time, put these in your shell profile instead:
export CLAUDE_CODE_PLUGIN_DIRS=/path/to/paneline
The plugin id is paneline. It has one option, probe, which writes render times to the debug log. It is off by default.
npm install
npm run check
Scripts:
npm run lint: ESLint with the strict type-checked typescript-eslint rules.npm run format and npm run format:check: Prettier, write or verify.npm run typecheck: tsc -p ..npm test: claude plugin test ..npm run check: all four.claude plugin validate . checks the manifest.
Notes:
hooks/. Tests are in tests/.typecheck and lint need the plugin type definitions in .claude-plugin/types/. Claude Code generates them on your machine and git ignores them, so a clean checkout or CI cannot run these two checks.claude plugin test has no coverage option, so there is no coverage report.MIT (c) 2026 Mark. The Mermaid renderer in hooks/vendor/ is third-party code, see THIRD_PARTY_NOTICES.md.
hooks/register.ts 33 lines1import type { Register } from "claude-code";
2
3import { trackActivity } from "./activity-track";
4import { trackAgents } from "./agents-track";
5import { renderChat } from "./chat-render";
6import { trackGit } from "./git-track";
7import { trackMcpUses } from "./mcp-track";
8import { registerProbe } from "./probe";
9import { trackPromptInfo } from "./prompt-track";
10import { registerSessionCommand, registerSessionPane } from "./session-pane";
11import { trackSkills } from "./skills-track";
12import { trackSpend } from "./spend-track";
13import { registerTabs } from "./tabs";
14import { trackTheme } from "./theme-track";
15import { trackUsage } from "./usage-track";
16
17export const register: Register = (on, options) => {
18 if (options.probe === true) registerProbe(on);
19 registerSessionPane(on);
20 trackAgents(on);
21 trackGit(on);
22 trackPromptInfo(on);
23 trackActivity(on);
24 trackUsage(on);
25 trackTheme(on);
26 trackSkills(on);
27 trackMcpUses(on);
28 trackSpend(on);
29 registerTabs(on);
30 registerSessionCommand(on);
31 renderChat(on);
32};
33hooks/activity-track.ts 190 lines1import { atom, memberOf, update } from "claude-code";
2import type {
3 EngineInterface,
4 On,
5 ToolCallInput,
6 ToolCallResult,
7 TurnCompleteInput,
8} from "claude-code";
9
10import type { Activity, AgentEdit, CallRecord, RunningCall, TurnStats } from "../types";
11import { callRecord } from "./activity-calls";
12import {
13 afterMainTurn,
14 emptySpend,
15 rentRowOf,
16 SPEND_SESSIONS_KEY,
17 withRent,
18 withSession,
19} from "./spend-model";
20import { isEditTool, targetOf } from "./tools";
21
22const KEPT_CALLS = 50;
23const KEPT_ACTIVITY = 400;
24const KEPT_AGENT_EDITS = 400;
25export const UNMEASURED_MS = -1;
26
27const activityAtom = atom({ plugin: "paneline", key: "activity" } as const, []);
28const agentEditsAtom = atom({ plugin: "paneline", key: "agentEdits" } as const, [] as AgentEdit[]);
29const totalMsAtom = atom({ plugin: "paneline", key: "totalMs" } as const, 0);
30const callsAtom = atom({ plugin: "paneline", key: "calls" } as const, [] as CallRecord[]);
31const runningAtom = atom({ plugin: "paneline", key: "running" } as const, [] as RunningCall[]);
32const toolMsAtom = atom({ plugin: "paneline", key: "toolMs" } as const, UNMEASURED_MS);
33const turnStatsAtom = atom({ plugin: "paneline", key: "turnStats" } as const, null);
34
35const spendAtom = atom({ plugin: "paneline", key: "spend" } as const, emptySpend());
36
37let turnActivity: Activity[] = [];
38
39export function trackActivity(on: On): void {
40 on("turn.start", ($, e, next) => {
41 turnActivity = [];
42 return next(e);
43 });
44
45 on("tool.call", async ($, e, next) => {
46 const startedAt = await $.clock.now();
47 const ran = await (e.agentId === undefined ? runningMainCall($, e, next) : next(e));
48 const entry = activityOf(e, ran, (await $.clock.now()) - startedAt);
49 const recorded = update($, callsAtom, (list) =>
50 [...list, callRecord(e, entry, ran, e.agentId)].slice(-KEPT_CALLS),
51 );
52 await Promise.all([
53 recorded,
54 e.agentId === undefined
55 ? Promise.all([recordMainCall($, e.tool_use_id, entry), recordRent($, e, ran)])
56 : recordAgentCall($, e.agentId, entry),
57 ]);
58 return ran;
59 });
60
61 on("turn.complete", async ($, e, next) => {
62 if (e.agentId !== undefined) return next(e);
63 const stats = turnStats(turnActivity);
64 await update($, memberOf(turnStatsAtom, { requestId: String(e.durationMs) }), () => stats);
65 await update($, totalMsAtom, (total) => total + e.durationMs);
66 await recordMainSpend($, e);
67 return next(e);
68 });
69}
70
71async function recordRent(
72 $: EngineInterface,
73 e: ToolCallInput,
74 ran: ToolCallResult,
75): Promise<void> {
76 if (ran.text === undefined) return;
77 const row = rentRowOf(
78 { tool: e.tool, target: targetOf(e), cwd: await $.session.cwd() },
79 ran.text,
80 );
81 await update($, spendAtom, (state) => withRent(state, row));
82}
83
84async function recordMainSpend($: EngineInterface, e: TurnCompleteInput): Promise<void> {
85 if (e.usage === undefined) return;
86 const [endedAt, usage] = await Promise.all([$.clock.now(), $.session.usage()]);
87 const turn = {
88 usage: e.usage,
89 context: usage.context.tokens,
90 startedAt: endedAt - e.durationMs,
91 endedAt,
92 fallbackTtl: usage.rateLimits.length > 0 ? "1h" : "5m",
93 } as const;
94 const state = await update($, spendAtom, (current) => afterMainTurn(current, turn));
95 await $.store.set(
96 SPEND_SESSIONS_KEY,
97 withSession(await $.store.get(SPEND_SESSIONS_KEY), await $.session.id(), state),
98 );
99}
100
101function runningMainCall(
102 $: EngineInterface,
103 e: ToolCallInput,
104 next: (e: ToolCallInput) => Promise<ToolCallResult>,
105): Promise<ToolCallResult> {
106 return update($, runningAtom, (list) => [
107 ...list,
108 { callId: e.tool_use_id, tool: e.tool, target: targetOf(e) },
109 ]).then(() =>
110 next(e).finally(() =>
111 update($, runningAtom, (list) => list.filter((one) => one.callId !== e.tool_use_id)),
112 ),
113 );
114}
115
116async function recordMainCall($: EngineInterface, callId: string, entry: Activity): Promise<void> {
117 turnActivity = [...turnActivity, entry];
118 await Promise.all([
119 update($, memberOf(toolMsAtom, { requestId: callId }), () => entry.ms),
120 update($, activityAtom, (list) => [...list, entry].slice(-KEPT_ACTIVITY)),
121 ]);
122}
123
124async function recordAgentCall(
125 $: EngineInterface,
126 agentId: string,
127 entry: Activity,
128): Promise<void> {
129 if (!isEditTool(entry.tool) || entry.isErrored) return;
130 await update($, agentEditsAtom, (list) =>
131 [...list, { ...entry, agentId }].slice(-KEPT_AGENT_EDITS),
132 );
133}
134
135function activityOf(
136 call: { tool: string; tool_use_id: string },
137 ran: ToolCallResult,
138 ms: number,
139): Activity {
140 const patched = diffstat(ran.result);
141 const diff =
142 patched.added + patched.removed > 0
143 ? patched
144 : (createdFileStat(call.tool, Reflect.get(call, "content")) ?? patched);
145 return {
146 id: call.tool_use_id,
147 tool: call.tool,
148 target: targetOf(call),
149 ms,
150 isErrored: ran.deny !== undefined || ran.isError === true,
151 added: diff.added,
152 removed: diff.removed,
153 };
154}
155
156function createdFileStat(
157 tool: string,
158 content: unknown,
159): { added: number; removed: number } | null {
160 if (tool !== "Write" || typeof content !== "string" || content === "") return null;
161 return { added: content.replace(/\n$/, "").split("\n").length, removed: 0 };
162}
163
164export function diffstat(result: unknown): { added: number; removed: number } {
165 const patch = (result as { structuredPatch?: unknown } | null)?.structuredPatch;
166 if (!Array.isArray(patch)) return { added: 0, removed: 0 };
167 const lines = patch.flatMap((hunk) => {
168 const hunkLines = (hunk as { lines?: unknown } | null)?.lines;
169 return Array.isArray(hunkLines)
170 ? hunkLines.filter((line): line is string => typeof line === "string")
171 : [];
172 });
173 return {
174 added: lines.filter((line) => line.startsWith("+")).length,
175 removed: lines.filter((line) => line.startsWith("-")).length,
176 };
177}
178
179function turnStats(activity: Activity[]): TurnStats {
180 return {
181 reads: activity.filter((entry) => entry.tool === "Read").length,
182 commands: activity.filter((entry) => entry.tool === "Bash").length,
183 changedFiles: new Set(
184 activity
185 .filter((entry) => isEditTool(entry.tool) && !entry.isErrored)
186 .map((entry) => entry.target),
187 ).size,
188 };
189}
190hooks/agents-track.ts 263 lines1import { gitText } from "./git-run";
2import { atom, read, update } from "claude-code";
3import type { EngineInterface, On, Timer } from "claude-code";
4
5import type { AgentTree } from "../types";
6import { AGENTS_TAB } from "./agents-tab";
7import {
8 completed,
9 located,
10 pruned,
11 reconciled,
12 spawned,
13 stepped,
14 toolEnded,
15 toolStarted,
16} from "./agents-model";
17import type { Place } from "./agents-place";
18import { directoryOf } from "./agents-place";
19import { PANE } from "./pane-tab";
20import {
21 afterAgentRequest,
22 BACKGROUND_OWNER,
23 emptySpend,
24 hasBaseline,
25 SPEND_SESSIONS_KEY,
26 withBaseline,
27 withSession,
28} from "./spend-model";
29import { forgetShownTab, isShownTab, justEntered } from "./shown-tab";
30import { folderName, isInside } from "./paths";
31import { targetOf } from "./tools";
32
33const FLUSH_DELAY_MS = 300;
34const POLL_MS = 1000;
35const PRESENT_AGENT_ID = /^(?!undefined$)./;
36
37const agentsAtom = atom({ plugin: "paneline", key: "agents" } as const, {} as AgentTree);
38const sessionUsdAtom = atom(
39 { plugin: "paneline", key: "sessionUsd" } as const,
40 null as number | null,
41);
42
43const spendAtom = atom({ plugin: "paneline", key: "spend" } as const, emptySpend());
44
45let tree: AgentTree = {};
46let seeding: Promise<void> | undefined;
47let pendingFlush: Timer | undefined;
48let poller: Timer | undefined;
49let lastUsd: number | null | undefined;
50const places = new Map<string, Place | null>();
51const rootOfAgent = new Map<string, string>();
52
53export function trackAgents(on: On): void {
54 on("agent.spawn", async ($, e, next) => {
55 const spawn = await next(e);
56 if (spawn.agentId === undefined) return spawn;
57 const agentId = spawn.agentId;
58 const at = await $.clock.now();
59 await record($, (current) =>
60 spawned(current, {
61 id: agentId,
62 parentId: e.parentAgentId,
63 type: e.subagentType,
64 description: e.description,
65 name: e.name,
66 background: e.background,
67 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
68 model: spawn.model ?? e.model ?? "",
69 at,
70 }),
71 );
72 if (isShownTab(AGENTS_TAB.id)) startPolling($);
73 if (e.cwd !== undefined) await locate($, agentId, e.cwd);
74 return spawn;
75 });
76
77 on("tool.call", { agentId: PRESENT_AGENT_ID }, async ($, e, next) => {
78 const agentId = e.agentId;
79 if (agentId === undefined) return next(e);
80 const started = { callId: e.tool_use_id, tool: e.tool, target: targetOf(e) };
81 await record($, (current) => toolStarted(current, agentId, started));
82 try {
83 return await next(e);
84 } finally {
85 await record($, (current) => toolEnded(current, agentId, e.tool_use_id));
86 const dir = rootOfAgent.has(agentId) ? undefined : directoryOf(e);
87 if (dir !== undefined) await locate($, agentId, dir);
88 }
89 });
90
91 on("turn.step", { agentId: PRESENT_AGENT_ID }, async function* ($, e, next) {
92 const startedAt = await $.clock.now();
93 const step = yield* next(e);
94 const agentId = e.agentId;
95 if (agentId === undefined) return step;
96 await record($, (current) =>
97 stepped(current, agentId, {
98 model: e.model,
99 effort: e.effort?.toString(),
100 usage: step.usage,
101 }),
102 );
103 if (step.usage !== null) {
104 const request = {
105 agentId,
106 index: e.index,
107 usage: step.usage,
108 startedAt,
109 endedAt: await $.clock.now(),
110 };
111 const owner = tree[agentId]?.type ?? BACKGROUND_OWNER;
112 const state = await update($, spendAtom, (current) =>
113 afterAgentRequest(current, owner, request),
114 );
115 await $.store.set(
116 SPEND_SESSIONS_KEY,
117 withSession(await $.store.get(SPEND_SESSIONS_KEY), await $.session.id(), state),
118 );
119 }
120 return step;
121 });
122
123 on("turn.complete", { agentId: PRESENT_AGENT_ID }, async ($, e, next) => {
124 const agentId = e.agentId;
125 if (agentId === undefined) return next(e);
126 const at = await $.clock.now();
127 await record($, (current) => completed(current, agentId, e.reason, at));
128 await relocate($, agentId);
129 return next(e);
130 });
131
132 on("ui.render", { component: "Pane", requestId: PANE }, ($, e, next) => {
133 if (justEntered(AGENTS_TAB.id)) void watchAgents($);
134 return next(e);
135 });
136
137 on("ui.close", { id: PANE }, async ($, e, next) => {
138 forgetShownTab();
139 stopPolling();
140 return next(e);
141 });
142
143 on("session.measure", { changed: ["cost"] }, async ($, e, next) => {
144 const usd = e.cost?.usd ?? null;
145 if (usd !== lastUsd) {
146 lastUsd = usd;
147 await update($, sessionUsdAtom, () => usd);
148 if (usd !== null) await settleBaseline($, usd);
149 }
150 return next(e);
151 });
152
153 on("classic.SessionStart", { source: "clear" }, async ($, e, next) => {
154 stopPolling();
155 places.clear();
156 rootOfAgent.clear();
157 await record($, () => ({}));
158 return next(e);
159 });
160}
161
162async function locate($: EngineInterface, agentId: string, dir: string): Promise<void> {
163 if (!places.has(dir)) places.set(dir, await readPlace($, dir));
164 const place = places.get(dir);
165 if (!place) return;
166 rootOfAgent.set(agentId, place.root);
167 await showPlace($, agentId, place);
168}
169
170async function relocate($: EngineInterface, agentId: string): Promise<void> {
171 const root = rootOfAgent.get(agentId);
172 if (root === undefined) return;
173 const place = await readPlace($, root);
174 if (!place) return;
175 places.set(root, place);
176 await showPlace($, agentId, place);
177}
178
179async function showPlace(
180 $: EngineInterface,
181 agentId: string,
182 { root, branch }: Place,
183): Promise<void> {
184 const isMainCheckout = isInside(await $.session.cwd(), root);
185 await record($, (current) =>
186 located(current, agentId, { branch, ...(!isMainCheckout && { worktree: folderName(root) }) }),
187 );
188}
189
190async function readPlace($: EngineInterface, dir: string): Promise<Place | null> {
191 const out = await gitText(
192 {
193 run: (argv) => $.process.run(argv),
194 report: (error) => $.ui.log(`git failed: ${String(error)}`, { to: "debug" }),
195 },
196 dir,
197 ["rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD"],
198 );
199 const [root, branch] = out?.split("\n") ?? [];
200 return root && branch ? { root, branch } : null;
201}
202
203async function record(
204 $: EngineInterface,
205 change: (current: AgentTree) => AgentTree,
206): Promise<void> {
207 await seeded($);
208 const changed = change(tree);
209 if (changed === tree) return;
210 tree = changed;
211 scheduleFlush($);
212}
213
214function seeded($: EngineInterface): Promise<void> {
215 seeding ??= read($, agentsAtom).then((saved) => {
216 tree = saved;
217 });
218 return seeding;
219}
220
221async function watchAgents($: EngineInterface): Promise<void> {
222 await pollOnce($);
223 startPolling($);
224}
225
226function startPolling($: EngineInterface): void {
227 if (hasRunningAgent()) poller ??= $.clock.every(POLL_MS, () => void pollOnce($));
228}
229
230function stopPolling(): void {
231 poller?.cancel();
232 poller = undefined;
233}
234
235function hasRunningAgent(): boolean {
236 return Object.values(tree).some((node) => node.status === "running");
237}
238
239async function pollOnce($: EngineInterface): Promise<void> {
240 const [listed, at] = await Promise.all([$.agent.list(), $.clock.now()]);
241 await record($, (current) => reconciled(current, listed, at));
242 $.ui.invalidate("ui.render");
243 if (!hasRunningAgent() || !isShownTab(AGENTS_TAB.id)) stopPolling();
244}
245
246function scheduleFlush($: EngineInterface): void {
247 if (pendingFlush) return;
248 pendingFlush = $.clock.after(FLUSH_DELAY_MS, () => {
249 pendingFlush = undefined;
250 tree = pruned(tree);
251 void update($, agentsAtom, () => tree);
252 });
253}
254
255async function settleBaseline($: EngineInterface, usd: number): Promise<void> {
256 if (hasBaseline(await read($, spendAtom))) return;
257 const state = await update($, spendAtom, (current) => withBaseline(current, usd));
258 await $.store.set(
259 SPEND_SESSIONS_KEY,
260 withSession(await $.store.get(SPEND_SESSIONS_KEY), await $.session.id(), state),
261 );
262}
263hooks/chat-render.tsx 260 lines1import { atom, memberOf, read, update } from "claude-code";
2import type {
3 EngineInterface,
4 On,
5 RenderElement,
6 ResolveInput,
7 UiPressArgument,
8} from "claude-code";
9
10import type { UsageSnap, PromptInfo } from "../types";
11import { UNMEASURED_MS } from "./activity-track";
12import { footerRow, layoutOf, toolGroupRow, userBlock } from "./chat-draw";
13import type { ToolGroupView } from "./chat-draw";
14import {
15 bashChangesOf,
16 bashNotes,
17 diffPanel,
18 fileChangeOf,
19 withoutBashChanges,
20} from "./diff-panel";
21import type { BashChanges } from "./diff-panel";
22import { replyView } from "./reply-draw";
23import type { Accent } from "./pane-kit";
24import { fillsFor } from "./palette";
25import type { Fills } from "./palette";
26import { metersOf, promptInfoRow } from "./prompt-info";
27import { PANE } from "./pane-tab";
28import { accentOf } from "./session-color";
29import { TABS } from "./tabs";
30
31const DIFF_TOOLS = new Set(["Edit", "Write"]);
32const TYPED = new Set(["composer", "bridge", "sdk"]);
33const MAX_PROMPT = 4000;
34const MAX_REPLY_CHARS = 60_000;
35const BAND_ROWS_WITH_SPACER = 2;
36type Copy = (text: string, press: UiPressArgument) => void;
37
38const usageAtom = atom(
39 { plugin: "paneline", key: "usage" } as const,
40 { context: null, limits: [] } as UsageSnap,
41);
42const promptInfoAtom = atom(
43 { plugin: "paneline", key: "promptInfo" } as const,
44 { model: "", effort: null, cwd: "" } as PromptInfo,
45);
46const sessionColorAtom = atom({ plugin: "paneline", key: "sessionColor" } as const, "default");
47const themeAtom = atom({ plugin: "paneline", key: "theme" } as const, "dark");
48const toolMsAtom = atom({ plugin: "paneline", key: "toolMs" } as const, UNMEASURED_MS);
49const turnStatsAtom = atom({ plugin: "paneline", key: "turnStats" } as const, null);
50const tabAtom = atom({ plugin: "paneline", key: "tab" } as const, "activity");
51
52type RenderEvent = { surface: string; viewport?: { columns: number } };
53
54async function elapsedOf($: EngineInterface, ids: (string | undefined)[]): Promise<number | null> {
55 const known = ids.filter((id): id is string => id !== undefined && id !== "");
56 const measured = (
57 await Promise.all(known.map((id) => read($, memberOf(toolMsAtom, { requestId: id }))))
58 ).filter((ms) => ms !== UNMEASURED_MS);
59 return measured.length === 0 ? null : measured.reduce((sum, ms) => sum + ms, 0);
60}
61
62async function currentAccent($: EngineInterface): Promise<Accent> {
63 return accentOf(await read($, sessionColorAtom));
64}
65
66async function currentFills($: EngineInterface): Promise<Fills> {
67 return fillsFor(await read($, themeAtom));
68}
69
70async function openSessionPane($: EngineInterface): Promise<void> {
71 await update($, tabAtom, () => TABS[0].id);
72 await $.ui.open({ id: PANE, title: "Session", closeOnEscape: true });
73}
74
75async function toolRow(
76 $: EngineInterface,
77 e: ResolveInput & RenderEvent,
78 ids: (string | undefined)[],
79 view: Omit<ToolGroupView, "accent" | "fills">,
80) {
81 const [elapsedMs, accent, fills] = await Promise.all([
82 elapsedOf($, ids),
83 currentAccent($),
84 currentFills($),
85 ]);
86 return toolGroupRow(
87 $.ui.resolve(e),
88 { ...view, elapsedMs, accent, fills },
89 layoutOf(columnsOf(e)),
90 () => void openSessionPane($),
91 );
92}
93
94type FinishedCall = { tool_use_id: string; tool: string; output?: unknown; isErrored: boolean };
95type CallEvent = ResolveInput & RenderEvent & { props: FinishedCall };
96type Draw<E> = (e: E) => RenderElement | Promise<RenderElement>;
97
98async function drawWithChanges<E extends CallEvent>(
99 $: EngineInterface,
100 e: E,
101 next: Draw<E>,
102): Promise<RenderElement> {
103 const call = e.props;
104 const changes = call.isErrored ? null : changesOf(call);
105 if (changes === null) return next(e);
106 const [{ cwd }, home, accent, fills] = await Promise.all([
107 read($, promptInfoAtom),
108 $.env.get("HOME"),
109 currentAccent($),
110 currentFills($),
111 ]);
112 const ui = $.ui.resolve(e);
113 const windows = changes.changes.map((change) =>
114 diffPanel(ui, {
115 toolUseId: call.tool_use_id,
116 change,
117 cwd,
118 home,
119 width: layoutOf(columnsOf(e)).width,
120 accent,
121 fills,
122 }),
123 );
124 if (call.tool !== "Bash") return windows[0] ?? next(e);
125 const rest = await next({
126 ...e,
127 props: { ...call, output: withoutBashChanges(call.output) },
128 });
129 const { Box } = ui;
130 return (
131 <Box flexDirection="column">
132 {rest}
133 {windows}
134 {bashNotes(ui, changes)}
135 </Box>
136 );
137}
138
139function changesOf({ tool, output }: FinishedCall): BashChanges | null {
140 if (tool === "Bash") return bashChangesOf(output);
141 const change = DIFF_TOOLS.has(tool) ? fileChangeOf(output) : null;
142 return change === null ? null : { changes: [change], moreFiles: 0, isShared: false };
143}
144
145const columnsOf = (e: RenderEvent): number => e.viewport?.columns ?? 100;
146
147const RENDER_HINT_SECTION = {
148 id: "paneline:render-hint",
149 scope: "session",
150 text: [
151 "Replies in this session are drawn as rich terminal graphics.",
152 "Use pipe tables with alignment for comparisons.",
153 "Use GitHub alerts (> [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]) for things that must not be missed.",
154 'Use fenced code with a language, and title="path" when the code belongs to a file.',
155 "Commands the user should run go in fenced bash blocks.",
156 "When a flow, sequence, state, schema or numeric series is easier to see than read, add one small ```mermaid block: flowchart, sequenceDiagram, stateDiagram, classDiagram, erDiagram, or xychart-beta with bar or line. Keep labels short.",
157 "Skip the graphics for simple answers.",
158 ].join(" "),
159} as const;
160
161export function renderChat(on: On): void {
162 on("prompt.compose", async ($, e, next) => {
163 const composed = await next(e);
164 return { sections: [...composed.sections, RENDER_HINT_SECTION] };
165 });
166
167 const expandedCalls = new Set<string>();
168 let copyWithLatest: Copy = () => {};
169
170 const copy: Copy = (text, press) => copyWithLatest(text, press);
171
172 on("ui.render", { component: "UserMessage" }, async ($, e, next) => {
173 if (e.props.isExpanded || !TYPED.has(e.props.origin.kind) || e.props.text.length > MAX_PROMPT)
174 return next(e);
175 return userBlock($.ui.resolve(e), e.props.text, layoutOf(columnsOf(e)), await currentFills($));
176 });
177
178 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
179 if (e.props.text.length > MAX_REPLY_CHARS) return next(e);
180 copyWithLatest = (text, press) => {
181 void $.ui
182 .copy({ text, surface: press.surface })
183 .then((result) => result.isCopied)
184 .catch(() => false)
185 .then((isCopied) => $.ui.toast(isCopied ? "Copied" : "Could not copy here"));
186 };
187 const { width } = layoutOf(columnsOf(e));
188 const [accent, fills] = await Promise.all([currentAccent($), currentFills($)]);
189 const request = {
190 text: e.props.text,
191 width,
192 isFirstOfReply: e.props.isFirstOfReply,
193 surface: e.surface,
194 accent,
195 fills,
196 };
197 return replyView($.ui.resolve(e), request, copy) ?? next(e);
198 });
199
200 on("ui.render", { component: "ToolGroup" }, ($, e, next) => {
201 if (e.props.isExpanded) {
202 e.props.calls.forEach((call) => call.tool_use_id && expandedCalls.add(call.tool_use_id));
203 return next(e);
204 }
205 return toolRow(
206 $,
207 e,
208 e.props.calls.map((call) => call.tool_use_id),
209 { calls: e.props.calls, isActive: e.props.isActive, elapsedMs: null },
210 );
211 });
212
213 on("ui.render", { component: "ToolUse" }, ($, e, next) => {
214 if (expandedCalls.has(e.props.tool_use_id)) return drawWithChanges($, e, next);
215 return toolRow($, e, [e.props.tool_use_id], {
216 calls: [e.props],
217 isActive: e.props.isRunning,
218 elapsedMs: null,
219 });
220 });
221
222 on("ui.render", { component: "ToolResult" }, ($, e, next) => drawWithChanges($, e, next));
223
224 on("ui.render", { component: "TurnDuration" }, async ($, e) => {
225 const [stats, fills] = await Promise.all([
226 read($, memberOf(turnStatsAtom, { requestId: String(e.props.durationMs) })),
227 currentFills($),
228 ]);
229 return footerRow(
230 $.ui.resolve(e),
231 e.props.durationMs,
232 stats ?? undefined,
233 layoutOf(columnsOf(e)),
234 fills,
235 );
236 });
237
238 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
239 if (e.props.hasSurvey) return next(e);
240 const [theirs, usage, info, home, color, nowMs] = await Promise.all([
241 next(e),
242 read($, usageAtom),
243 read($, promptInfoAtom),
244 $.env.get("HOME"),
245 read($, sessionColorAtom),
246 $.clock.now(),
247 ]);
248 const meters = metersOf(usage, nowMs);
249 const ui = $.ui.resolve(e);
250 const { Box } = ui;
251 return (
252 <Box flexDirection="column">
253 {e.props.maxRows >= BAND_ROWS_WITH_SPACER ? <Box height={1} /> : null}
254 {theirs}
255 {promptInfoRow(ui, info, home, columnsOf(e), color, meters)}
256 </Box>
257 );
258 });
259}
260hooks/git-track.ts 197 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, On } from "claude-code";
3
4import type { Activity, AgentEdit, GitChange } from "../types";
5import { gitText } from "./git-run";
6
7const MOVING_COMMAND =
8 /\bgit\b[^|;&\n]*\b(merge|rebase|pull|cherry-pick|reset|checkout|switch|commit)\b/;
9const MERGE_SUBJECT = /^merge (\S+?):/;
10const GENERIC_LABEL = "merged";
11const GIT_DIR_FLAG = /\bgit\s+-C\s+(?:"([^"]+)"|'([^']+)'|(\S+))/g;
12const LEADING_CD = /^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s&;]+))\s*&&/;
13
14const changesAtom = atom({ plugin: "paneline", key: "gitChanges" } as const, [] as GitChange[]);
15
16const activityAtom = atom({ plugin: "paneline", key: "activity" } as const, [] as Activity[]);
17const agentEditsAtom = atom({ plugin: "paneline", key: "agentEdits" } as const, [] as AgentEdit[]);
18
19const branchesAtom = atom(
20 { plugin: "paneline", key: "gitBranches" } as const,
21 {} as Record<string, string>,
22);
23
24const rootOfDir = new Map<string, string | null>();
25
26type Checkout = { root: string; head: string };
27
28export function trackGit(on: On): void {
29 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
30 if (!MOVING_COMMAND.test(e.command)) return next(e);
31 const before = await checkoutsAround($, e.command);
32 const ran = await next(e);
33 await Promise.all(
34 before.map(async (checkout) => recordLanded($, checkout, await checkoutOf($, checkout.root))),
35 );
36 await refreshBranches(
37 $,
38 before.map((checkout) => checkout.root),
39 );
40 return ran;
41 });
42
43 on("classic.SessionStart", { source: "clear" }, async ($, e, next) => {
44 await reset($);
45 return next(e);
46 });
47}
48
49async function reset($: EngineInterface): Promise<void> {
50 rootOfDir.clear();
51 await update($, changesAtom, () => []);
52 await update($, branchesAtom, () => ({}));
53}
54
55async function checkoutsAround($: EngineInterface, command: string): Promise<Checkout[]> {
56 const cwd = await $.session.cwd();
57 const [known, edited] = await Promise.all([read($, branchesAtom), editedRoots($)]);
58 const dirs = [
59 cwd,
60 ...namedDirs(command).map((dir) => resolved(cwd, dir)),
61 ...Object.keys(known),
62 ...edited,
63 ];
64 const found = await Promise.all([...new Set(dirs)].map((dir) => checkoutOf($, dir)));
65 const byRoot = new Map(
66 found.flatMap((checkout) => (checkout === null ? [] : [[checkout.root, checkout] as const])),
67 );
68 return [...byRoot.values()];
69}
70
71function namedDirs(command: string): string[] {
72 const flagged = [...command.matchAll(GIT_DIR_FLAG)].map(
73 (match) => match[1] ?? match[2] ?? match[3],
74 );
75 const [, ...quoted] = LEADING_CD.exec(command) ?? [];
76 return [...flagged, ...quoted].filter((dir): dir is string => dir !== undefined);
77}
78
79function resolved(cwd: string, dir: string): string {
80 return dir.startsWith("/") ? dir : `${cwd}/${dir}`;
81}
82
83async function recordLanded(
84 $: EngineInterface,
85 before: Checkout | null,
86 after: Checkout | null,
87): Promise<void> {
88 if (before === null || after === null) return;
89 if (before.root !== after.root || before.head === after.head) return;
90 const landed = await changesBetween($, before, after.head);
91 await update($, changesAtom, (known) => mergedChanges(known, landed));
92}
93
94function mergedChanges(known: GitChange[], landed: GitChange[]): GitChange[] {
95 const landedTargets = new Set(landed.map((change) => change.target));
96 const earlier = new Map(known.map((change) => [change.target, change]));
97 return [
98 ...known.filter((change) => !landedTargets.has(change.target)),
99 ...landed.map((change) => {
100 const before = earlier.get(change.target);
101 return before === undefined
102 ? change
103 : {
104 ...change,
105 added: change.added + before.added,
106 removed: change.removed + before.removed,
107 };
108 }),
109 ];
110}
111
112async function refreshBranches($: EngineInterface, ranIn: string[]): Promise<void> {
113 const [known, edited] = await Promise.all([read($, branchesAtom), editedRoots($)]);
114 const roots = new Set([...Object.keys(known), ...edited, ...ranIn]);
115 const branches = await Promise.all(
116 [...roots].map(async (root) => [root, await branchOf($, root)] as const),
117 );
118 await update($, branchesAtom, () =>
119 Object.fromEntries(branches.filter(([, branch]) => branch !== null)),
120 );
121}
122
123async function branchOf($: EngineInterface, root: string): Promise<string | null> {
124 const name = await git($, root, ["rev-parse", "--abbrev-ref", "HEAD"]);
125 return name === "HEAD" ? git($, root, ["rev-parse", "--short", "HEAD"]) : name;
126}
127
128async function editedRoots($: EngineInterface): Promise<string[]> {
129 const [activity, agentEdits] = await Promise.all([
130 read($, activityAtom),
131 read($, agentEditsAtom),
132 ]);
133 const dirs = new Set(
134 [...activity, ...agentEdits]
135 .filter((entry) => entry.tool !== "Read" && entry.target.startsWith("/"))
136 .map((entry) => entry.target.slice(0, entry.target.lastIndexOf("/")) || "/"),
137 );
138 const roots = await Promise.all([...dirs].map((dir) => rootOf($, dir)));
139 return roots.filter((root): root is string => root !== null);
140}
141
142async function rootOf($: EngineInterface, dir: string): Promise<string | null> {
143 const known = rootOfDir.get(dir);
144 if (known !== undefined) return known;
145 const root = await git($, dir, ["rev-parse", "--show-toplevel"]);
146 rootOfDir.set(dir, root);
147 return root;
148}
149
150async function checkoutOf($: EngineInterface, dir: string): Promise<Checkout | null> {
151 const found = await git($, dir, ["rev-parse", "--show-toplevel", "HEAD"]);
152 const [root, head] = found?.split("\n") ?? [];
153 return root && head ? { root, head } : null;
154}
155
156async function changesBetween(
157 $: EngineInterface,
158 before: Checkout,
159 head: string,
160): Promise<GitChange[]> {
161 const { root } = before;
162 const [numstat, subjects] = await Promise.all([
163 git($, root, ["diff", "--numstat", "--no-renames", before.head, head]),
164 git($, root, ["log", "-g", "--format=%gs", `${before.head}..${head}`]),
165 ]);
166 const label = mergeLabel(subjects ?? "");
167 return (numstat ?? "")
168 .split("\n")
169 .map((line) => line.split("\t"))
170 .filter(([, , path]) => path !== undefined && path !== "")
171 .map(([added, removed, path]) => ({
172 target: `${root}/${path}`,
173 added: Number(added) || 0,
174 removed: Number(removed) || 0,
175 label,
176 }));
177}
178
179function mergeLabel(subjects: string): string {
180 const branches = new Set(
181 subjects.split("\n").flatMap((subject) => MERGE_SUBJECT.exec(subject)?.[1] ?? []),
182 );
183 const [only] = branches;
184 return branches.size === 1 && only !== undefined ? `merge ${only}` : GENERIC_LABEL;
185}
186
187function git($: EngineInterface, dir: string, args: string[]): Promise<string | null> {
188 return gitText(
189 {
190 run: (argv) => $.process.run(argv),
191 report: (error) => $.ui.log(`git failed: ${String(error)}`, { to: "debug" }),
192 },
193 dir,
194 args,
195 );
196}
197hooks/mcp-track.ts 38 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, On } from "claude-code";
3
4import { countsIn, withUse } from "./use-counts";
5import type { UseCounts } from "./use-counts";
6
7export const TOOL_USES_KEY = "toolUses";
8
9const MCP_TOOL = /^mcp__/;
10
11const launchFolderAtom = atom({ plugin: "paneline", key: "launchFolder" } as const, "");
12const toolUsesAtom = atom(
13 { plugin: "paneline", key: "toolUses" } as const,
14 {} as Record<string, number>,
15);
16
17export function trackMcpUses(on: On): void {
18 on("session.start", { isInteractive: true }, async ($, e, next) => {
19 const known = await read($, launchFolderAtom);
20 const counts = countsIn(await storedUses($), known === "" ? e.cwd : known);
21 await update($, toolUsesAtom, () => counts);
22 return next(e);
23 });
24
25 on("tool.call", { tool: MCP_TOOL }, async ($, e, next) => {
26 const folder = await read($, launchFolderAtom);
27 if (folder === "") return next(e);
28 const all = withUse(await storedUses($), folder, e.tool);
29 await $.store.set(TOOL_USES_KEY, all);
30 await update($, toolUsesAtom, () => countsIn(all, folder));
31 return next(e);
32 });
33}
34
35async function storedUses($: EngineInterface): Promise<UseCounts> {
36 return ((await $.store.get(TOOL_USES_KEY)) ?? {}) as UseCounts;
37}
38hooks/probe.ts 51 lines1import type { EngineInterface, Register } from "claude-code";
2
3const LOG_EVERY_CALLS = 200;
4
5type ComponentStats = { calls: number; totalMs: number; maxMs: number; requestIds: Set<string> };
6
7export function registerProbe(on: Parameters<Register>[0]): void {
8 const statsByComponent = new Map<string, ComponentStats>();
9 let totalCalls = 0;
10
11 on("ui.render", async ($, e, next) => {
12 const startedAt = performance.now();
13 const result = await next(e);
14 record(statsByComponent, e.component, e.requestId, performance.now() - startedAt);
15 totalCalls += 1;
16 if (totalCalls % LOG_EVERY_CALLS === 0) logStats($, statsByComponent, totalCalls);
17 return result;
18 });
19}
20
21function record(
22 statsByComponent: Map<string, ComponentStats>,
23 component: string,
24 requestId: string | undefined,
25 ms: number,
26): void {
27 const stats = statsByComponent.get(component) ?? {
28 calls: 0,
29 totalMs: 0,
30 maxMs: 0,
31 requestIds: new Set<string>(),
32 };
33 stats.calls += 1;
34 stats.totalMs += ms;
35 stats.maxMs = Math.max(stats.maxMs, ms);
36 if (requestId !== undefined) stats.requestIds.add(requestId);
37 statsByComponent.set(component, stats);
38}
39
40function logStats(
41 $: EngineInterface,
42 statsByComponent: Map<string, ComponentStats>,
43 totalCalls: number,
44): void {
45 const rows = [...statsByComponent].map(
46 ([component, stats]) =>
47 `${component} calls=${stats.calls} totalMs=${stats.totalMs.toFixed(1)} maxMs=${stats.maxMs.toFixed(1)} ids=${stats.requestIds.size}`,
48 );
49 $.ui.log(`paneline probe @${totalCalls}: ${rows.join(" | ")}`, { to: "debug" });
50}
51hooks/prompt-track.ts 117 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, On } from "claude-code";
3
4import type { PromptInfo } from "../types";
5import { emptySpend } from "./spend-model";
6
7const COLOR_ENTRY = '"type":"agent-color"';
8
9const promptInfoAtom = atom(
10 { plugin: "paneline", key: "promptInfo" } as const,
11 { model: "", effort: null, cwd: "" } as PromptInfo,
12);
13const spendAtom = atom({ plugin: "paneline", key: "spend" } as const, emptySpend());
14const sessionColorAtom = atom({ plugin: "paneline", key: "sessionColor" } as const, "default");
15const transcriptPathAtom = atom({ plugin: "paneline", key: "transcriptPath" } as const, "");
16
17export function trackPromptInfo(on: On): void {
18 on("session.start", { isInteractive: true }, async ($, e, next) => {
19 await seed($);
20 return next(e);
21 });
22
23 on(
24 "classic.SessionStart",
25 { source: ["startup", "resume", "clear", "compact", "fork"] },
26 async ($, e, next) => {
27 await seed($);
28 await refreshColor($, e.transcript_path);
29 return next(e);
30 },
31 );
32
33 on("classic.UserPromptSubmit", async ($, e, next) => {
34 await refreshColor($, e.transcript_path);
35 return next(e);
36 });
37
38 on("command.run", { command: "color" }, async ($, e, next) => {
39 const result = await next(e);
40 await refreshColor($, await read($, transcriptPathAtom));
41 return result;
42 });
43
44 on("classic.PostModelSwitch", async ($, e, next) => {
45 await change($, { model: e.to_model });
46 await update($, spendAtom, (state) => ({ ...state, mainCacheTtl: e.cache_ttl }));
47 return next(e);
48 });
49
50 on("classic.CwdChanged", async ($, e, next) => {
51 await change($, { cwd: e.new_cwd });
52 return next(e);
53 });
54
55 on("classic.Stop", async ($, e, next) => {
56 if (e.agent_id === undefined) {
57 await refreshColor($, e.transcript_path);
58 await change($, { effort: e.effort?.level ?? null });
59 }
60 return next(e);
61 });
62
63 on("classic.PostToolUse", async ($, e, next) => {
64 if (e.agent_id === undefined) await change($, { effort: e.effort?.level ?? null });
65 return next(e);
66 });
67}
68
69async function seed($: EngineInterface): Promise<void> {
70 await change($, { model: await $.session.model(), cwd: await $.session.cwd() });
71}
72
73async function change($: EngineInterface, patch: Partial<PromptInfo>): Promise<void> {
74 const current = await read($, promptInfoAtom);
75 const updated = { ...current, ...patch };
76 if (
77 updated.model === current.model &&
78 updated.effort === current.effort &&
79 updated.cwd === current.cwd
80 )
81 return;
82 await update($, promptInfoAtom, () => updated);
83}
84
85async function refreshColor($: EngineInterface, transcriptPath: string): Promise<void> {
86 await update($, transcriptPathAtom, () => transcriptPath);
87 const agentColor = await lastAgentColor($, transcriptPath);
88 if (agentColor === undefined) return;
89 await update($, sessionColorAtom, () => agentColor);
90}
91
92function colorOf(line: string): string | undefined {
93 try {
94 const { agentColor } = JSON.parse(line) as { agentColor?: unknown };
95 return typeof agentColor === "string" ? agentColor : undefined;
96 } catch {
97 return undefined;
98 }
99}
100
101async function lastAgentColor(
102 $: EngineInterface,
103 transcriptPath: string,
104): Promise<string | undefined> {
105 try {
106 const { stdout } = await $.process.run(["grep", "-a", COLOR_ENTRY, transcriptPath]);
107 return stdout
108 .trimEnd()
109 .split("\n")
110 .reverse()
111 .map(colorOf)
112 .find((color) => color !== undefined);
113 } catch {
114 return undefined;
115 }
116}
117hooks/session-pane.tsx 77 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, On } from "claude-code";
3
4import { failureText } from "./command-failure";
5import { PANE } from "./pane-tab";
6import { accentOf } from "./session-color";
7import { notePaneTheme, paneInk } from "./pane-ink";
8import { noteShownTab } from "./shown-tab";
9import { tabBar } from "./tab-bar";
10import { TABS } from "./tabs";
11
12const BODY_PADDING = 1;
13
14const sessionColorAtom = atom({ plugin: "paneline", key: "sessionColor" } as const, "default");
15const tabAtom = atom({ plugin: "paneline", key: "tab" } as const, "activity");
16const themeAtom = atom({ plugin: "paneline", key: "theme" } as const, "dark");
17
18export function registerSessionPane(on: On): void {
19 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e, next) => {
20 const [tab, theme, color] = await Promise.all([
21 read($, tabAtom),
22 read($, themeAtom),
23 read($, sessionColorAtom),
24 ]);
25 noteShownTab(tab);
26 notePaneTheme(theme);
27 const accent = accentOf(color);
28 const width = e.props.bodyColumns;
29 const body = await next({ ...e, props: { ...e.props, bodyColumns: width - BODY_PADDING } });
30 const ui = $.ui.resolve(e);
31 const { Box } = ui;
32 const { fill } = paneInk();
33 return (
34 <Box
35 flexDirection="column"
36 width={width}
37 minHeight={fill === undefined ? undefined : e.props.scroll.bodyRows}
38 backgroundColor={fill}
39 >
40 {tabBar(ui, TABS, tab, (id) => void update($, tabAtom, () => id), width, accent)}
41 <Box flexDirection="column" paddingLeft={BODY_PADDING} width={width}>
42 {body}
43 </Box>
44 </Box>
45 );
46 });
47}
48
49export function registerSessionCommand(on: On): void {
50 on("session.start", { isInteractive: true }, async ($, e, next) => {
51 await $.command.register({ name: "session", description: "Open the Session side pane" });
52 void openPane($);
53 return next(e);
54 });
55
56 on("classic.SessionStart", { source: ["clear", "resume", "fork"] }, async ($, e, next) => {
57 void openPane($);
58 return next(e);
59 });
60
61 on("command.run", { command: "session" }, async ($) => {
62 try {
63 await update($, tabAtom, () => TABS[0].id);
64 await openPane($);
65 return {};
66 } catch (error) {
67 const text = failureText(error);
68 $.ui.log(text, { to: "debug" });
69 return { text };
70 }
71 });
72}
73
74async function openPane($: EngineInterface): Promise<void> {
75 await $.ui.open({ id: PANE, title: "Session", closeOnEscape: true });
76}
77hooks/skills-track.ts 39 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, On } from "claude-code";
3
4import { countsIn, withUse } from "./use-counts";
5import type { UseCounts } from "./use-counts";
6
7export const SKILL_USES_KEY = "skillUses";
8
9const launchFolderAtom = atom({ plugin: "paneline", key: "launchFolder" } as const, "");
10const skillUsesAtom = atom(
11 { plugin: "paneline", key: "skillUses" } as const,
12 {} as Record<string, number>,
13);
14
15export function trackSkills(on: On): void {
16 on("session.start", { isInteractive: true }, async ($, e, next) => {
17 const known = await read($, launchFolderAtom);
18 const folder = known === "" ? e.cwd : known;
19 const counts = countsIn(await storedUses($), folder);
20 await update($, launchFolderAtom, () => folder);
21 await update($, skillUsesAtom, () => counts);
22 return next(e);
23 });
24
25 on("skill.prompt", async ($, e, next) => {
26 const prompt = await next(e);
27 const folder = await read($, launchFolderAtom);
28 if (folder === "") return prompt;
29 const all = withUse(await storedUses($), folder, e.skill);
30 await $.store.set(SKILL_USES_KEY, all);
31 await update($, skillUsesAtom, () => countsIn(all, folder));
32 return prompt;
33 });
34}
35
36async function storedUses($: EngineInterface): Promise<UseCounts> {
37 return ((await $.store.get(SKILL_USES_KEY)) ?? {}) as UseCounts;
38}
39hooks/spend-track.ts 34 lines1import { atom, update } from "claude-code";
2import type { On } from "claude-code";
3
4import {
5 afterCompaction,
6 emptySpend,
7 freshSpend,
8 restoredSpend,
9 SPEND_SESSIONS_KEY,
10 withSession,
11} from "./spend-model";
12
13const spendAtom = atom({ plugin: "paneline", key: "spend" } as const, emptySpend());
14
15export function trackSpend(on: On): void {
16 on("session.start", { isInteractive: true }, async ($, e, next) => {
17 const restored = restoredSpend(await $.store.get(SPEND_SESSIONS_KEY), await $.session.id());
18 if (restored !== undefined) await update($, spendAtom, () => restored);
19 return next(e);
20 });
21
22 on("classic.SessionStart", { source: ["startup", "clear", "compact"] }, async ($, e, next) => {
23 if (e.agent_id !== undefined) return next(e);
24 const state = await update($, spendAtom, (current) =>
25 e.source === "compact" ? afterCompaction(current) : freshSpend(),
26 );
27 await $.store.set(
28 SPEND_SESSIONS_KEY,
29 withSession(await $.store.get(SPEND_SESSIONS_KEY), await $.session.id(), state),
30 );
31 return next(e);
32 });
33}
34hooks/tabs.ts 31 lines1import type { On } from "claude-code";
2
3import { ACTIVITY_TAB, registerActivityTab } from "./activity-tab";
4import { AGENTS_TAB, registerAgentsTab } from "./agents-tab";
5import { CONTEXT_TAB, registerContextTab } from "./context-tab";
6import { FILES_TAB, registerFilesTab } from "./files-tab";
7import { MCP_TAB, registerMcpTab } from "./mcp-tab";
8import { registerSkillsTab, SKILLS_TAB } from "./skills-tab";
9import { registerSpendTab, SPEND_TAB } from "./spend-tab";
10import type { TabEntry } from "./tab-bar";
11
12export const TABS: [TabEntry, ...TabEntry[]] = [
13 ACTIVITY_TAB,
14 FILES_TAB,
15 AGENTS_TAB,
16 CONTEXT_TAB,
17 MCP_TAB,
18 SKILLS_TAB,
19 SPEND_TAB,
20];
21
22export function registerTabs(on: On): void {
23 registerActivityTab(on);
24 registerFilesTab(on);
25 registerAgentsTab(on);
26 registerContextTab(on);
27 registerMcpTab(on);
28 registerSkillsTab(on);
29 registerSpendTab(on);
30}
31