SLOPSHOPPER

paneline

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…

newpanebandspinnerrowsguard
★ 5v0.3.2MITupdated 2026-10-06markneonin/paneline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · paneline
│ ┃ Session ✕ │ ┃ Activity Files Agents Context MCP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ┃ │ ┃ 42s · 9 tools · 3 files · +12 · −0 · 1 erro │ ┃ ⏺ Read(src/auth.ts) │ ┃ Now ─────────────────────────────────────── ⎿ Read 6 lines │ ┃ idle │ ┃ TOOL ────────────────────────────────────────────────────────────── │ ┃ Tool mix ────────────────────────────────── Done Edited 1 file [ Details ] │ ┃ ███████████████████████████████████████████ ⏺ Bash(bun test) │ ┃ ● Read 1 ● Edit 3 ● Bash 4 ● Search 1 ⎿ 3 pass, 1 fail │ ┃ │ ┃ Timeline ────────────────────────────────── │ ┃ ✓ Bash cat .env Done. I made refresh reject expired claims and added an audit call. │ ┃ ✓ Bash rm -rf build && git push --force o │ ┃ ✓ Bash git status --porcelain │ ┃ ✗ Bash bun test Completed · 42s · 1 file read · 3 files changed │ ┃ ✓ Write src/cache.ts │ ┃ ✓ Write src/audit.ts › /session │ ┃ ✓ Edit src/auth.ts │ ┃ ✓ Grep refresh\( │ ┃ ✓ Read src/auth.ts │ ┃ │ ┃ Failed ──────────────────────────────────── │ ┃ ✗ Bash bun test │ ⟨Claude Code's own drawing⟩ opus 5.5 /work/app ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ opus 5.5 /work/app
Pane · Session
Activity Files Agents Context MCP Skills › 42s · 9 tools · 3 files · +12 · −0 · 1 error Now ───────────────────────────────────────────── clear idle Tool mix ────────────────────────────────────────────── ███████████████████████████████████████████████████████ ● Read 1 ● Edit 3 ● Bash 4 ● Search 1 Timeline ────────────────────────────────────────────── ✓ Bash cat .env 20ms ✓ Bash rm -rf build && git push --force origi… 20ms ✓ Bash git status --porcelain 20ms ✗ Bash bun test 20ms ✓ Write src/cache.ts 20ms ✓ Write src/audit.ts 20ms ✓ Edit src/auth.ts 20ms ✓ Grep refresh\( 20ms ✓ Read src/auth.ts 20ms Failed ──────────────────────────────────────────────── ✗ Bash bun test 20ms
Turn line
Completed · 42s · 1 file read · 3 files changed
Your message
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
TOOL ───────────────────────────────────────────────────────────────────────────────────────────── Done Edited 1 file [ Details ]
README

paneline

License: MIT Claude Code 2.1.289+

paneline in a Claude Code session

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.

Install

/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.

How it looks

Status row

Status row

Green, purple and orange sessions

Session colour green Session colour purple Session colour orange

Chat

Chat layout

Tables, alerts and code panels

Tables, alerts and a code panel

Mermaid diagram

Mermaid diagram in the terminal

Edit diff panel

Edit diff panel

Write panel

Write panel

Activity tab

Activity tab

Files tab

Files tab

Agents tab

Agents tab

Context tab

Context tab

MCP tab

MCP tab

Skills tab

Skills tab

Spend tab

Spend tab

What it runs and reads

paneline works only on your machine. It sends nothing over the network and asks for no keys or tokens.

  • Programs it starts: read-only 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.
  • Files it reads: ~/.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.
  • Slash commands it runs: /mcp enable <server> or /mcp disable <server>, only when you press that button in the MCP tab.
  • Events it watches: tool calls, prompts, subagent starts and stops, /color, /theme and /config changes. It passes every event on unchanged and never answers a permission prompt.
  • What it stores: its own state in Claude Code's plugin store on this machine.
  • Command it adds: /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.

Other ways to run

  • In the fullscreen layout the pane docks beside the chat from 110 columns. In the main-screen layout (the default under tmux, or with CLAUDE_CODE_NO_FLICKER=0) it opens inline above the prompt at any width.
  • The pane opens when a session starts. Run /session to open it again after you close it.
  • Node.js, only if you want to run the checks (see Development).

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.

Development

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:

  • The hooks are TypeScript in hooks/. Tests are in tests/.
  • The pre-commit hook (husky and lint-staged) lints and format-checks staged files, then type-checks.
  • 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.
  • The code has no comments. Names and tests have to explain it.

License

MIT (c) 2026 Mark. The Mermaid renderer in hooks/vendor/ is third-party code, see THIRD_PARTY_NOTICES.md.

Source 69 files
hooks/register.ts 33 lines
1import 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};
33
hooks/activity-track.ts 190 lines
1import { 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}
190
hooks/agents-track.ts 263 lines
1import { 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}
263
hooks/chat-render.tsx 260 lines
1import { 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}
260
hooks/git-track.ts 197 lines
1import { 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}
197
hooks/mcp-track.ts 38 lines
1import { 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}
38
hooks/probe.ts 51 lines
1import 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}
51
hooks/prompt-track.ts 117 lines
1import { 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}
117
hooks/session-pane.tsx 77 lines
1import { 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}
77
hooks/skills-track.ts 39 lines
1import { 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}
39
hooks/spend-track.ts 34 lines
1import { 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}
34
hooks/tabs.ts 31 lines
1import 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