Session pane: context, cost and turns, the last turn's tokens, cache, tools and refused calls, a task list Claude keeps, an autopick switch that answers a…

<h1 align="center">🧭 session-info</h1>
A Claude Code mod with one pane and thirteen sections.
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdougcunha%2Fclaude-mods%2Fmain%2Fsession-info%2F.claude-plugin%2Fplugin.json&query=%24.version&label=version&color=blue" alt="Version"> <a href="../LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a> <img src="https://img.shields.io/badge/Claude%20Code-2.1.296-d97757.svg" alt="Tested on Claude Code 2.1.296">
<a href="#-install">Install</a> · <a href="#-use">Use</a> · <a href="#-settings">Settings</a> · <a href="#-limitations">Limitations</a> · <a href="#-development">Development</a>
todo_write tool for it./usage, updated live.git status lists, in a treeview like the Changes one. Pressing a file opens its diff against HEAD; pressing the branch name opens your Git client, such as lazygit, in a new terminal window.🔔 It also raises a native notification when a usage window reaches 80% or 90%, or the context window reaches 85%, even with the pane closed. See Alerts.
Each section is a card with a rounded frame that lights up under the pointer in the fullscreen terminal, and a row of tiles above them shows the cost and the elapsed time at a glance. Bars fill in whole cells, so the line after the filled run starts flush against it in every bar. In a terminal pane narrower than 44 columns the frames and tiles step aside and the sections draw bare, as in earlier versions. The desktop app always draws the frames and tiles, sizing them and the bars by percentage instead of terminal cells.
Sections can share one card as tabs, to make the pane shorter. By default Changes and Git do: the card opens on Changes, and pressing a tab shows that section in the same place. A tab that is not active shows a count, such as Changes 12, so a hidden section still says it has something. The tabGroups option sets the groups, for example changes+git, ci+jira+skills.
The sectionOrder option sets the order of the sections, top to bottom, for example changes, git, session. The sections it does not name follow in the default order: Session, Last turn, Todos, Skills, CI, Jira, Usage, Agents, Background, Checks, Git, Changes. A tab group card sits where its first shown section sits in this order, and it stays there when you press another tab. The order of the tabs in the card, and the tab it opens on, come from tabGroups. For example, with sectionOrder set to git, session and the default group changes+git, the Changes and Git card is at the top and opens on Changes. A group typed whole, such as changes+git, also counts as its sections in order.
A row that does not fit the pane ends in …. Pressing a cut text opens the whole of it in place of the sections, with a ← Back button (b) that returns to them and a Copy button (y) that puts the text on the clipboard. The text is wrapped to the pane and scrolls with it. These texts open that way:
| Section | Texts |
|---|---|
| Last turn | The tools called and the refused calls |
| Autopick | The recommended options of a question that waits, the Picked line (why it happened, then each pick on its own line) and the warning about the ask-autopick mod |
| CI | The error that stopped the polling |
| Jira | An issue's summary and the error of a failed read |
| Agents | An agent's model, effort and task description |
| Background | A command's name and its whole command, every line of it |
| Checks | The command, the result line and the error lines or failed test names, shown together |
| Git | The commit subject and the operation in progress |
The view keeps the first 4,000 characters of a text. It is not saved: /clear closes it, and so does a reload.
The pane in a fullscreen terminal at the start of a task: an autopick countdown on a question, a task list with its first task in progress, and an active skill:

Each section that follows has its own screenshot from the same session.
At the Claude Code prompt:
/plugin install session-info --marketplace dougcunha/claude-mods
Answer y to add the marketplace and choose a scope. If you already added the marketplace:
/plugin install session-info@claude-mods
| Action | How |
|---|---|
| Open the pane | /session-info (or /changes). To open it when the session starts, set session-info.openOnStart to true |
| Fold or unfold a section | Press its header, for example ▾ Session or ▾ Changes |
| Open the Git client | Press the branch name in the ▾ Git header, or press g while the pane has the focus |
| Show the legend of the context bar | Press ▸ Context |
| Open a file | Click the file in the fullscreen terminal, or move to it with Tab or the arrow keys and press Enter. A new file (A, or ?? in the Git tree) opens in the editor; a modified (M) or deleted (D) file opens as a diff. In the Git tree, the diff is against HEAD |
| Collapse or expand a folder | Press the folder |
| Scroll a long tree | A tree taller than the room the terminal leaves draws a window of rows with a scrollbar (┃ on the track │) at its right edge, and under it ▲ and ▼ with the rows above and below. Press ▲ or ▼, or u and d, to move a page; press the track to jump to that place. The wheel over a list moves that list, a page at most per turn of the wheel; elsewhere on a pane taller than its window it scrolls the whole pane, and so does the wheel over a list that has reached its end. A list always shows at least 6 rows. The arrow keys move the first tree on screen while the pane fits its window. In the Git tree u and d work while the Changes tree is not on screen |
| Collapse all, expand all, clear the list | c, e, x, or the buttons at the bottom. The Git tree has collapse and expand only; c and e go to it while the Changes tree is not on screen |
| Turn autopick on or off | Press [ ] Answer unanswered questions in the Autopick section, or run /autopick on or /autopick off |
| Set the autopick wait | Press − or + in the Autopick section, or run /autopick <minutes>, 1 to 120 |
| Answer a waiting question now, or stop its countdown | Press Pick now or Cancel on its row in the Autopick section |
| Move or hide a card | Press ≡ Layout at the bottom of the pane, or press l while the pane has the focus. Each card header then shows ↑, ↓ and ✕. Press ≡ Done or l again to leave layout mode. See Arrange the cards |
| Show a hidden card again | Press its name after Hidden: at the bottom of the pane, or press [ ] before its name in the Sections list of layout mode |
| Return to the prompt | Esc |
Status colors follow your theme: green for created (A), yellow for modified (M), red and struck through for deleted (D). Chains of single-child folders collapse into one row, for example src/app/ui.

| Row | Source |
|---|---|
| Cost and elapsed (tiles) | The session's cost as /cost totals it and the time since the session started. In a narrow pane, they move to the section header |
| Context bar | The live context window: tokens used, the window size, where auto-compaction runs (compacts at 967k) and the percentage. The bar is stacked, one color per category (system prompt, tools, memory files, skills, messages), then the free space as a thin line and the compaction buffer shaded. Pressing ▸ Context unfolds a legend with each category's tokens and share. The categories are estimated locally after each turn and compaction; before the first reading the bar has one color |
| Compactions | Compactions that went through in the main conversation |
| Clears | /clear commands run since the mod loaded, by hand or by another mod (· 3 clears); it shows after the first reply that follows a /clear |
| Turns | Main-thread turns, their average output tokens, and how long the last one took |
The compaction and clear counts start when the mod loads, so a newly installed mod starts them at zero. When the mod joins a conversation that already has turns, after a resume or an install, the turn count starts from the prompts in its transcript.
After a /clear or a /resume in the same process, Claude Code starts the next conversation with empty pane state and raises no start event for it. The mod waits until the session id changes, then reads the context, the repository, the Autopick switch and the Jira server again and, on a resume, the task list from the transcript.
The section shows the last turn of the main conversation. It updates when the turn ends.

| Row | Source |
|---|---|
| Header | The turn's number (#14), how the turn ended (✓ answer, ⊘ aborted, ✖ refusal, ✖ error), its duration, and its cost: the session's cost when the turn ended minus the cost when it started, so it includes the subagents that ran during the turn |
| Model | The model of the last request of the turn, the effort it sent, and how many model requests the turn made |
| Tokens | Input, output, cache read and cache write tokens, summed over the turn's requests, and the share of the input that the prompt cache served (cache read / (input + cache read + cache write)) |
| Tools | The tools the main conversation called in the turn, most-called first, four names and then +N more. An MCP tool shows by its own name. Then the subagents the turn spawned and the tokens of the subagent runs that ended during the turn |
| Refused | The calls of the main conversation that the permission check refused in the turn (⊘ 2 refused: Bash (Bash(rm:*)) · WebFetch (mode)), in order, four and then +N more. Each names what decided: the settings rule as written, the hook (PreToolUse), the setting, or mode for the permission mode. The row shows only when a call was refused |
The token counts of the main turn do not include its subagents; the Tools row shows them apart. The Refused row reads the verdict that Claude Code's rules, permission mode and hooks give before a call runs. A call that you refuse in the permission dialog, or that the auto-mode classifier refuses, is not in it: the mod gets no verdict for those. A subagent that runs in the background and ends after the turn is not counted. When the mod loads while a turn runs, for example after a reload, that turn's record has no effort, request count, tools or cost. /clear empties the section.

Every agent loop of the session, as Claude Code lists it: subagents the model or a plugin spawned and teammates. Each row shows a status glyph, the agent's name (or its type), its model and effort, and its task description:
| Glyph | Status |
|---|---|
⟳ | running |
⏸ | waiting |
◌ | pending |
○ | idle (a teammate waiting for a message) |
✓ | completed |
✖ | failed |
⊘ | killed |
Active agents come first; an agent spawned by another is indented under it. Claude Code drops a finished agent from its list some time after it ends, and the row leaves with it. The section redraws when an agent starts or finishes a turn, and once a minute. Up to 8 rows are shown.
The model comes from the agent's spawn and then from each of its model requests; the effort comes from its requests only, so it shows after the agent's first request. The last request wins, so a fallback model shows too. An agent that started before the mod loaded shows its model and effort after its next request; one that ended before that shows neither. /clear forgets them.
The shell commands that Claude started with run_in_background, in a Bash or PowerShell call of the main conversation or of a subagent. Each row shows a status glyph, the task's name, and its state in words with the time it has run or ran for:
| Glyph | State | Meaning |
|---|---|---|
⟳ | running · 3m 2s | The command runs, and the time counts up |
✓ | done · 12s | The task ended with the status completed |
✖ | failed · 4s | The task ended with any other status than completed or killed |
⊘ | stopped · 1m 1s | The task was killed |
The name is the description Claude wrote for the call, with the command on a dim line under it; with no description, the command's first line is the name (the detail view shows the whole command). Running tasks come first, then the ended ones, newest first. The header counts them by state, and a tab that is not active shows how many run. Up to 8 rows are shown, then a count of the rest, and the section keeps the last 30 commands.
A command is running until its task notification reaches the session: Claude Code sends one when a background task ends, and the mod marks the task ended then, not at the moment the command exits. The time of an ended task is the time the notification took to arrive. A foreground command is not listed, and neither is a task that Claude Code started without a shell call, such as a background subagent (the Agents section has those). The section keeps only the command, the description and the state: it never keeps a command's output. /clear empties it. Commands that the Checks section recognizes as a build or test run show in both sections.

One row for the last build and one for the last test run, from any shell command the session ran, a subagent's included (marked agent). A command counts when one of its segments runs a known tool, so cd app && npm test and rtk dotnet test … count too. The runners in front of a tool and their options are skipped: rtk, env, time, npx/bunx/uvx (so npx -y -p typescript tsc is tsc), pnpm/yarn exec and dlx, uv/poetry/pipenv/hatch run, bundle exec and variable assignments such as CI=1.
| Check | Commands |
|---|---|
| Build | dotnet build, dotnet publish, dotnet pack, dotnet msbuild, msbuild, npm/pnpm/yarn/bun run build, compile, typecheck or type-check, bun build, tsc, vue-tsc, tsgo, tsup, esbuild, webpack, rollup, vite/next/nuxt/astro/ng/parcel/turbo/nx/flutter build, claude plugin validate, cargo build, cargo check, cargo clippy, go build, go vet, Gradle and Maven builds, swift/zig/stack/cabal build, mix compile, xcodebuild, cmake --build, ninja, and make alone or with all or build |
| Tests | dotnet test, dotnet run of a test project, npm/pnpm/yarn/bun test, bun test, jest, vitest, mocha, ava, playwright test, cypress run, node --test, deno test, claude plugin test, turbo/nx test, cargo test, cargo nextest, go test, gotestsum, pytest, python -m unittest, tox, nox, Gradle and Maven test, check or verify, rspec, rake test, rails test, phpunit, pest, php artisan test, mix/swift/flutter/dart/stack/cabal test, zig build test, ctest, make test and make check |
[!TIP] Add your own commands, such as
./build.ps1, with thebuildPatternandtestPatternsettings.
The status comes from the command's exit code; an interrupted or timed-out command shows as failed. When a pipe hid the exit code (dotnet test | tail -3), the summary in the output decides. The details come from the output itself:
| Check | Shows |
|---|---|
| A failed build | The error count and the first error lines (file(line,col): code: message). With no recognizable error line, the last lines of the output |
| A test run | 12 passed, or 2 failed / 120 with the first failed test names. A test run that did not compile shows the compile errors |
| A command run in the background | running in background until its task ends. Claude Code then sends the session a notification with the task's status and output file; the mod reads that file and shows the result as for a command in the foreground |
The output is read as the model got it: raw, filtered by RTK, or localized. Localized .NET output (Com falha! – Com falha: 2, Aprovado: 1, …) is read by its structure, not its words. When RTK cut the output and points to the whole log ([full output: …]), or wrote a vitest JSON report in its place, the mod reads that file for the counts and names.
The readers were checked against real output of dotnet build and dotnet test (VSTest and Microsoft.Testing.Platform, English and Portuguese), tsc, jest, vitest, node --test, Bun and cargo test, each with and without RTK. Go and pytest are read by their documented formats. For the other tools, the status comes from the exit code, and the counts and failed names show when the output has a format the mod already reads, such as claude plugin test, which prints Bun's.
When a turn of the main conversation ends and the last build or test run failed, the mod proposes a prompt in the empty prompt box, dim, for Tab to take:
| Failure | Prompt |
|---|---|
| Build with an error count | Fix the 2 build errors |
| Build without an error count | Fix the failed build |
| Test run with failed test names | Fix the 2 failed tests: Subtract_Fails, Divide_Throws, three names and then and N more |
| Test run without names | Fix the 3 failed tests, or Fix the failed test run |
A failed build comes first, because a build that fails often explains the failed tests. Each failed run gets one proposal: the next turn proposes again only for a new failed run. Until you send the next prompt, the proposal replaces the suggestion that Claude Code makes after a turn. Claude Code shows no proposal while the prompt box holds text. Set suggestFix to false to turn it off.
The section shows the repository of the session's folder and only what differs from a clean state. A row with nothing to say is left out, so a clean repository takes one row under the header.

▾ Git g: ⎇ main ↑2 ↓1
⚠ Rebase in progress: step 3 of 7
✖ 1 conflict ● 2 staged ✎ 3 modified ? 1 untracked
◆ a1b2c3d feat: add the Git section 12m
◇ 4 submodules · 2 changed
libs/core new commit · modified
libs/ui not initialized
├─ ▾ src
│ ├─ ● a.ts M
│ ├─ ● b.ts M
│ ├─ ✚ new.ts A
│ └─ ● merge.ts UU
└─ ✚ notes.md ??
[ collapse ] [ expand ]
| Row | Shows |
|---|---|
| Header | The branch, then ↑ commits ahead of and ↓ commits behind its upstream. local only when the branch has no upstream, upstream gone when the upstream was deleted on the remote, detached and the short hash on a detached HEAD |
| Operation | A merge, rebase (with its step), cherry-pick, revert or bisect that stopped halfway |
| Counts | Conflicts, staged, modified and untracked files, the non-zero ones only. Each untracked file counts, also inside a new folder. Submodules are not counted here |
| Last commit | Short hash, subject and age |
| Submodules | The submodule count, then one row per submodule that is in conflict, not initialized, on a commit other than the one the repository records (new commit), or with changed files (modified). Up to 5 rows. Only the submodules of the repository itself are listed; a change in a nested submodule shows as modified on its parent |
| Files | Every changed, staged and untracked file, in a treeview from the repository root, with the code of git status --short: the staged letter in green, the unstaged letter in yellow, ?? for an untracked file and a red pair such as UU for a conflict. Up to 500 files. Pressing a new file opens it in the editor; pressing any other file opens its diff against HEAD (for a rename, against the old path at HEAD) in the configured diff viewer |
The mod reads the repository when the session starts, after each shell command or file edit, when you send a prompt, when you open the pane, and once a minute. It runs git status --porcelain=v2 --branch --untracked-files=all, git log -1, and git submodule status when the repository has a .gitmodules file. It
hooks/register.tsx 2032 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, RenderSurface, ToolCallResult } from 'claude-code'
3
4import type { AgentModel, AutopickPick, AutopickSettings, AutopickWait, BackgroundTask, ChangeStatus, Checks, CiRun, CiWatch, ContextReading, Detail, FileChange, InlineDiff, JiraIssue, Repo, RepoFile, SkillUse, Todo, TurnRecord } from '../types'
5
6import { AGENT_STATUS, agentLabel, agentModelText, agentRows, agentSummary, withAgentModel } from './agents'
7import { crossings } from './alerts'
8import { AUTOPICK_USAGE, autopickSummary, countdown, DEFAULT_SETTINGS, hasRival, minutesOf, pickDetail, pickedLog, pickedNote, picksDetail, picksOf, picksText, settingsOf, statusText, stepMinutes, STORE_MINUTES, STORE_ON } from './autopick'
9import type { Question } from './autopick'
10import type { AgentRow } from './agents'
11import { BACKGROUND_ROOM, BACKGROUND_STATUS, backgroundSummary, firstLine, hasCommandLine, orderedTasks, startedTask, taskLabel, taskRowCount, taskState, withEnd, withTask } from './background'
12import { BANNER_ROWS, FRAME_COLUMNS, FRAME_ROWS, isFramed, tileLayout, widgets } from './cards'
13import type { Tile } from './cards'
14import { openClient } from './client'
15import { clip, detailLines, detailOf } from './detail'
16import { cellWidths, contextText, GLYPH, LEGEND_GAP, legendLines, legendText, SWATCH, toReading } from './context'
17import { CHECK_STATUS, checkRowCount, checkRuns, checkSummary, checkViews, checkWhen, classify, fixSuggestion, NO_CHECKS, toPattern } from './checks'
18import { CI_POLL_MS, CI_ROOM, CI_STATUS, ciTrigger, isWatching, pollWatch, runDetail, startWatch, stateOf, waitingText, watchState } from './ci'
19import type { CheckView, Patterns } from './checks'
20import { gitStatus } from './git'
21import type { Host } from './host'
22import { fileIcon, folderIcon, STATUS } from './icons'
23import type { IconSet } from './icons'
24import { allowRequest, browseUrl, errorText, isAllowed, isIssueKey, isRefusal, issueDetail, issueKeys, JIRA_FIELDS, JIRA_ROOM, mention, needsRead, readIssue, readSite, serverNames, serverOf, siteSetting, statusColor, toKeyPattern } from './jira'
25import { age, branchText, codeParts, countParts, fileStatus, isConflict, ISSUE_COLOR, issueText, operationText, readRepo, repoRowCount, SUBMODULE_ROWS } from './repo'
26import { endSkills, isSameSkill, SKILL_ROOM, SKILL_STATUS, skillSummary, startSkill } from './skills'
27import { moveCard, ORDER, orderText, parseSectionOrder, parseTabGroups, showOption, TITLES } from './tabs'
28import type { SectionKey, TabGroup } from './tabs'
29import { clampOffset, MIN_LIST_ROWS, movedTo, offsetAt, POINTER_SLACK, scrollbar, targetAt, targetNear } from './scrollbar'
30import type { ScrollTarget, ScrollView } from './scrollbar'
31import { finishedRuns, taskEnds, withFinished } from './tasks'
32import { SHELLS, track } from './track'
33import { cost, countText, duration, NO_TURNS, tokens, turnLine } from './session'
34import { latestTodos, minSteps, parseTodos, TODO_ROOM, TODO_SPEC, TODO_STATUS, TODO_TOOL, todoPrompt, todoReply, todoSummary, todoText } from './todos'
35import { buildTree, flatten } from './tree'
36import { modelText, refusedText, RESULT, resultText, startDraft, toolsText, tokensText, toRecord, turnLines, withRefusal, withSpawn, withStep, withSubagentUsage, withTool } from './turn'
37import type { TurnDraft } from './turn'
38import type { Row } from './tree'
39import { customTitle, DEFAULT_TITLE, titleAlign, titleColor } from './title'
40import { bar, colorOf, labelOf, ordered, percentText, resetIn } from './usage'
41import type { Limit } from './usage'
42import { openChange, openRepoFile, openUrl } from './viewer'
43import type { Editor, Prefs, Viewer } from './viewer'
44
45const files = atom({ plugin: 'session-info', key: 'files' } as const, {} as Record<string, FileChange>)
46const collapsed = atom({ plugin: 'session-info', key: 'collapsed' } as const, [] as string[])
47const inline = atom({ plugin: 'session-info', key: 'inline' } as const, null as InlineDiff | null)
48const now = atom({ plugin: 'session-info', key: 'now' } as const, 0)
49const turns = atom({ plugin: 'session-info', key: 'turns' } as const, NO_TURNS)
50const compactions = atom({ plugin: 'session-info', key: 'compactions' } as const, 0)
51const breakdown = atom({ plugin: 'session-info', key: 'breakdown' } as const, false)
52const contextState = atom({ plugin: 'session-info', key: 'context' } as const, null as ContextReading | null)
53const alerted = atom({ plugin: 'session-info', key: 'alerted' } as const, [] as string[])
54const checks = atom({ plugin: 'session-info', key: 'checks' } as const, NO_CHECKS as Checks)
55const background = atom({ plugin: 'session-info', key: 'background' } as const, [] as BackgroundTask[])
56const detail = atom({ plugin: 'session-info', key: 'detail' } as const, null as Detail | null)
57const repoState = atom({ plugin: 'session-info', key: 'repo' } as const, null as Repo | 'outside' | null)
58const lastTurn = atom({ plugin: 'session-info', key: 'lastTurn' } as const, null as TurnRecord | null)
59const todos = atom({ plugin: 'session-info', key: 'todos' } as const, [] as Todo[])
60const skills = atom({ plugin: 'session-info', key: 'skills' } as const, [] as SkillUse[])
61const ciState = atom({ plugin: 'session-info', key: 'ci' } as const, null as CiWatch | null)
62const jiraServer = atom({ plugin: 'session-info', key: 'jiraServer' } as const, null as string | null)
63const jiraIssues = atom({ plugin: 'session-info', key: 'jiraIssues' } as const, [] as JiraIssue[])
64const agentModels = atom({ plugin: 'session-info', key: 'agentModels' } as const, {} as Record<string, AgentModel>)
65const tabs = atom({ plugin: 'session-info', key: 'tabs' } as const, {} as Record<string, string>)
66const autopick = atom({ plugin: 'session-info', key: 'autopick' } as const, DEFAULT_SETTINGS as AutopickSettings)
67const autopickWaits = atom({ plugin: 'session-info', key: 'autopickWaits' } as const, [] as AutopickWait[])
68const autopickLast = atom({ plugin: 'session-info', key: 'autopickLast' } as const, null as AutopickPick | null)
69const autopickConflict = atom({ plugin: 'session-info', key: 'autopickConflict' } as const, false)
70const layout = atom({ plugin: 'session-info', key: 'layout' } as const, false)
71const changesTop = atom({ plugin: 'session-info', key: 'changesTop' } as const, 0)
72const gitTop = atom({ plugin: 'session-info', key: 'gitTop' } as const, 0)
73
74const PANE = 'session-info'
75/** Why the Autopick switch stays off while the ask-autopick mod answers the same questions. */
76const RIVAL_TEXT = 'The ask-autopick mod is loaded and answers questions too: this switch stays off until you disable it.'
77const MINUTE = 60_000
78/** Cells an Autopick row keeps free so its text, cut with `…`, never meets the edge. */
79const ROW_SPARE_COLUMNS = 1
80/** Cells a wait row spends beside its picks: the countdown, its gap, Pick now, Cancel and their gaps; and, with no countdown, the glyph and its gap. */
81const WAIT_COUNTING_COLUMNS = 26
82const WAIT_IDLE_COLUMNS = 2
83
84/** Tools whose calls can change the working tree, so the Git section reads the repository again after them. */
85const FILE_TOOLS: readonly string[] = [...SHELLS, 'Write', 'Edit', 'NotebookEdit']
86
87let reading: Promise<void> | undefined
88let isStale = false
89/** The main-thread turn that runs now; a reload during a turn starts without it. */
90let draft: TurnDraft | undefined
91/** Skill tool calls in flight: the skill.prompt each raises takes the calling loop from here. */
92const pendingSkills: { name: string; agentId?: string; isSeen: boolean }[] = []
93/** The GitHub poll that runs now; a tick while it runs is skipped. */
94let polling: Promise<void> | undefined
95/** The Jira site the reads go to, found once per load; the keys whose read runs now. */
96let jiraSite: { cloudId: string; url?: string } | undefined
97/** The name `$.mcp.call` took for the Jira server, found by the first call that went through. */
98let jiraName: string | undefined
99/** /clear commands run since this load; $.state starts over with each /clear, so the count lives here. */
100let clearCount = 0
101/** How long layout mode waits for the draw that adds the Sections checklist before it scrolls the pane to its end. */
102const LAYOUT_SCROLL_MS = 50
103
104/** How long a fix suggestion the busy prompt box refused waits for its second try. */
105const FIX_RETRY_MS = 1_500
106/** The fix the prompt box proposes after a turn left a failed check, until the next prompt; the key names the run it is for. */
107let fix: { key: string; text: string; isOpen: boolean } | undefined
108/** After a session.end the state switches at once, but the session id and its usage only some time later. */
109const SWITCH_POLL_MS = 200
110const SWITCH_LIMIT_MS = 10_000
111const reads = new Set<string>()
112
113/**
114 * The countdowns of one load of the module: the Pick now and Cancel actions of each question whose countdown runs, by
115 * tool_use_id, and the one-second tick that redraws them, stopped while none runs.
116 */
117type Countdowns = { controls: Map<string, { pick: () => void; cancel: () => void }>; ticker?: { cancel: () => void } }
118
119const toggle = (list: readonly string[], key: string): string[] =>
120 list.includes(key) ? list.filter(one => one !== key) : [...list, key]
121
122const isSection = (key: string): boolean => key.startsWith('#')
123
124/** Folded folders of the Git tree carry this prefix, so they never mix with the Changes tree's. */
125const GIT_FOLD = 'git:'
126
127const isGitFold = (key: string): boolean => key.startsWith(GIT_FOLD)
128
129function summary(changes: readonly FileChange[]): string {
130 const count = (status: FileChange['status']) => changes.filter(one => one.status === status).length
131
132 return (['created', 'modified', 'deleted'] as const).map(s => `${STATUS[s].glyph} ${count(s)}`).join(' ')
133}
134
135function hostOf($: EngineInterface): Host {
136 return {
137 run: (argv, cwd) => $.process.run(argv, cwd === undefined ? undefined : { cwd }),
138 spawn: (argv, cwd) => $.process.spawn(cwd === undefined ? { argv } : { argv, cwd }),
139 readFile: path => $.fs.read(path),
140 writeFile: (path, text) => $.fs.write(path, text),
141 exists: path => $.fs.exists(path),
142 env: async () => ({
143 temp: await $.env.get('TEMP'),
144 tmpdir: await $.env.get('TMPDIR'),
145 os: await $.env.get('OS'),
146 home: (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')),
147 }),
148 sessionId: () => $.session.id(),
149 cwd: () => $.session.cwd(),
150 toast: text => $.ui.toast(text),
151 files: () => read($, files),
152 updateFiles: async fn => void (await update($, files, all => fn(all ?? {}))),
153 showInline: async diff => void (await update($, inline, () => diff)),
154 }
155}
156
157/** A folder folds or unfolds; a file opens in the editor or as a diff. */
158async function press($: EngineInterface, row: Row, prefs: Prefs): Promise<void> {
159 try {
160 if (row.isDir) await update($, collapsed, list => toggle(list ?? [], row.key))
161 else if (row.change) await openChange(hostOf($), row.change, prefs)
162 } catch (error) {
163 $.ui.toast(`session-info: ${String(error)}`)
164 }
165}
166
167/** A folder of the Git tree folds or unfolds; a file opens in the editor or as a diff against HEAD. */
168async function pressRepo($: EngineInterface, repo: Repo, row: Row<RepoFile>, prefs: Prefs): Promise<void> {
169 try {
170 if (row.isDir) await update($, collapsed, list => toggle(list ?? [], `${GIT_FOLD}${row.key}`))
171 else if (row.change) await openRepoFile(hostOf($), repo.root, row.change, repo.at, prefs)
172 } catch (error) {
173 $.ui.toast(`session-info: ${String(error)}`)
174 }
175}
176
177/** Notifies each usage or context threshold the measurement crosses, once; a toast stands in where notifications are off. */
178async function alert($: EngineInterface, limits: readonly Limit[], context: { percent?: number; tokens?: number; window: number }): Promise<void> {
179 const { alerts, standing } = crossings(limits, context, await read($, alerted), Date.now())
180 await update($, alerted, () => standing)
181 for (const { title, body } of alerts) {
182 const sent = await $.ui.notify(body, { title }).catch(() => ({ isSent: false }))
183 if (!sent.isSent) $.ui.toast(`${title}: ${body}`)
184 }
185}
186
187/** Records a shell call that ran a build or tests as the last one of its kind; any agent's call counts. */
188async function inspect($: EngineInterface, input: Record<string, unknown>, ran: Record<string, unknown>, patterns: Patterns): Promise<void> {
189 const command = typeof input.command === 'string' ? input.command : ''
190 const kinds = classify(command, patterns)
191 if (kinds.length === 0 || ran.deny !== undefined) return
192
193 const isError = ran.isError === true
194 const result = !isError && typeof ran.result === 'object' && ran.result !== null ? (ran.result as Record<string, unknown>) : {}
195 const fallback = typeof ran.result === 'string' ? ran.result : `${result.stdout ?? ''}\n${result.stderr ?? ''}`
196 const runs = await checkRuns(hostOf($), kinds, {
197 command,
198 text: typeof ran.text === 'string' ? ran.text : fallback,
199 isError,
200 taskId: typeof result.backgroundTaskId === 'string' ? result.backgroundTaskId : undefined,
201 isAgent: input.agentId !== undefined,
202 persistedPath: typeof result.persistedOutputPath === 'string' ? result.persistedOutputPath : undefined,
203 }, Date.now())
204 await update($, checks, current => runs.reduce((all, run) => ({ ...all, [run.kind]: run }), current ?? NO_CHECKS))
205}
206
207/** Proposes a fix of the failed check in the empty prompt box, once per failed run; a box still busy gets one more try. */
208async function suggestFix($: EngineInterface): Promise<void> {
209 const suggestion = fixSuggestion(await read($, checks))
210 if (suggestion === undefined || suggestion.key === fix?.key) return
211 const open = { ...suggestion, isOpen: true }
212 fix = open
213 const shown = await $.prompt.suggest({ text: open.text }).catch(() => ({ isShown: false }))
214 if (!shown.isShown)
215 $.clock.after(FIX_RETRY_MS, () => {
216 if (fix === open && open.isOpen) void $.prompt.suggest({ text: open.text }).catch(() => undefined)
217 })
218}
219
220/** A conversation the mod joins with turns behind it, by a resume or an install, counts them from the prompts in its transcript. */
221async function seedTurns($: EngineInterface): Promise<void> {
222 const sent = await $.session.turns()
223 await update($, turns, stats => ((stats ?? NO_TURNS).count > 0 ? (stats ?? NO_TURNS) : { ...(stats ?? NO_TURNS), count: sent }))
224}
225
226/** Which sections a task notification can change: the Background list marks the task ended, Checks reads the run it ran. */
227type TaskSections = { isBackground: boolean; isChecks: boolean }
228
229/** Marks each background task the notification says ended, and reads the checks of its command. */
230async function finishTasks($: EngineInterface, text: string, { isBackground, isChecks }: TaskSections): Promise<void> {
231 for (const end of taskEnds(text)) {
232 if (isBackground) await update($, background, tasks => withEnd(tasks ?? [], end, Date.now()))
233 if (!isChecks) continue
234 const runs = await finishedRuns(hostOf($), await read($, checks), end, Date.now())
235 if (runs.length > 0) await update($, checks, current => withFinished(current ?? NO_CHECKS, end, runs))
236 }
237}
238
239/** Reads the repository into the Git section; a call made while a read runs makes that read go once more. */
240function refreshRepo($: EngineInterface): Promise<void> {
241 if (reading !== undefined) {
242 isStale = true
243
244 return reading
245 }
246 reading = (async () => {
247 do {
248 isStale = false
249 // A read that fails keeps the last state on screen; only git's own answer says the folder is outside a repository.
250 const repo = await readRepo(hostOf($), Date.now()).catch(() => undefined)
251 if (repo !== undefined) await update($, repoState, () => repo ?? 'outside')
252 } while (isStale)
253 })().finally(() => {
254 reading = undefined
255 })
256
257 return reading
258}
259
260/** Keeps what a subagent runs on; a step that repeats the model and effort writes nothing, so the pane does not redraw. */
261async function noteAgentModel($: EngineInterface, agentId: string, model: string, effort?: string | number): Promise<void> {
262 const known = await read($, agentModels)
263 const next = withAgentModel(known, agentId, model, effort)
264 if (next !== known) await update($, agentModels, () => next)
265}
266
267/** Reads /context's breakdown, estimated locally (no token-count calls), for the stacked bar. */
268async function refreshContext($: EngineInterface): Promise<void> {
269 const usage = await $.session.usage({ breakdown: 'summary' })
270 const reading = usage.context.breakdown ? toReading(usage.context.breakdown) : null
271 if (reading === null) return
272 await update($, contextState, () => reading)
273 // After a /clear or a resume the pane's reads no longer redraw it on a write: it is drawn again here.
274 $.ui.invalidate('ui.render')
275}
276
277/**
278 * Runs `reload` once the session id is the next conversation's. A /clear or a resume raises session.end and no start
279 * event; the state switches at once, but the session id and its usage only some time later, and a read made before
280 * would put the ended conversation's figures in the next one's state.
281 */
282function afterSwitch($: EngineInterface, endedId: string, reload: () => Promise<void>): void {
283 let waited = 0
284 const poll = async (): Promise<void> => {
285 if ((await $.session.id().catch(() => endedId)) !== endedId) return reload()
286 waited += SWITCH_POLL_MS
287 if (waited < SWITCH_LIMIT_MS) $.clock.after(SWITCH_POLL_MS, () => void poll())
288 }
289 $.clock.after(SWITCH_POLL_MS, () => void poll())
290}
291
292/** Reads the context when the session has no reading yet: after a /clear or a resume, before any turn ends. */
293async function readMissingContext($: EngineInterface): Promise<void> {
294 if ((await read($, contextState)) === null) await refreshContext($).catch(() => undefined)
295}
296
297/** A resumed session starts with no list in its state: the last todo_write call of the transcript gives it back. */
298async function restoreTodos($: EngineInterface): Promise<void> {
299 if ((await read($, todos)).length > 0) return
300 const messages = await $.session.messages()
301 const found = Array.isArray(messages) ? latestTodos(messages) : undefined
302 if (found !== undefined) await update($, todos, () => found)
303}
304
305/** Asks GitHub about the watched runs once, and notifies each run that completed. */
306function pollCi($: EngineInterface): Promise<void> {
307 polling ??= (async () => {
308 const watch = await read($, ciState)
309 if (!isWatching(watch, Date.now())) return
310 const { watch: polled, finished } = await pollWatch(argv => $.process.run(argv), watch, Date.now())
311 // A push made while the poll ran started a new watch: the old poll's answer is dropped.
312 await update($, ciState, current => (current?.since === watch.since ? polled : current))
313 for (const run of finished) {
314 const body = `${run.workflow} · ${run.conclusion} · ${runDetail(run, Date.now())}`
315 const sent = await $.ui.notify(body, { title: 'GitHub Actions' }).catch(() => ({ isSent: false }))
316 if (!sent.isSent) $.ui.toast(`GitHub Actions: ${body}`)
317 }
318 })().finally(() => {
319 polling = undefined
320 })
321
322 return polling
323}
324
325/** Starts following the runs a push, a pull request, a dispatch or a rerun started. */
326async function watchCi($: EngineInterface, command: string, output: string): Promise<void> {
327 const trigger = ciTrigger(command, output)
328 if (trigger === undefined) return
329 const git = async (args: string[]) => {
330 const done = await $.process.run(['git', ...args]).catch(() => undefined)
331
332 return done?.exitCode === 0 ? done.stdout.trim() : undefined
333 }
334 const isCommit = trigger.kind === 'push' || trigger.kind === 'pr'
335 const sha = isCommit ? await git(['rev-parse', 'HEAD']) : undefined
336 const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'])
337 await update($, ciState, () =>
338 startWatch(trigger, { ...(sha === undefined ? {} : { sha }), ...(branch === undefined ? {} : { branch }) }, Date.now()),
339 )
340 await pollCi($)
341}
342
343/** The connected MCP server that serves getJiraIssue; servers connect after the session starts, so a miss asks again later. */
344async function findJiraServer($: EngineInterface): Promise<string | null> {
345 const known = await read($, jiraServer)
346 if (known !== null) return known
347 const server = (await $.tool.list()).map(tool => serverOf(tool.name)).find(name => name !== undefined) ?? null
348 if (server !== null) await update($, jiraServer, () => server)
349
350 return server
351}
352
353/** Calls a tool of the Jira server by each name it may go by until one goes through; all failing, the first error stands. */
354async function callJira($: EngineInterface, server: string, tool: string, args: Record<string, unknown>) {
355 let first: unknown
356 for (const name of jiraName === undefined ? serverNames(server) : [jiraName]) {
357 try {
358 const result = await $.mcp.call(name, tool, args)
359 jiraName = name
360
361 return result
362 } catch (error) {
363 first ??= error
364 }
365 }
366 throw first
367}
368
369/** The site from the jiraSite setting, else the first Jira site the server's account reaches. */
370async function findJiraSite($: EngineInterface, server: string, setting: unknown): Promise<{ cloudId: string; url?: string } | undefined> {
371 jiraSite ??= siteSetting(setting) ?? readSite(await callJira($, server, 'getAccessibleAtlassianResources', {}))
372
373 return jiraSite
374}
375
376async function readJira($: EngineInterface, key: string, setting: unknown): Promise<void> {
377 if (reads.has(key)) return
378 reads.add(key)
379 const started = Date.now()
380 const store = (fn: (issue: JiraIssue) => JiraIssue) =>
381 update($, jiraIssues, list => (list ?? []).map(issue => (issue.key === key ? fn(issue) : issue)))
382 try {
383 const server = await findJiraServer($)
384 if (server === null) return void (await store(issue => ({ ...issue, readAt: Date.now(), error: 'No Atlassian MCP server is connected.' })))
385 const site = await findJiraSite($, server, setting)
386 if (site === undefined) return void (await store(issue => ({ ...issue, readAt: Date.now(), error: 'No Jira site found: set jiraSite.' })))
387 const result = await callJira($, server, 'getJiraIssue', { cloudId: site.cloudId, issueIdOrKey: key, fields: JIRA_FIELDS })
388 const url = browseUrl(site.url, key)
389 await store(issue => ({ ...readIssue(issue, result, Date.now()), ...(url === undefined ? {} : { url }) }))
390 } catch (error) {
391 const message = error instanceof Error ? error.message : String(error)
392 // The row has room for one line; the transcript keeps the whole message.
393 $.ui.log(`session-info: Jira ${key}: ${message}`)
394 const server = await read($, jiraServer)
395 const blocked = server !== null && isRefusal(message) ? { blockedTool: `mcp__${server}__getJiraIssue` } : {}
396 // Claude's own read may land while this one fails: a good reading newer than this read stays.
397 await store(issue =>
398 issue.error === undefined && issue.readAt !== undefined && issue.readAt >= started
399 ? issue
400 : { ...issue, readAt: Date.now(), error: errorText(error), ...blocked },
401 )
402 } finally {
403 reads.delete(key)
404 }
405}
406
407/** Puts the request for the allow rule in the prompt, after any draft: the person sends it, and Claude edits the settings. */
408async function askAllow($: EngineInterface, tool: string): Promise<void> {
409 const filled = await $.prompt.fill({ text: allowRequest(tool), mode: 'append' }).catch(() => ({ isFilled: false }))
410 $.ui.toast(filled.isFilled ? 'session-info: send the request in the prompt to add the rule.' : 'session-info: the prompt could not take the request.')
411}
412
413async function copyRule($: EngineInterface, tool: string, surface: RenderSurface): Promise<void> {
414 const copied = await $.ui.copy({ text: tool, surface }).catch(() => ({ isCopied: false as const, reason: 'refused' as const }))
415 $.ui.toast(copied.isCopied ? `session-info: copied ${tool}` : `session-info: could not copy the rule (${copied.reason}).`)
416}
417
418/** The issue as the model's own getJiraIssue call answered it: no call of the mod's, so no permission to ask. */
419async function takeIssue($: EngineInterface, key: string, text: string, cloudId: unknown, setting: unknown): Promise<void> {
420 const at = Date.now()
421 const site = siteSetting(setting)?.url ?? jiraSite?.url ?? (typeof cloudId === 'string' ? siteSetting(cloudId)?.url : undefined)
422 const url = browseUrl(site, key)
423 await update($, jiraIssues, list => {
424 const [top, ...rest] = mention(list ?? [], key, at)
425 const issue = readIssue(top ?? { key, at }, { content: [{ type: 'text', text }] }, at)
426
427 return [{ ...issue, ...(url === undefined ? {} : { url }) }, ...rest]
428 })
429}
430
431/** Puts the mentioned keys on top of the Jira section and reads the ones not read in the last minutes. */
432async function noteIssues($: EngineInterface, keys: readonly string[], setting: unknown): Promise<void> {
433 if (keys.length === 0) return
434 const at = Date.now()
435 const before = await read($, jiraIssues)
436 await update($, jiraIssues, list => [...keys].reverse().reduce((all, key) => mention(all, key, at), list ?? []))
437 for (const key of keys) if (needsRead(before.find(issue => issue.key === key), at)) await readJira($, key, setting)
438}
439
440/** The reads run on after the hook that saw the keys returns, so a slow server never holds the prompt. */
441function noteText($: EngineInterface, text: string, pattern: RegExp | undefined, setting: unknown): void {
442 void noteIssues($, issueKeys(text, pattern), setting).catch(() => undefined)
443}
444
445/** Reads the autopick settings from the store, which every window shares, into the pane's mirror. */
446async function loadAutopick($: EngineInterface): Promise<AutopickSettings> {
447 const settings = settingsOf(await $.store.get(STORE_ON), await $.store.get(STORE_MINUTES))
448 const shown = await read($, autopick)
449 if (shown.isOn !== settings.isOn || shown.minutes !== settings.minutes) await update($, autopick, () => settings)
450
451 return settings
452}
453
454/** Whether ask-autopick is loaded; it answers the same questions, so autopick here stands aside while it is. */
455async function checkRival($: EngineInterface): Promise<boolean> {
456 const isLoaded = hasRival(await $.command.list())
457 if ((await read($, autopickConflict)) !== isLoaded) await update($, autopickConflict, () => isLoaded)
458
459 return isLoaded
460}
461
462/** Turning autopick off stops every countdown that runs: those questions wait for the person. */
463async function saveAutopick($: EngineInterface, live: Countdowns, change: Partial<AutopickSettings>): Promise<AutopickSettings> {
464 const settings = { ...(await loadAutopick($)), ...change }
465 await $.store.set(STORE_ON, settings.isOn)
466 await $.store.set(STORE_MINUTES, settings.minutes)
467 await update($, autopick, () => settings)
468 if (!settings.isOn) for (const control of [...live.controls.values()]) control.cancel()
469
470 return settings
471}
472
473async function switchAutopick($: EngineInterface, live: Countdowns): Promise<void> {
474 const isOn = !(await loadAutopick($)).isOn
475 if (isOn && (await checkRival($))) return void $.ui.toast('session-info: ask-autopick is loaded and answers questions too; disable it first.')
476 await saveAutopick($, live, { isOn })
477}
478
479/** `/autopick`, `/autopick on | off` and `/autopick <minutes>`. */
480async function runAutopick($: EngineInterface, live: Countdowns, args: string): Promise<{ text: string }> {
481 const arg = args.trim().toLowerCase()
482 const isBlocked = await checkRival($)
483 let settings = await loadAutopick($)
484 if (arg === 'on' || arg === 'off') {
485 if (arg === 'off' || !isBlocked) settings = await saveAutopick($, live, { isOn: arg === 'on' })
486 } else if (arg !== '') {
487 const minutes = minutesOf(arg)
488 if (minutes === undefined) return { text: AUTOPICK_USAGE }
489 settings = await saveAutopick($, live, { minutes })
490 }
491
492 return { text: statusText(settings, isBlocked) }
493}
494
495/** Redraws the pane each second while a countdown runs. */
496function syncTicker($: EngineInterface, live: Countdowns): void {
497 if (live.controls.size === 0) {
498 live.ticker?.cancel()
499 live.ticker = undefined
500 } else live.ticker ??= $.clock.every(1_000, () => $.ui.invalidate('ui.render'))
501}
502
503const dropWait = ($: EngineInterface, id: string) => update($, autopickWaits, list => (list ?? []).filter(wait => wait.id !== id))
504
505/**
506 * An AskUserQuestion call while autopick is on: the dialog opens as usual, and when the wait runs out (or the person
507 * presses Pick now) the hook answers the call with the recommended options in the person's place. The engine then
508 * closes the dialog and settles the abandoned `next(e)` as rejected, which the hook absorbs.
509 */
510async function answerOrPick($: EngineInterface, live: Countdowns, input: { tool_use_id?: string }, questions: readonly Question[], pending: Promise<ToolCallResult>): Promise<ToolCallResult> {
511 const settings = await read($, autopick)
512 const picks = picksOf(questions)
513 const at = await $.clock.now()
514 const id = input.tool_use_id ?? `ask-${at}`
515 const ms = settings.minutes * MINUTE
516 await update($, autopickWaits, list => [...(list ?? []), { id, picks: picks ?? null, at, ...(picks ? { deadline: at + ms } : {}) }])
517 if (picks === undefined) {
518 try {
519 return await pending
520 } finally {
521 await dropWait($, id)
522 }
523 }
524 let fire: (isByHand: boolean) => void = () => undefined
525 const fired = new Promise<boolean>(resolve => {
526 fire = resolve
527 })
528 const timer = $.clock.after(ms, () => fire(false))
529 live.controls.set(id, {
530 pick: () => {
531 timer.cancel()
532 fire(true)
533 },
534 cancel: () => {
535 timer.cancel()
536 live.controls.delete(id)
537 syncTicker($, live)
538 // The wait keeps its row without a deadline: the question is the person's again.
539 void update($, autopickWaits, list => (list ?? []).map(wait => (wait.id === id ? { id, picks: wait.picks, at: wait.at } : wait)))
540 },
541 })
542 syncTicker($, live)
543 try {
544 const first = await Promise.race([pending, fired])
545 if (typeof first !== 'boolean') return first
546 // Turned off in another window during the wait: the question stays for the person.
547 if (!first && !(await loadAutopick($)).isOn) {
548 live.controls.get(id)?.cancel()
549
550 return await pending
551 }
552 pending.catch(() => undefined)
553 const pick: AutopickPick = { picks, at: await $.clock.now(), minutes: settings.minutes, isByHand: first }
554 await update($, autopickLast, () => pick)
555 $.ui.log(pickedLog(pick))
556
557 return { result: { questions, answers: picks, annotations: {} }, context: [pickedNote(pick)] } as never
558 } finally {
559 timer.cancel()
560 live.controls.delete(id)
561 syncTicker($, live)
562 await dropWait($, id)
563 }
564}
565
566type OpenPaneArgs = { title: string; isGitShown: boolean; isSessionShown: boolean }
567
568async function openPane($: EngineInterface, { title, isGitShown, isSessionShown }: OpenPaneArgs): Promise<{ text: string }> {
569 if (isGitShown) void refreshRepo($)
570 if (isSessionShown) void refreshContext($).catch(() => undefined)
571 await $.ui.open({ id: PANE, title, focus: true })
572
573 return { text: 'Session info pane opened.' }
574}
575
576export const register: Register = (on, options) => {
577 const prefs: Prefs = {
578 viewer: (options.viewer as Viewer | undefined) ?? 'auto',
579 editor: (options.editor as Editor | undefined) ?? 'auto',
580 }
581 const icons = (options.icons as IconSet | undefined) ?? 'unicode'
582 const shows = {
583 session: options.showSession !== false,
584 turn: options.showLastTurn !== false,
585 todos: options.showTodos !== false,
586 skills: options.showSkills !== false,
587 ci: options.showCi !== false,
588 usage: options.showUsage !== false,
589 agents: options.showAgents !== false,
590 background: options.showBackground !== false,
591 checks: options.showChecks !== false,
592 git: options.showGit !== false,
593 changes: options.showChanges !== false,
594 jira: options.showJira !== false,
595 autopick: options.showAutopick !== false,
596 }
597 const live: Countdowns = { controls: new Map() }
598 const keyPattern = toKeyPattern(options.jiraKeyPattern)
599 const gitClient = typeof options.gitClient === 'string' ? options.gitClient.trim() : 'lazygit'
600 const paneBackground = typeof options.paneBackground === 'string' ? options.paneBackground.trim() : ''
601 const paneTitle = customTitle(options.paneTitle)
602 const paneTitleColor = titleColor(options.paneTitleColor)
603 const paneTitleAlign = titleAlign(options.paneTitleAlign)
604 const openArgs: OpenPaneArgs = { title: paneTitle || DEFAULT_TITLE, isGitShown: shows.git, isSessionShown: shows.session }
605 const patterns: Patterns = { build: toPattern(options.buildPattern), test: toPattern(options.testPattern) }
606 const isAlerting = options.alerts !== false
607 const isSuggestingFix = shows.checks && options.suggestFix !== false
608 const order = parseSectionOrder(options.sectionOrder)
609 const groups = parseTabGroups(options.tabGroups)
610 const groupOf = new Map(groups.flatMap(group => group.members.map(key => [key, group] as const)))
611 /** A grouped section folds with its group, whichever members show now. */
612 const foldOf = (key: SectionKey): string => groupOf.get(key)?.key ?? key
613 const isLive = shows.session || shows.usage || shows.agents || shows.checks || shows.background
614 const isOpenOnStart = options.openOnStart === true
615
616 /** The pane's layout mode toggles the sections, so /config leaves out their thirteen show* rows. */
617 on('config.describe', { key: ORDER.map(key => `session-info.${showOption(key)}`) }, async ($, e, next) => ({ ...(await next(e)), isHidden: true }))
618
619 on('session.start', async ($, e, next) => {
620 clearCount = 0
621 await $.command.register({
622 name: 'session-info',
623 description: 'Show the session, plan usage and the files this session created, modified or deleted',
624 })
625 await $.command.register({ name: 'changes', description: 'Same as /session-info' })
626 if (shows.todos) {
627 await $.tool.register(TODO_SPEC).catch(error => $.ui.toast(`session-info: ${String(error)}`))
628 await restoreTodos($).catch(() => undefined)
629 }
630 if (isLive || shows.git)
631 $.clock.every(MINUTE, () => {
632 if (isLive) void update($, now, () => Date.now())
633 // The write alone redraws nothing after a /clear or a resume.
634 if (isLive) $.ui.invalidate('ui.render')
635 if (shows.session) void readMissingContext($)
636 if (shows.git) void refreshRepo($)
637 })
638 if (shows.git) void refreshRepo($)
639 if (shows.session) void refreshContext($).catch(() => undefined)
640 void seedTurns($).catch(() => undefined)
641 if (shows.ci) {
642 $.clock.every(CI_POLL_MS, () => void pollCi($).catch(() => undefined))
643 // A reload during a run picks the watch up again from the state.
644 void pollCi($).catch(() => undefined)
645 }
646 if (shows.jira) void findJiraServer($).catch(() => undefined)
647 if (shows.autopick) {
648 await $.command.register({
649 name: 'autopick',
650 description: 'Pick the recommended option of a question left unanswered: status, on, off, <minutes>',
651 argumentHint: '[on | off | <minutes>]',
652 immediate: true,
653 })
654 await loadAutopick($).catch(() => undefined)
655 // The other plugins register their commands in their own session.start: the check waits for them.
656 $.clock.after(5_000, () => void checkRival($).catch(() => undefined))
657 }
658 // The engine draws a pane that the person did not ask for only on a wide terminal; a narrow one keeps it until /session-info.
659 if (isOpenOnStart && e.isInteractive) await $.ui.open({ id: PANE, title: openArgs.title })
660
661 return next(e)
662 })
663
664 // /clear starts a new conversation in the same process and raises no session.start: what belonged to the old one goes here.
665 on('classic.SessionStart', async ($, e, next) => {
666 const started = await next(e)
667 if (e.source === 'clear') {
668 draft = undefined
669 if (shows.todos) await update($, todos, () => [])
670 if (shows.turn) await update($, lastTurn, () => null)
671 if (shows.skills) await update($, skills, () => [])
672 if (shows.jira) await update($, jiraIssues, () => [])
673 if (shows.agents) await update($, agentModels, () => ({}))
674 if (shows.background) await update($, background, () => [])
675 await update($, detail, () => null)
676 if (shows.autopick) await update($, autopickLast, () => null)
677 }
678
679 return started
680 })
681
682 // A /clear ends the session as session.end with reason clear; the module and its count go on.
683 on('session.end', async ($, e, next) => {
684 if (e.reason === 'clear') {
685 clearCount += 1
686 // The count is no state value: nothing else draws it.
687 if (shows.session) $.ui.invalidate('ui.render')
688 }
689 // The next conversation's state starts empty and a draw may not write: what session.start loads is loaded here.
690 if (e.reason === 'clear' || e.reason === 'resume') {
691 const isResume = e.reason === 'resume'
692 afterSwitch($, e.sessionId, async () => {
693 if (shows.todos && isResume) await restoreTodos($).catch(() => undefined)
694 if (isResume) await seedTurns($).catch(() => undefined)
695 if (shows.git) void refreshRepo($)
696 if (shows.jira) await findJiraServer($).catch(() => undefined)
697 if (shows.autopick) {
698 await loadAutopick($).catch(() => undefined)
699 await checkRival($).catch(() => undefined)
700 }
701 // A CI watch belongs to the conversation that pushed.
702 if (shows.ci) await update($, ciState, () => null)
703 if (shows.session) await refreshContext($).catch(() => undefined)
704 $.ui.invalidate('ui.render')
705 })
706 }
707
708 return next(e)
709 })
710
711 on('command.run', { command: 'session-info' }, async $ => openPane($, openArgs))
712
713 on('command.run', { command: 'changes' }, async $ => openPane($, openArgs))
714
715 if (shows.autopick) on('command.run', { command: 'autopick' }, async ($, e) => runAutopick($, live, String(e.args ?? '')))
716
717 // A commit made outside the session, in the Git client for example, shows by the next prompt.
718 on('prompt.submit', async ($, e, next) => {
719 if (shows.git) void refreshRepo($)
720 if (fix) fix.isOpen = false
721 // A background command's end reaches the session as a notification prompt, the one place its exit status shows.
722 if ((shows.checks || shows.background) && e.origin?.kind === 'task-notification')
723 void finishTasks($, e.text, { isBackground: shows.background, isChecks: shows.checks }).catch(error => $.ui.toast(`session-info: ${String(error)}`))
724 if (shows.jira) {
725 // MCP servers connect after the session starts: each prompt looks again until one serves Jira.
726 void findJiraServer($).catch(() => undefined)
727 noteText($, e.text, keyPattern, options.jiraSite)
728 }
729
730 return next(e)
731 })
732
733 if (shows.todos) {
734 const instruction = todoPrompt(minSteps(options.todoMinSteps), options.todoInstruction)
735 // The section rides after the engine's own, on the session side of the cache boundary.
736 on('prompt.compose', async ($, e, next) => {
737 const composed = await next(e)
738 if (!e.tools.includes(TODO_TOOL)) return composed
739
740 return { sections: [...composed.sections, { id: 'session-info:todos', text: instruction, scope: 'session' }] }
741 })
742
743 on('tool.call', { tool: TODO_TOOL }, async ($, e) => {
744 if (e.agentId !== undefined) return { result: 'Ignored: only the main conversation keeps the task list.' }
745 // This hook answers the call itself, so the general tool.call hook never counts it for the Last turn section.
746 if (shows.turn && draft !== undefined) draft = withTool(draft, e.tool)
747 const parsed = parseTodos(e as Record<string, unknown>)
748 if (typeof parsed === 'string') return { deny: parsed }
749 await update($, todos, () => parsed)
750
751 return { result: todoReply(parsed) }
752 })
753 }
754
755 if (shows.skills) {
756 // The Skill tool's call raises skill.prompt while it runs; a slash command raises it alone, in the main conversation.
757 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
758 const input = e as Record<string, unknown>
759 const name = typeof input.skill === 'string' ? input.skill : ''
760 const pending = { name, ...(e.agentId === undefined ? {} : { agentId: e.agentId }), isSeen: false }
761 pendingSkills.push(pending)
762 try {
763 const ran = await next(e)
764 if (!pending.isSeen && name !== '' && ran.deny === undefined && !ran.isError)
765 await update($, skills, list => startSkill(list ?? [], name, e.agentId, Date.now()))
766
767 return ran
768 } finally {
769 pendingSkills.splice(pendingSkills.indexOf(pending), 1)
770 }
771 })
772
773 on('skill.prompt', async ($, e, next) => {
774 const prompted = await next(e)
775 const pending = pendingSkills.find(one => !one.isSeen && isSameSkill(one.name, e.skill))
776 if (pending) pending.isSeen = true
777 await update($, skills, list => startSkill(list ?? [], e.skill, pending?.agentId, Date.now()))
778
779 return prompted
780 })
781 }
782
783 if (shows.turn) {
784 on('turn.start', async ($, e, next) => {
785 const usage = await $.session.usage().catch(() => undefined)
786 draft = startDraft(e.turnId, usage?.cost?.usd)
787
788 return next(e)
789 })
790
791 // Only reads the verdict: a query (no tool_use_id) and a subagent's call leave the main turn's record alone.
792 on('tool.check', async ($, e, next) => {
793 const verdict = await next(e)
794 if (verdict.decision === 'deny' && e.agentId === undefined && e.tool_use_id !== undefined && draft !== undefined)
795 draft = withRefusal(draft, e.tool, verdict)
796
797 return verdict
798 }).catch(($, e, next) => next(e))
799 }
800
801 if (shows.turn || shows.agents) {
802 // The step only notes the request; it leaves the stream to the hooks beneath it.
803 on('turn.step', async function* ($, e, next) {
804 if (shows.turn && e.agentId === undefined && draft !== undefined) draft = withStep(draft, e)
805 if (shows.agents && e.agentId !== undefined) await noteAgentModel($, e.agentId, e.model, e.effort)
806
807 return yield* next(e)
808 })
809 }
810
811 on('session.measure', async ($, e, next) => {
812 if (isLive) await update($, now, () => Date.now())
813 if (isAlerting) await alert($, e.rateLimits, e.context)
814
815 return next(e)
816 })
817
818 on('turn.complete', async ($, e, next) => {
819 const answered = await next(e)
820 if (e.agentId !== undefined && shows.agents) await update($, now, () => Date.now())
821 if (e.agentId === undefined && shows.session) void refreshContext($).catch(() => undefined)
822 if (e.agentId === undefined)
823 await update($, turns, stats => ({
824 count: (stats ?? NO_TURNS).count + 1,
825 outputTokens: (stats ?? NO_TURNS).outputTokens + (e.usage?.output_tokens ?? 0),
826 lastMs: e.durationMs,
827 }))
828 if (shows.turn && e.agentId !== undefined && draft !== undefined) draft = withSubagentUsage(draft, e.usage)
829 if (shows.skills) await update($, skills, list => endSkills(list ?? [], e.agentId))
830 if (shows.jira && e.agentId === undefined) noteText($, e.answer, keyPattern, options.jiraSite)
831 if (shows.turn && e.agentId === undefined) {
832 const own = draft
833 draft = undefined
834 const usage = await $.session.usage().catch(() => undefined)
835 await update($, lastTurn, () => toRecord(own, e, usage?.cost?.usd, Date.now()))
836 }
837 if (isSuggestingFix && e.agentId === undefined) await suggestFix($).catch(() => undefined)
838
839 return answered
840 })
841
842 if (isSuggestingFix)
843 // The engine's own guess can come after the fix: the fix stays the suggestion until the next prompt.
844 on('prompt.suggest', async ($, e, next) => (fix?.isOpen && e.origin.kind === 'suggestion' ? next({ ...e, text: fix.text }) : next(e)))
845
846 on('agent.spawn', async ($, e, next) => {
847 const spawned = await next(e)
848 if (shows.turn && draft !== undefined) draft = withSpawn(draft)
849 if (shows.agents && spawned.agentId !== undefined) await noteAgentModel($, spawned.agentId, spawned.model)
850 if (shows.agents) await update($, now, () => Date.now())
851
852 return spawned
853 })
854
855 on('session.compact', async ($, e, next) => {
856 const compacted = await next(e)
857 if (e.agentId === undefined && e.trigger !== 'precompute' && 'messages' in compacted) {
858 await update($, compactions, count => (count ?? 0) + 1)
859 if (shows.session) void refreshContext($).catch(() => undefined)
860 }
861
862 return compacted
863 })
864
865 on('tool.call', async ($, e, next) => {
866 if (shows.turn && e.agentId === undefined && draft !== undefined) draft = withTool(draft, e.tool)
867 if (e.tool === 'AskUserQuestion') return next(e)
868 const isShell = (SHELLS as readonly string[]).includes(e.tool)
869 const isChecked = shows.checks && isShell
870 const isBackgrounded = shows.background && isShell
871 const isRepoTool = shows.git && FILE_TOOLS.includes(e.tool)
872 const isCiTool = shows.ci && isShell
873 // The model's own read of an issue names it as clearly as a mention, and its answer is the read the section needs.
874 const input = e as Record<string, unknown>
875 const asked = typeof input.issueIdOrKey === 'string' ? input.issueIdOrKey.toUpperCase() : ''
876 const isJiraRead = shows.jira && serverOf(e.tool) !== undefined && isIssueKey(asked)
877 if (!shows.changes && !isChecked && !isBackgrounded && !isRepoTool && !isCiTool && !isJiraRead) return next(e)
878 const before = shows.changes && isShell ? await gitStatus(hostOf($)).catch(() => undefined) : undefined
879 const ran = await next(e)
880 const toast = (error: unknown) => $.ui.toast(`session-info: ${String(error)}`)
881 if (shows.changes && ran.deny === undefined && !ran.isError) await track(hostOf($), e.tool, ran.result, before).catch(toast)
882 if (isChecked) await inspect($, e as Record<string, unknown>, ran as Record<string, unknown>, patterns).catch(toast)
883 if (isBackgrounded) {
884 const started = startedTask(input, ran as Record<string, unknown>, Date.now())
885 if (started !== undefined) await update($, background, tasks => withTask(tasks ?? [], started)).catch(toast)
886 }
887 if (isRepoTool && ran.deny === undefined) void refreshRepo($)
888 if (isCiTool && ran.deny === undefined && !ran.isError) {
889 const command = typeof (e as Record<string, unknown>).command === 'string' ? String((e as Record<string, unknown>).command) : ''
890 void watchCi($, command, typeof ran.text === 'string' ? ran.text : '').catch(toast)
891 }
892 if (isJiraRead && ran.deny === undefined && !ran.isError && typeof ran.text === 'string')
893 await takeIssue($, asked, ran.text, input.cloudId, options.jiraSite).catch(toast)
894
895 return ran
896 }).catch(($, e, next) => next(e))
897
898 if (shows.autopick) {
899 // Registered after the general tool.call hook so it sits beneath it: the abandoned dialog's rejection stays here.
900 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
901 const settings = await loadAutopick($)
902 if (!settings.isOn || (await checkRival($))) return next(e)
903 const questions = (e as { questions?: unknown }).questions
904
905 return answerOrPick($, live, e, Array.isArray(questions) ? (questions as Question[]) : [], next(e))
906 }).catch(($, e, next) => next(e))
907 }
908
909 /** The trees that leave rows out, as the last draw placed them in the body; none while every tree shows whole. */
910 let scrollTargets: ScrollTarget[] = []
911
912 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
913 // The event carries the offset the engine scrolled the body to: a row of the body is that many rows further down the tree.
914 const treeRow = e.pointer === undefined ? undefined : e.pointer.row + e.offset
915 // A body taller than its window scrolls as a whole under the engine; the wheel goes to a list only over it, and the keys then stay with the engine.
916 const isClipped = e.contentRows > e.bodyRows
917 const target = isClipped ? (treeRow === undefined ? undefined : targetNear(scrollTargets, treeRow, POINTER_SLACK)) : targetAt(scrollTargets, treeRow)
918 if (target === undefined) return next(e)
919 const from = target.key === 'git' ? await read($, gitTop) : await read($, changesTop)
920 const to = movedTo(from, e.by, target)
921 // A list at its end hands the wheel to the pane, so the sections past it stay reachable.
922 if (isClipped && to === from) return next(e)
923 if (target.key === 'git') await update($, gitTop, () => to)
924 else await update($, changesTop, () => to)
925
926 return {}
927 }).catch(($, e, next) => next(e))
928
929 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
930 const { Box, Text, Button, Code } = $.ui.resolve(e)
931 const isTerminal = e.surface === 'terminal'
932 const { card, tiles, banner } = widgets({ Box, Text }, isTerminal)
933 const cols = Math.max(20, e.props.bodyColumns)
934 const isCards = !isTerminal || isFramed(cols)
935 const shownDetail = await read($, detail)
936 if (shownDetail !== null) {
937 // No list is on screen: the wheel and the keys belong to the pane.
938 scrollTargets = []
939 const body = (
940 <Box flexDirection="column" gap={1}>
941 <Text bold wrap="wrap">{shownDetail.title}</Text>
942 <Text wrap="wrap">{shownDetail.text}</Text>
943 </Box>
944 )
945
946 return (
947 <Box flexDirection="column" gap={1}>
948 <Box flexDirection="row" gap={2}>
949 <Button plain key="detail-back" hotkey="b" onPress={() => void update($, detail, () => null)}>
950 <Text bold color="claude">← Back</Text>
951 </Button>
952 <Button
953 plain
954 key="detail-copy"
955 hotkey="y"
956 onPress={() =>
957 void $.ui.copy({ text: shownDetail.text, surface: e.surface })
958 .then(copied => $.ui.toast(copied.isCopied ? 'session-info: copied the text' : `session-info: could not copy the text (${copied.reason}).`))
959 .catch(() => $.ui.toast('session-info: could not copy the text.'))
960 }
961 >
962 <Text color="suggestion">Copy</Text>
963 </Button>
964 </Box>
965 {isCards ? card('detail', true, body) : body}
966 </Box>
967 )
968 }
969 const inner = isCards ? cols - FRAME_COLUMNS : cols
970 const chrome = isCards ? 1 + FRAME_ROWS : 2
971 const shut = new Set(await read($, collapsed))
972 const isOpen = (key: SectionKey): boolean => !shut.has(foldOf(key))
973 const issueList = shows.jira ? await read($, jiraIssues) : []
974 // The section waits for an Atlassian MCP server: without one it has nothing to read.
975 const isJiraShown = shows.jira && (issueList.length > 0 || (await read($, jiraServer)) !== null)
976 const present: Record<SectionKey, boolean> = {
977 '#session': shows.session,
978 '#turn': shows.turn,
979 '#todos': shows.todos,
980 '#autopick': shows.autopick,
981 '#skills': shows.skills,
982 '#ci': shows.ci,
983 '#jira': isJiraShown,
984 '#usage': shows.usage,
985 '#agents': shows.agents,
986 '#background': shows.background,
987 '#checks': shows.checks,
988 '#git': shows.git,
989 '#changes': shows.changes,
990 }
991 const activeTabs = groups.length > 0 ? await read($, tabs) : {}
992 const membersOf = (group: TabGroup): SectionKey[] => group.members.filter(key => present[key])
993 /** The stored tab while it still shows, else the first member that does. */
994 const activeOf = (group: TabGroup): SectionKey | undefined => {
995 const members = membersOf(group)
996
997 return members.find(key => key === activeTabs[group.key]) ?? members[0]
998 }
999 /** Whether the section draws now: on, and the active tab when it is in a group. */
1000 const counts = (key: SectionKey): boolean => {
1001 const group = groupOf.get(key)
1002
1003 return present[key] && (group === undefined || activeOf(group) === key)
1004 }
1005 /** The sections to draw, in order; a group sits where its first shown member sits, so a tab switch does not move the card. */
1006 const slots = order.flatMap((key): SectionKey[] => {
1007 const group = groupOf.get(key)
1008 if (group === undefined) return present[key] ? [key] : []
1009 const active = activeOf(group)
1010
1011 return active !== undefined && order.find(one => groupOf.get(one) === group && present[one]) === key ? [active] : []
1012 })
1013 await read($, now)
1014 const isDetailed = shows.session && isOpen('#session') && (await read($, breakdown))
1015 const usage = isLive ? await $.session.usage() : undefined
1016 const reading = shows.session ? await read($, contextState) : null
1017 const limits: Limit[] = shows.usage && usage ? ordered(usage.rateLimits) : []
1018 const stats = shows.session || shows.turn ? await read($, turns) : NO_TURNS
1019 const compacted = shows.session ? await read($, compactions) : 0
1020 const turnRecord = shows.turn ? await read($, lastTurn) : null
1021 const taskList = shows.todos ? await read($, todos) : []
1022 const skillList = shows.skills ? await read($, skills) : []
1023 const pickSettings = shows.autopick ? await read($, autopick) : DEFAULT_SETTINGS
1024 const waits = shows.autopick ? await read($, autopickWaits) : []
1025 const lastPick = shows.autopick ? await read($, autopickLast) : null
1026 const isPickBlocked = shows.autopick && (await read($, autopickConflict))
1027 // The countdowns and the last pick's age run on the engine's clock, the one their times were set by.
1028 const clockNow = waits.length > 0 || lastPick ? await $.clock.now() : 0
1029 const ciWatch = shows.ci ? await read($, ciState) : null
1030 const ciRuns = ciWatch?.runs.slice(0, CI_ROOM) ?? []
1031 const issues = issueList.slice(0, JIRA_ROOM)
1032 // The settings are read only while a refusal is on screen: the buttons go once the rule is in place.
1033 const permissions = issues.some(issue => issue.blockedTool !== undefined) ? await $.settings.read() : undefined
1034 const needsAllow = (issue: JiraIssue): issue is JiraIssue & { blockedTool: string } =>
1035 issue.blockedTool !== undefined && !isAllowed(permissions, issue.blockedTool)
1036 const agents = shows.agents ? await $.agent.list() : []
1037 const loops = shows.agents || !skillList.some(use => use.agentId !== undefined) ? agents : await $.agent.list()
1038 const changes = shows.changes ? Object.values(await read($, files)) : []
1039 const diff = shows.changes ? await read($, inline) : null
1040 const rows = flatten(buildTree(await $.session.cwd(), changes), shut)
1041 const repo = shows.git ? await read($, repoState) : null
1042 const isRepo = repo !== null && repo !== 'outside'
1043 // A repository read before the files were kept has none until the next read.
1044 const repoFiles = isRepo ? (repo.files ?? []) : []
1045 const gitShut = new Set([...shut].filter(isGitFold).map(key => key.slice(GIT_FOLD.length)))
1046 const gitTree = isRepo ? flatten(buildTree(repo.root, repoFiles), gitShut) : []
1047 // Before the first reply the usage has no context figures; the /context reading has them already.
1048 const context =
1049 usage?.context.tokens === undefined && reading
1050 ? { tokens: reading.total, window: reading.window, percent: (reading.total / reading.window) * 100 }
1051 : usage?.context
1052 const figures: Tile[] =
1053 isCards && shows.session && usage !== undefined
1054 ? [
1055 ...(usage.cost === undefined ? [] : [{ key: 'cost', value: cost(usage.cost.usd), label: 'cost' }]),
1056 { key: 'elapsed', value: duration(Date.now() - usage.startedAt), label: 'elapsed' },
1057 ]
1058 : []
1059 // The rows above the first section: the title line and the figure tiles.
1060 const tileRows = (paneTitle !== '' ? BANNER_ROWS : 0) + tileLayout(figures.length, cols).rows
1061 const turnRows = counts('#turn') ? chrome + (isOpen('#turn') ? (turnRecord ? turnLines(turnRecord).length : 1) : 0) : 0
1062 const skillRoom = Math.min(skillList.length, SKILL_ROOM)
1063 const skillsRows = counts('#skills') ? chrome + (isOpen('#skills') ? Math.max(1, skillRoom + (skillList.length > skillRoom ? 1 : 0)) : 0) : 0
1064 const ciBody = ciRuns.reduce((sum, run) => sum + 1 + (run.jobs.length > 0 ? 1 : 0) + (run.url === '' ? 0 : 1), 0)
1065 const ciRows = counts('#ci') ? chrome + (isOpen('#ci') ? Math.max(1, ciBody + (ciWatch?.error === undefined ? 0 : 1)) : 0) : 0
1066 const jiraRows = counts('#jira') ? chrome + (isOpen('#jira') ? Math.max(1, issues.length * 2 + issues.filter(needsAllow).length) : 0) : 0
1067 const todoRoom = Math.min(taskList.length, TODO_ROOM)
1068 const todosRows = counts('#todos') ? chrome + (isOpen('#todos') ? Math.max(1, todoRoom + (taskList.length > todoRoom ? 1 : 0)) : 0) : 0
1069 const autopickBody = 1 + (isPickBlocked ? 1 : 0) + waits.length + (lastPick ? 1 : 0)
1070 const autopickRows = counts('#autopick') ? chrome + (isOpen('#autopick') ? autopickBody : 0) : 0
1071 const usageRows = counts('#usage') ? chrome + (isOpen('#usage') ? Math.max(1, limits.length * 2) : 0) : 0
1072 const legend = isDetailed && reading ? legendLines(reading, inner) : []
1073 const sessionRows = counts('#session') ? chrome + (isOpen('#session') ? 3 + legend.length : 0) : 0
1074 const agentLines = agentRows(agents)
1075 const models = shows.agents ? await read($, agentModels) : {}
1076 const agentRoom = Math.min(agentLines.length, 8)
1077 const agentsRows = counts('#agents') ? chrome + (isOpen('#agents') ? agentRoom + (agentLines.length > agentRoom ? 1 : 0) : 0) : 0
1078 const backgroundList = shows.background ? orderedTasks(await read($, background)) : []
1079 const backgroundShown = backgroundList.slice(0, BACKGROUND_ROOM)
1080 const backgroundRows = counts('#background')
1081 ? chrome + (isOpen('#background') ? Math.max(1, taskRowCount(backgroundShown) + (backgroundList.length > backgroundShown.length ? 1 : 0)) : 0)
1082 : 0
1083 const lastChecks = shows.checks ? await read($, checks) : NO_CHECKS
1084 const views = checkViews(lastChecks)
1085 const checksRows = counts('#checks') ? chrome + (isOpen('#checks') ? Math.max(1, checkRowCount(views)) : 0) : 0
1086 const isChangesTree = counts('#changes') && isOpen('#changes')
1087 const isGitTree = counts('#git') && isOpen('#git') && gitTree.length > 0
1088 // The room below holds one tree's buttons, hint and overflow row; a second tree adds its own 4.
1089 const gitExtra = isGitTree && isChangesTree ? 4 : 0
1090 const gitRows = counts('#git') ? chrome + (isOpen('#git') ? repoRowCount(repo) + gitExtra : 0) : 0
1091 const changesFrame = counts('#changes') && isCards ? FRAME_ROWS : 0
1092 const isEditing = await read($, layout)
1093 /** The sections their show* option turned off: the footer offers them back. */
1094 const hidden = order.filter(key => options[showOption(key)] === false)
1095 // Layout mode adds the Sections checklist above the footer: its title and the toggles, wrapped on about two lines.
1096 const footerRows = isEditing ? 4 : 1
1097 const above = tileRows + usageRows + sessionRows + turnRows + todosRows + autopickRows + skillsRows + ciRows + jiraRows + agentsRows + backgroundRows + checksRows + gitRows + changesFrame + footerRows
1098 // The terminal's height is a guess at what the pane may take; the window Claude Code gives the body is the limit, and a tree taller than it is clipped.
1099 const bodyRows = e.props.scroll?.bodyRows ?? Number.MAX_SAFE_INTEGER
1100 const room = isTerminal ? Math.max(MIN_LIST_ROWS, Math.min((e.viewport?.rows ?? 30) - above - (diff ? 16 : 7), bodyRows - above - (diff ? 16 : 7))) : Number.MAX_SAFE_INTEGER
1101 // Two trees on screen split the room: the Git tree takes up to half, the Changes tree the rest.
1102 const gitRoom = isChangesTree ? Math.min(gitTree.length, Math.max(MIN_LIST_ROWS, Math.floor(room / 2))) : room
1103 const changesRoom = isGitTree ? Math.max(MIN_LIST_ROWS, room - gitRoom) : room
1104 const changesFrom = clampOffset(await read($, changesTop), rows.length, changesRoom)
1105 const gitFrom = clampOffset(await read($, gitTop), gitTree.length, gitRoom)
1106 // Without the Changes tree on screen, its hotkeys are free for the Git tree.
1107 const gitView: ScrollView = { id: 'git', total: gitTree.length, from: gitFrom, room: gitRoom, jump: to => void update($, gitTop, () => to), isKeyed: !counts('#changes') }
1108 const changesView: ScrollView = { id: 'changes', total: rows.length, from: changesFrom, room: changesRoom, jump: to => void update($, changesTop, () => to), isKeyed: true }
1109 // Where each list starts in the body, from the rows the sections above are known to take: the frame's top border, the header and the rows before the list.
1110 const listTop = (isCards ? 1 : 0) + 1
1111 const fixedRows: Partial<Record<SectionKey, number>> = {
1112 '#session': sessionRows,
1113 '#turn': turnRows,
1114 '#todos': todosRows,
1115 '#autopick': autopickRows,
1116 '#skills': skillsRows,
1117 '#ci': ciRows,
1118 '#jira': jiraRows,
1119 '#usage': usageRows,
1120 '#agents': agentsRows,
1121 '#background': backgroundRows,
1122 '#checks': checksRows,
1123 }
1124 const gitShown = Math.min(gitTree.length, gitRoom)
1125 const changesShown = Math.min(rows.length, changesRoom)
1126 const targets: ScrollTarget[] = []
1127 let sectionTop = tileRows
1128 for (const key of slots) {
1129 if (key === '#git' && isGitTree) targets.push({ key: 'git', start: sectionTop + listTop + repoRowCount(repo), rows: gitShown, overflow: gitTree.length - gitShown })
1130 if (key === '#changes' && isChangesTree) targets.push({ key: 'changes', start: sectionTop + listTop, rows: changesShown, overflow: rows.length - changesShown })
1131 sectionTop += key === '#git' ? chrome + repoRowCount(repo) + gitShown + 4 : key === '#changes' ? chrome + changesShown + 4 : (fixedRows[key] ?? 0)
1132 }
1133 scrollTargets = targets.filter(target => target.overflow > 0)
1134
1135 const section = (key: SectionKey, children: RenderChildren) =>
1136 isCards ? card(foldOf(key), isOpen(key), children) : (
1137 <Box flexDirection="column" marginBottom={key === slots.at(-1) ? 0 : 1}>
1138 {children}
1139 </Box>
1140 )
1141
1142 /** What an inactive tab adds to its title, so the hidden section still says it has something. */
1143 const badge = (key: SectionKey): string => {
1144 if (key === '#ci') {
1145 const state = ciWatch ? watchState(ciWatch) : undefined
1146
1147 return state === undefined ? '' : ` ${CI_STATUS[state].glyph}`
1148 }
1149 const count: Partial<Record<SectionKey, number>> = {
1150 '#changes': changes.length,
1151 '#git': repoFiles.length,
1152 '#skills': skillList.length,
1153 '#jira': issueList.length,
1154 '#todos': taskList.length,
1155 '#autopick': waits.length,
1156 '#agents': agents.length,
1157 '#background': backgroundList.filter(task => task.status === 'running').length,
1158 }
1159
1160 return count[key] ? ` ${count[key]}` : ''
1161 }
1162
1163 /** A tab opens its group too, so a press on a folded group shows what was picked. */
1164 const pick = async (group: TabGroup, key: SectionKey) => {
1165 await update($, tabs, all => ({ ...all, [group.key]: key }))
1166 await update($, collapsed, list => (list ?? []).filter(one => one !== group.key))
1167 }
1168
1169 const toast = (error: unknown) => $.ui.toast(`session-info: ${String(error)}`)
1170
1171 /**
1172 * A line the row cuts short, pressed to read the whole text in the detail view; the key names the line. A Button
1173 * draws its text with the wrap of its own, not the inner Text's, so the box keeps the line to one row and clips the rest.
1174 */
1175 /** The text cut with `…` to what is left of the row once `taken` cells beside it are spent, for a line that `expandable` clips. */
1176 const fit = (text: string, taken = 0) => clip(text, inner - ROW_SPARE_COLUMNS - taken)
1177
1178 const expandable = (key: string, title: string, text: string, line: RenderChildren) =>
1179 text.trim() === '' ? line : (
1180 <Box key={`detail-row:${key}`} height={1} flexShrink={1} overflow="hidden">
1181 <Button plain key={`detail:${key}`} onPress={() => void update($, detail, () => detailOf(title, text))}>
1182 {line}
1183 </Button>
1184 </Box>
1185 )
1186
1187 /**
1188 * Changes one of the mod's settings as /config would; the engine then loads the module again with it. A load during
1189 * an autopick countdown would drop its timer, so the layout waits until no countdown runs.
1190 */
1191 const setOption = async (field: string, value: boolean | string) => {
1192 if (live.controls.size > 0) return void $.ui.toast('session-info: the layout changes after the autopick countdown ends or you cancel it.')
1193 const done = await $.config.set({ key: `session-info.${field}`, value })
1194 if (done.deny !== undefined) $.ui.toast(`session-info: ${done.deny}`)
1195 }
1196 const moveTo = (key: SectionKey, direction: 1 | -1) =>
1197 void setOption('sectionOrder', orderText(moveCard(order, groups, one => present[one], key, direction))).catch(toast)
1198
1199 /** Up, down and hide, in place of the header's right side while the pane is in layout mode. */
1200 const layoutButtons = (key: SectionKey) => {hooks/agents.ts 80 lines1import type { AgentModel } from '../types'
2
3/** One agent loop as `$.agent.list()` reports it. */
4export type Agent = {
5 id: string
6 type: string
7 description: string
8 status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
9 parentId?: string
10 name?: string
11}
12
13export type AgentRow = { agent: Agent; depth: number }
14
15export const AGENT_STATUS = {
16 running: { glyph: '⟳', color: 'suggestion' },
17 pending: { glyph: '◌', color: 'inactive' },
18 waiting: { glyph: '⏸', color: 'warning' },
19 idle: { glyph: '○', color: 'inactive' },
20 completed: { glyph: '✓', color: 'success' },
21 failed: { glyph: '✖', color: 'error' },
22 killed: { glyph: '⊘', color: 'error' },
23} as const satisfies Record<Agent['status'], { glyph: string; color: string }>
24
25const RANK: Record<Agent['status'], number> = {
26 running: 0, waiting: 1, pending: 2, idle: 3, failed: 4, killed: 5, completed: 6,
27}
28
29const isActive = (agent: Agent): boolean => RANK[agent.status] <= RANK.pending
30
31/** Active agents first, each followed by the agents it spawned, indented under it. */
32export function agentRows(agents: readonly Agent[]): AgentRow[] {
33 const ids = new Set(agents.map(agent => agent.id))
34 const byRank = [...agents].sort((a, b) => RANK[a.status] - RANK[b.status])
35 const rows: AgentRow[] = []
36 const walk = (parentId: string | undefined, depth: number): void => {
37 for (const agent of byRank) {
38 const isChild = parentId === undefined ? agent.parentId === undefined || !ids.has(agent.parentId) : agent.parentId === parentId
39 if (!isChild) continue
40 rows.push({ agent, depth })
41 walk(agent.id, depth + 1)
42 }
43 }
44 walk(undefined, 0)
45
46 return rows
47}
48
49export const agentLabel = (agent: Agent): string => agent.name ?? agent.type
50
51/** The models with this agent's entry set; the same object when nothing changed, so a step that repeats them redraws nothing. */
52export function withAgentModel(
53 models: Readonly<Record<string, AgentModel>>,
54 agentId: string,
55 model: string,
56 effort?: string | number,
57): Record<string, AgentModel> {
58 const known = models[agentId]
59 // The spawn names no effort: the effort a request already reported stays.
60 const next = effort === undefined ? (known?.model === model ? known : { model }) : { model, effort: String(effort) }
61 if (known !== undefined && known.model === next.model && known.effort === next.effort) return models
62
63 return { ...models, [agentId]: next }
64}
65
66/** ` · claude-haiku-5-5 · low`, or '' when the agent's model is unknown. */
67export const agentModelText = (run: AgentModel | undefined): string =>
68 run === undefined ? '' : [run.model, run.effort].filter(part => part !== undefined).map(part => ` · ${part}`).join('')
69
70/** `2 running · 1 idle · 3 done`, or `none`. */
71export function agentSummary(agents: readonly Agent[]): string {
72 if (agents.length === 0) return 'none'
73 const active = agents.filter(isActive).length
74 const idle = agents.filter(agent => agent.status === 'idle').length
75 const done = agents.length - active - idle
76 const parts = [[active, 'running'], [idle, 'idle'], [done, 'done']] as const
77
78 return parts.filter(([count]) => count > 0).map(([count, word]) => `${count} ${word}`).join(' · ')
79}
80hooks/alerts.ts 54 lines1import { tokens } from './session'
2import { labelOf, resetIn } from './usage'
3import type { Limit } from './usage'
4
5export const USAGE_THRESHOLDS = [80, 90] as const
6
7export const CONTEXT_THRESHOLD = 85
8
9export type Alert = { key: string; title: string; body: string }
10
11type Context = { percent?: number; tokens?: number; window: number }
12
13/** A usage alert's key names its window instance, so a window that resets can alert again. */
14const usageKey = (limit: Limit, threshold: number): string => `${limit.kind}@${limit.resetsAt ?? ''}:${threshold}`
15
16const CONTEXT_KEY = `context:${CONTEXT_THRESHOLD}`
17
18/**
19 * The thresholds this measurement crosses that have not alerted yet, and the alert keys still standing:
20 * a reset window drops its keys, a context back under the threshold drops its key.
21 */
22export function crossings(
23 limits: readonly Limit[],
24 context: Context,
25 alerted: readonly string[],
26 now: number,
27): { alerts: Alert[]; standing: string[] } {
28 const live = new Set(limits.flatMap(limit => USAGE_THRESHOLDS.map(threshold => usageKey(limit, threshold))))
29 const isContextHigh = (context.percent ?? 0) >= CONTEXT_THRESHOLD
30 const standing = alerted.filter(key => live.has(key) || (key === CONTEXT_KEY && isContextHigh))
31 const alerts: Alert[] = []
32
33 for (const limit of limits) {
34 const crossed = USAGE_THRESHOLDS.filter(threshold => limit.percentUsed >= threshold)
35 const top = crossed[crossed.length - 1]
36 if (top === undefined || crossed.every(threshold => standing.includes(usageKey(limit, threshold)))) continue
37 const reset = resetIn(limit.resetsAt, now)
38 alerts.push({
39 key: usageKey(limit, top),
40 title: 'Claude usage',
41 body: `${labelOf(limit.kind)} at ${Math.round(limit.percentUsed)}%${reset === undefined ? '' : ` · ${reset}`}`,
42 })
43 standing.push(...crossed.map(threshold => usageKey(limit, threshold)))
44 }
45
46 if (isContextHigh && !standing.includes(CONTEXT_KEY)) {
47 const size = context.tokens === undefined ? '' : ` (${tokens(context.tokens)} / ${tokens(context.window)})`
48 alerts.push({ key: CONTEXT_KEY, title: 'Claude context', body: `Context at ${Math.round(context.percent ?? 0)}%${size}: compaction is near` })
49 standing.push(CONTEXT_KEY)
50 }
51
52 return { alerts, standing }
53}
54hooks/autopick.ts 140 lines1import type { AutopickPick, AutopickSettings } from '../types'
2
3// The pick rules follow ask-autopick by Kerem Gok (MIT): https://github.com/KilimcininKorOglu/claude-code-mods/tree/main/plugins/ask-autopick
4
5/** One question of an AskUserQuestion call, as far as autopick reads it. */
6export type Question = { question: string; multiSelect?: boolean; options?: { label: string }[] }
7
8/** The `$.store` keys of the two settings. */
9export const STORE_ON = 'autopickOn'
10export const STORE_MINUTES = 'autopickMinutes'
11
12export const DEFAULT_MINUTES = 10
13
14/** The waits the − and + buttons step through; `/autopick <minutes>` takes any whole number from the first to the last. */
15export const MINUTE_STEPS: readonly number[] = [1, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120]
16
17const MIN_MINUTES = MINUTE_STEPS[0] ?? 1
18const MAX_MINUTES = MINUTE_STEPS.at(-1) ?? 120
19
20export const DEFAULT_SETTINGS: AutopickSettings = { isOn: false, minutes: DEFAULT_MINUTES }
21
22/** The command that ask-autopick registers; a session that lists it has that mod loaded. */
23const RIVAL_COMMAND = 'ask-autopick'
24
25/**
26 * The words a model writes in parentheses after the option it recommends. The tool tells it to write `(Recommended)`,
27 * and in a question written in another language it writes that word in the language.
28 */
29const RECOMMENDED_WORDS = [
30 'recommended', 'önerilen', 'empfohlen', 'recomendado', 'recomendada', 'recommandé', 'recommandée',
31 'consigliato', 'consigliata', 'aanbevolen', 'zalecane', 'рекомендуется', '推荐', '推薦', '推奨', 'おすすめ', '권장', '추천',
32]
33
34/** A label that ends with one of those words in parentheses, ASCII or full-width. */
35const RECOMMENDED_END = new RegExp(`[((]\\s*(?:${RECOMMENDED_WORDS.join('|')})\\s*[))]\\s*$`, 'iu')
36
37export const isRecommended = (label: string): boolean => RECOMMENDED_END.test(label)
38
39/**
40 * The recommended label of one question: the first option, marked at the end of its label, with no other option
41 * marked. Undefined for a question that takes several answers or has no such option.
42 */
43function pickOf(question: Question): string | undefined {
44 const [first, ...rest] = question.options ?? []
45 if (question.multiSelect === true || first === undefined || !isRecommended(first.label)) return undefined
46
47 return rest.some(option => isRecommended(option.label)) ? undefined : first.label
48}
49
50/** One recommended label per question, or undefined when a question has none: such a call waits for the person. */
51export function picksOf(questions: readonly Question[]): Record<string, string> | undefined {
52 if (questions.length === 0) return undefined
53 const picks: Record<string, string> = {}
54 for (const question of questions) {
55 const label = pickOf(question)
56 if (label === undefined) return undefined
57 picks[question.question] = label
58 }
59
60 return picks
61}
62
63/** The settings as `$.store` holds them; a missing or broken value reads as its default. */
64export function settingsOf(isOn: unknown, minutes: unknown): AutopickSettings {
65 return {
66 isOn: isOn === true,
67 minutes: typeof minutes === 'number' && minutesOf(String(minutes)) !== undefined ? minutes : DEFAULT_MINUTES,
68 }
69}
70
71/** The minutes a `/autopick <word>` argument names, or undefined when it names none in range. */
72export function minutesOf(arg: string): number | undefined {
73 if (!/^\d{1,3}$/.test(arg)) return undefined
74 const n = Number(arg)
75
76 return n >= MIN_MINUTES && n <= MAX_MINUTES ? n : undefined
77}
78
79/** The next wait up or down the steps; a wait between two steps moves to the nearer one in that direction. */
80export function stepMinutes(minutes: number, direction: 1 | -1): number {
81 if (direction > 0) return MINUTE_STEPS.find(step => step > minutes) ?? MAX_MINUTES
82
83 return [...MINUTE_STEPS].reverse().find(step => step < minutes) ?? MIN_MINUTES
84}
85
86/** Whether a command list names ask-autopick's command, which means that mod is loaded. */
87export const hasRival = (commands: readonly { name: string; plugin?: string }[]): boolean =>
88 commands.some(command => command.name === RIVAL_COMMAND || command.plugin === RIVAL_COMMAND)
89
90/** `7:05` for the time left; never below `0:00`. */
91export function countdown(ms: number): string {
92 const seconds = Math.max(0, Math.ceil(ms / 1_000))
93
94 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
95}
96
97/** `Color? → Blue (Recommended)`, the questions of one call joined. */
98export const picksText = (picks: Readonly<Record<string, string>>): string =>
99 Object.entries(picks)
100 .map(([question, label]) => `${question} → ${label}`)
101 .join('; ')
102
103/** The picks one per line, `question → label`, for the detail view. */
104export const picksDetail = (picks: Readonly<Record<string, string>>): string =>
105 Object.entries(picks)
106 .map(([question, label]) => `${question} → ${label}`)
107 .join('\n')
108
109/** What a pick was and why it happened, then its picks one per line: the detail view of the Picked line. */
110export const pickDetail = (pick: AutopickPick): string =>
111 `${pick.isByHand ? 'Pick now pressed' : `No answer in ${pick.minutes} min`}\n${picksDetail(pick.picks)}`
112
113/** The header's right side: `on · 10 min` or `off`. */
114export const autopickSummary = (settings: AutopickSettings): string => (settings.isOn ? `on · ${settings.minutes} min` : 'off')
115
116/** The line the transcript keeps for a pick. */
117export function pickedLog(pick: AutopickPick): string {
118 const why = pick.isByHand ? 'Pick now pressed' : `no answer in ${pick.minutes} min`
119
120 return `session-info: ${why}, picked the recommended option: ${picksText(pick.picks)}`
121}
122
123/** What the model reads after the picked answer, so it does not take the pick for the person's choice. */
124export function pickedNote(pick: AutopickPick): string {
125 return pick.isByHand
126 ? 'session-info autopick: the person pressed Pick now, so the recommended option was picked without reading the options. Treat it as a default they accepted, not as a choice they weighed.'
127 : `session-info autopick: the person did not answer within ${pick.minutes} minutes, so the recommended option was picked for them. Treat it as a default, not as their decision, and name the pick in your next reply.`
128}
129
130/** The `/autopick` answer. */
131export function statusText(settings: AutopickSettings, isBlocked: boolean): string {
132 if (isBlocked) return 'off · the ask-autopick mod is loaded and answers questions too; disable it to use this one'
133
134 return settings.isOn
135 ? `on · a question unanswered for ${settings.minutes} min gets its recommended option`
136 : `off · questions wait for you; /autopick on picks the recommended option after ${settings.minutes} min`
137}
138
139export const AUTOPICK_USAGE = 'Usage: /autopick [on | off | <minutes from 1 to 120>]'
140hooks/background.ts 84 lines1import type { BackgroundStatus, BackgroundTask } from '../types'
2
3import { duration } from './session'
4import type { TaskEnd } from './tasks'
5
6/** The most background commands the section keeps; the oldest finished one drops when a newer starts. */
7export const BACKGROUND_LIMIT = 30
8/** The most background commands the section lists; a longer list shows the first ones and a count. */
9export const BACKGROUND_ROOM = 8
10/** The longest command or description the state keeps; a longer one is cut. */
11export const TEXT_LIMIT = 1_000
12
13/** Glyph, word and theme color of each status; the word is what a color-blind reader goes by. */
14export const BACKGROUND_STATUS = {
15 running: { glyph: '⟳', word: 'running', color: 'suggestion' },
16 completed: { glyph: '✓', word: 'done', color: 'success' },
17 failed: { glyph: '✖', word: 'failed', color: 'error' },
18 killed: { glyph: '⊘', word: 'stopped', color: 'warning' },
19} as const satisfies Record<BackgroundStatus, { glyph: string; word: string; color: string }>
20
21/** The first line of a text that has any, trimmed: what a row draws of a command that spans lines. */
22export const firstLine = (text: string): string => (text.split(/\r?\n/).find(line => line.trim() !== '') ?? '').trim()
23
24const kept = (text: string): string => text.trim().slice(0, TEXT_LIMIT)
25
26/**
27 * The background task a finished shell call started: its id comes from `backgroundTaskId` in the call's result, so a
28 * command that ran to its end, failed or was refused gives none. The text kept is the command and the description
29 * the caller wrote, never the output.
30 */
31export function startedTask(input: Record<string, unknown>, ran: Record<string, unknown>, at: number): BackgroundTask | undefined {
32 if (ran.deny !== undefined || ran.isError === true) return undefined
33 const result = typeof ran.result === 'object' && ran.result !== null ? (ran.result as Record<string, unknown>) : {}
34 const id = result.backgroundTaskId
35 if (typeof id !== 'string' || id === '') return undefined
36 const command = typeof input.command === 'string' ? kept(input.command) : ''
37 const description = typeof input.description === 'string' ? kept(firstLine(input.description)) : ''
38
39 return { id, command, ...(description === '' ? {} : { description }), status: 'running', at }
40}
41
42/** The tasks with this one in front, the newest; a task already there under the same id is replaced, and the list is cut to BACKGROUND_LIMIT. */
43export const withTask = (tasks: readonly BackgroundTask[], task: BackgroundTask): BackgroundTask[] =>
44 [task, ...tasks.filter(one => one.id !== task.id)].slice(0, BACKGROUND_LIMIT)
45
46/** A notification's status as a task status; anything but completed or killed counts as failed. */
47export const statusOf = (status: string): BackgroundStatus => (status === 'completed' || status === 'killed' ? status : 'failed')
48
49/** The tasks with the one the notification names marked as ended; the same array when it names none that runs. */
50export function withEnd(tasks: BackgroundTask[], end: TaskEnd, at: number): BackgroundTask[] {
51 if (!tasks.some(task => task.id === end.taskId && task.status === 'running')) return tasks
52
53 return tasks.map(task => (task.id === end.taskId && task.status === 'running' ? { ...task, status: statusOf(end.status), endedAt: at } : task))
54}
55
56/** Running tasks first, then the ended ones; each group keeps its newest-first order. */
57export const orderedTasks = (tasks: readonly BackgroundTask[]): BackgroundTask[] =>
58 [...tasks.filter(task => task.status === 'running'), ...tasks.filter(task => task.status !== 'running')]
59
60/** What the task is called: the description its caller wrote, else the command. */
61export const taskLabel = (task: BackgroundTask): string => task.description ?? (task.command === '' ? task.id : firstLine(task.command))
62
63/** Whether the row has a second line: the command under a description that names it. */
64export const hasCommandLine = (task: BackgroundTask): boolean => task.description !== undefined && task.command !== ''
65
66/** The rows the listed tasks take: one line each, and a second for the command under a description. */
67export const taskRowCount = (tasks: readonly BackgroundTask[]): number => tasks.reduce((sum, task) => sum + 1 + (hasCommandLine(task) ? 1 : 0), 0)
68
69/** `running · 3m 2s` while it runs, `done · 12s` once it ended: the time it has run or ran for. */
70export function taskState(task: BackgroundTask, now: number): string {
71 const word = BACKGROUND_STATUS[task.status].word
72
73 return `${word} · ${duration((task.endedAt ?? now) - task.at)}`
74}
75
76/** `2 running · 1 done · 1 failed`, or `none`. */
77export function backgroundSummary(tasks: readonly BackgroundTask[]): string {
78 if (tasks.length === 0) return 'none'
79 const count = (status: BackgroundStatus) => tasks.filter(task => task.status === status).length
80 const parts = (['running', 'completed', 'failed', 'killed'] as const).map(status => [count(status), BACKGROUND_STATUS[status].word] as const)
81
82 return parts.filter(([total]) => total > 0).map(([total, word]) => `${total} ${word}`).join(' · ')
83}
84hooks/cards.tsx 108 lines1import type { Color, Elements, RenderChildren } from 'claude-code'
2
3import { ALIGN_GROW } from './title'
4import type { TitleAlign } from './title'
5
6type Widgets = Pick<Elements['terminal'], 'Box' | 'Text'>
7
8/** One headline figure of the tiles row: a bold value, then a dim label. */
9export type Tile = { key: string; value: string; label: string; color?: Color }
10
11/** Below this many columns a frame costs more than it shows, and sections draw bare. */
12const FRAME_MIN_COLUMNS = 44
13/** Rows a frame adds to its section: the top and bottom border. */
14export const FRAME_ROWS = 2
15/** Columns a frame takes from its body: two borders and one cell of padding on each side. */
16export const FRAME_COLUMNS = 4
17const TILE_MIN_COLUMNS = 18
18const TILE_ROWS = 3
19/** Rows the pane's title banner takes: its rule row alone. The blank row below comes from the pane itself. */
20export const BANNER_ROWS = 1
21/** Dashes in each side of the title rule, more than any pane is wide; the box clips them. */
22const RULE_LENGTH = 400
23/** Columns of the side of the rule that does not grow. */
24const RULE_STUB = 2
25
26export const isFramed = (columns: number): boolean => columns >= FRAME_MIN_COLUMNS
27
28/** How wide each tile is and how many rows the tiles take once they wrap to the pane's width. */
29export function tileLayout(count: number, columns: number): { width: number; rows: number } {
30 if (count === 0) return { width: columns, rows: 0 }
31 const perRow = Math.min(count, Math.max(1, Math.floor(columns / TILE_MIN_COLUMNS)))
32
33 return { width: Math.floor(columns / perRow), rows: Math.ceil(count / perRow) * TILE_ROWS }
34}
35
36/** Framed cards and KPI tiles drawn with the surface's own `Box` and `Text`; the frame lights under the pointer. */
37export function widgets({ Box, Text }: Widgets, isTerminal = true) {
38 const card = (key: string, isOpen: boolean, children: RenderChildren) => (
39 <Box
40 key={`card:${key}`}
41 flexDirection="column"
42 borderStyle="round"
43 borderColor="subtle"
44 borderDimColor={!isOpen}
45 paddingX={1}
46 {...(isTerminal ? {} : { marginBottom: 1, paddingY: 1 })}
47 hover={{ borderColor: 'claude', borderDimColor: false }}
48 >
49 {children}
50 </Box>
51 )
52
53 /** Cells mean nothing outside a terminal: there the tiles take half the row each and wrap by share. */
54 const tiles = (data: readonly Tile[], columns: number) => {
55 const { width } = tileLayout(data.length, columns)
56
57 return (
58 <Box
59 flexDirection="row"
60 flexWrap="wrap"
61 justifyContent={isTerminal ? 'flex-start' : 'space-between'}
62 {...(isTerminal ? {} : { marginBottom: 1, rowGap: 1 })}
63 >
64 {data.map(tile => (
65 <Box
66 key={`tile:${tile.key}`}
67 width={isTerminal ? width : '49%'}
68 // bodyColumns is a little narrower than the row: the width sets the wrap and the grow takes up what is left.
69 {...(isTerminal ? { flexGrow: 1 } : {})}
70 flexDirection="row"
71 borderStyle="round"
72 borderColor="subtle"
73 paddingX={1}
74 hover={{ borderColor: 'claude' }}
75 >
76 <Text bold color={tile.color}>{tile.value}</Text>
77 <Text dimColor wrap="truncate-end">{` ${tile.label}`}</Text>
78 </Box>
79 ))}
80 </Box>
81 )
82 }
83
84 /** One side of the title rule: a row of dashes clipped to the width the box gets, which is the rest of the row when it grows and RULE_STUB columns when it does not. */
85 const rule = (key: string, color: string, isGrowing: boolean) => (
86 <Box key={key} height={1} overflow="hidden" {...(isGrowing ? { flexGrow: 1 } : { width: RULE_STUB, flexShrink: 0 })}>
87 <Text color={color || 'subtle'}>{'─'.repeat(RULE_LENGTH)}</Text>
88 </Box>
89 )
90
91 /** The pane's custom title on one row between two rules, no margin, the title bold in the title color; align picks the side that grows. */
92 const banner = (title: string, color: string, align: TitleAlign) => {
93 const [isLeftGrowing, isRightGrowing] = ALIGN_GROW[align]
94
95 return (
96 <Box key="pane-title" flexDirection="row">
97 {rule('pane-title:left', color, isLeftGrowing)}
98 <Box key="pane-title:text" flexShrink={0}>
99 <Text bold {...(color === '' ? {} : { color })}>{` ${title} `}</Text>
100 </Box>
101 {rule('pane-title:right', color, isRightGrowing)}
102 </Box>
103 )
104 }
105
106 return { card, tiles, banner }
107}
108hooks/client.ts 68 lines1import type { Host } from './host'
2import { launch, lookupArgv, onPath, platformOf, succeeds } from './viewer'
3import type { Platform } from './viewer'
4
5/** Terminal emulators tried in order on Linux, each with the arguments that come before the command it runs. */
6const TERMINALS: readonly (readonly string[])[] = [
7 ['x-terminal-emulator', '-e'],
8 ['gnome-terminal', '--'],
9 ['konsole', '-e'],
10 ['xfce4-terminal', '-x'],
11 ['kitty'],
12 ['alacritty', '-e'],
13 ['wezterm', 'start', '--'],
14 ['foot'],
15 ['xterm', '-e'],
16]
17
18/** Runs the command from the folder in its first argument, as not every terminal starts in its own working directory. */
19const IN_FOLDER = ['sh', '-c', 'cd "$1" && shift && exec "$@"', 'sh']
20
21/** Splits a command line on spaces; double quotes keep a path with spaces in one piece. */
22export function splitCommand(line: string): string[] {
23 return [...line.matchAll(/"([^"]*)"|(\S+)/g)].map(match => match[1] ?? match[2] ?? '')
24}
25
26const shellQuote = (text: string): string => `'${text.replace(/'/g, `'\\''`)}'`
27
28const appleString = (text: string): string => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
29
30/**
31 * The argv that opens the client in a new terminal window, in the repository's root.
32 * Windows: `start` opens the default terminal, Windows Terminal on Windows 11; its quoted title comes first.
33 * macOS: Terminal.app through AppleScript. Linux: the first terminal emulator found, given by the caller, with `sh` changing to the root.
34 */
35export function clientArgv(platform: Platform, root: string, command: readonly string[], terminal?: readonly string[]): string[] | undefined {
36 if (platform === 'windows') return ['cmd', '/c', 'start', 'Git client', '/D', root.replace(/\//g, '\\'), ...command]
37 if (platform === 'mac') {
38 const script = `cd ${shellQuote(root)} && ${command.map(shellQuote).join(' ')}`
39
40 return ['osascript', '-e', `tell application "Terminal" to do script ${appleString(script)}`, '-e', 'tell application "Terminal" to activate']
41 }
42
43 return terminal === undefined ? undefined : [...terminal, ...IN_FOLDER, root, ...command]
44}
45
46/** The first terminal emulator of `TERMINALS` on the PATH, with its arguments; undefined when there is none. */
47export async function linuxTerminal(host: Host): Promise<readonly string[] | undefined> {
48 for (const terminal of TERMINALS) if (await succeeds(host, lookupArgv('linux', terminal[0] ?? ''))) return terminal
49
50 return undefined
51}
52
53/** Opens the configured Git client in a new terminal window; a toast says why when it cannot. */
54export async function openClient(host: Host, root: string, line: string): Promise<void> {
55 const command = splitCommand(line)
56 const program = command[0]
57 if (program === undefined) return
58
59 const isFound = /[\\/]/.test(program) ? await host.exists(program) : await onPath(host, program)
60 if (!isFound) return host.toast(`session-info: ${program} is not on the PATH. Set the Git client in /config.`)
61
62 const platform = await platformOf(host)
63 const terminal = platform === 'linux' ? await linuxTerminal(host) : undefined
64 const argv = clientArgv(platform, root, command, terminal)
65 if (argv === undefined) return host.toast('session-info: no terminal emulator found to open the Git client.')
66 launch(host, argv, platform === 'linux' ? root : undefined)
67}
68hooks/detail.ts 28 lines1import type { Detail } from '../types'
2
3/** The most characters the detail view keeps of one text; a longer one is cut with a mark. */
4export const DETAIL_LIMIT = 4_000
5
6const CUT = '\n… (cut)'
7
8/** The detail view of a text a row cut short: the title says what it is, the text is whole up to DETAIL_LIMIT. */
9export function detailOf(title: string, text: string): Detail {
10 const whole = text.trim()
11
12 return { title, text: whole.length > DETAIL_LIMIT ? `${whole.slice(0, DETAIL_LIMIT)}${CUT}` : whole }
13}
14
15const ELLIPSIS = '…'
16
17/** The text cut to `room` cells with a trailing `…` when it is longer, else as it is; a row's box clips by itself and leaves no mark. */
18export function clip(text: string, room: number): string {
19 const chars = Array.from(text)
20 if (room < 1 || chars.length <= room) return text
21
22 return `${chars.slice(0, room - 1).join('')}${ELLIPSIS}`
23}
24
25/** Joins the parts of a detail text, one per line, and leaves out an empty part. */
26export const detailLines = (parts: readonly (string | undefined)[]): string =>
27 parts.filter((part): part is string => part !== undefined && part.trim() !== '').join('\n')
28hooks/context.ts 100 lines1import type { ContextReading, ContextSlice } from '../types'
2
3import { tokens } from './session'
4
5/** /context gives several rows the same grey, so each used row takes its own color, in order. */
6const PALETTE = ['#7aa2f7', '#7dcfff', '#bb9af7', '#9ece6a', '#e0af68', '#f7768e', '#73daca', '#ff9e64', '#c0caf5']
7/** The row that grows takes the accent color. */
8const MESSAGES = 'claude'
9const EMPTY = 'subtle'
10
11export const GLYPH = { used: '█', free: '─', buffer: '░' } as const
12
13/** Cells between two legend entries; the row Box draws them as its gap. */
14export const LEGEND_GAP = 3
15
16export const SWATCH = { used: '■', free: '─', buffer: '░' } as const
17
18type Category = { name: string; tokens: number; kind: string }
19
20/**
21 * The breakdown `$.session.usage({ breakdown: 'summary' })` answers, as the bar draws it: the used rows in
22 * /context's order, then the free space and the compaction buffer. Deferred tool schemas sit outside the window.
23 */
24export function toReading(breakdown: {
25 categories: readonly Category[]
26 totalTokens: number
27 rawMaxTokens: number
28 autoCompactThreshold?: number
29 isAutoCompactEnabled: boolean
30}): ContextReading | null {
31 if (!(breakdown.rawMaxTokens > 0)) return null
32 const rank = { used: 0, free: 1, buffer: 2 } as const
33 const slices = breakdown.categories
34 .filter((c): c is Category & { kind: ContextSlice['kind'] } => c.tokens > 0 && c.kind in rank)
35 .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, kind: c.kind, color: EMPTY }))
36 .sort((a, b) => rank[a.kind] - rank[b.kind])
37 let next = 0
38 for (const slice of slices)
39 if (slice.kind === 'used') slice.color = slice.name === 'messages' ? MESSAGES : (PALETTE[next++ % PALETTE.length] ?? EMPTY)
40
41 return {
42 slices,
43 total: breakdown.totalTokens,
44 window: breakdown.rawMaxTokens,
45 compactsAt: breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined,
46 }
47}
48
49/** Each slice's share of `width` cells; a used slice keeps at least one, and free space absorbs the rounding. */
50export function cellWidths(reading: ContextReading, width: number): number[] {
51 const { slices, window } = reading
52 const sizes = slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / window) * width)))
53 let diff = width - sizes.reduce((sum, n) => sum + n, 0)
54 const isUsed = (i: number) => slices[i]?.kind === 'used'
55 const order = slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || (sizes[b] ?? 0) - (sizes[a] ?? 0))
56 for (const i of order) {
57 if (diff === 0) break
58 const size = Math.max(isUsed(i) ? 1 : 0, (sizes[i] ?? 0) + diff)
59 diff -= size - (sizes[i] ?? 0)
60 sizes[i] = size
61 }
62
63 return sizes
64}
65
66/** `2.1%`, `23%`, `<0.1%`. */
67export function share(count: number, window: number): string {
68 const percent = (count / window) * 100
69 if (percent > 0 && percent < 0.1) return '<0.1%'
70
71 return `${percent >= 10 ? Math.round(percent) : +percent.toFixed(1)}%`
72}
73
74/** One legend entry's text after its swatch: `skills 9.9k 1%`; free space and the buffer carry no share. */
75export const legendText = (slice: ContextSlice, window: number): string =>
76 `${slice.name} ${tokens(slice.tokens)}${slice.kind === 'used' ? ` ${share(slice.tokens, window)}` : ''}`
77
78/** The legend packed into lines no wider than `width`, each entry its swatch and text. */
79export function legendLines(reading: ContextReading, width: number): ContextSlice[][] {
80 const lines: ContextSlice[][] = []
81 let used = 0
82 for (const slice of reading.slices) {
83 const size = 2 + legendText(slice, reading.window).length
84 const line = lines.at(-1)
85 if (line === undefined || (line.length > 0 && used + LEGEND_GAP + size > width)) {
86 lines.push([slice])
87 used = size
88 } else {
89 line.push(slice)
90 used += LEGEND_GAP + size
91 }
92 }
93
94 return lines
95}
96
97/** `273k / 1.0M · compacts at 967k`: the live count, and where auto-compaction runs when it is on. */
98export const contextText = (used: number, window: number, compactsAt: number | undefined, counts: string): string =>
99 `${tokens(used)} / ${tokens(window)}${compactsAt ? ` · compacts at ${tokens(compactsAt)}` : ''}${counts}`
100hooks/checks.ts 227 lines1import type { CheckKind, CheckRun, Checks, CheckStatus } from '../types'
2
3import type { Host } from './host'
4import { buildErrorCount, buildErrors, clean, failedTests, fullOutputPath, tailLines, testCounts, vitestReport, vitestReportPath } from './parse'
5import type { TestCounts } from './parse'
6import { duration } from './session'
7
8export const NO_CHECKS: Checks = { build: null, test: null }
9
10export type Patterns = { build?: RegExp; test?: RegExp }
11
12/** A finished shell call, as `tool.call` resolved it; `taskId` names the background task of a command still running. */
13export type ShellRun = {
14 command: string
15 text: string
16 isError: boolean
17 isAgent: boolean
18 taskId?: string
19 persistedPath?: string
20}
21
22const MAX_ERRORS = 3
23const MAX_FAILURES = 5
24export const MAX_READ = 1_000_000
25
26// Each segment of a shell command is matched against these, so the lists stay anchored and free of nested repeats.
27const TEST = [
28 /^dotnet\s+test(?=\s|$)/,
29 /^dotnet\s+run\s.*\btests?\b/i,
30 /^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?test(?::\S+)?(?=\s|$)/,
31 /^npm\s+t(?=\s|$)/,
32 /^(?:jest|vitest|mocha|ava|cypress\s+run|gotestsum|ctest)(?=\s|$)/,
33 /^playwright\s+test(?=\s|$)/,
34 /^node\s+(?:\S+\s+)*--test(?=\s|$)/,
35 /^deno\s+test(?=\s|$)/,
36 /^claude\s+plugin\s+test(?=\s|$)/,
37 /^(?:turbo|nx)\s+(?:run\s+)?test(?::\S+)?(?=\s|$)/,
38 /^cargo\s+(?:test|nextest)(?=\s|$)/,
39 /^go\s+test(?=\s|$)/,
40 /^(?:(?:py|python3?)\s+-m\s+)?(?:pytest|unittest)(?=\s|$)/,
41 /^(?:tox|nox)(?=\s|$)/,
42 /^(?:\.\/)?(?:gradlew?|mvnw?)(?:\.bat|\.cmd)?\s.*\b(?:test|check|verify)\b/,
43 /^(?:rspec|rake\s+(?:test|spec)|(?:bin\/)?rails\s+test)(?=\s|$)/,
44 /^(?:\S*\/)?(?:phpunit|pest)(?=\s|$)/,
45 /^php\s+artisan\s+test(?=\s|$)/,
46 /^(?:mix|swift|flutter|dart|stack|cabal)\s+test(?=\s|$)/,
47 /^zig\s+(?:build\s+)?test(?=\s|$)/,
48 /^make\s+(?:test|check)(?=\s|$)/,
49]
50
51const BUILD = [
52 /^dotnet\s+(?:build|publish|pack|msbuild)(?=\s|$)/,
53 /^msbuild(?:\.exe)?(?=\s|$)/i,
54 /^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?(?:build|compile|typecheck|type-check)(?::\S+)?(?=\s|$)/,
55 /^bun\s+build(?=\s|$)/,
56 /^(?:tsc|vue-tsc|tsgo|tsup|esbuild|webpack|rollup)(?=\s|$)/,
57 /^(?:vite|next|nuxt|astro|ng|parcel|turbo|nx|flutter)\s+(?:run\s+)?build(?::\S+)?(?=\s|$)/,
58 /^claude\s+plugin\s+validate(?=\s|$)/,
59 /^cargo\s+(?:build|check|clippy)(?=\s|$)/,
60 /^go\s+(?:build|vet)(?=\s|$)/,
61 /^(?:\.\/)?(?:gradlew?|mvnw?)(?:\.bat|\.cmd)?\s.*\b(?:build|assemble|package|compile|install)\b/,
62 /^(?:swift|zig|stack|cabal)\s+build(?=\s|$)/,
63 /^mix\s+compile(?=\s|$)/,
64 /^xcodebuild(?=\s|$)/,
65 /^(?:cmake\s+--build|ninja)(?=\s|$)/,
66 /^make(?:\s+-[\w-]+)*(?:\s+(?:all|build))?\s*$/,
67]
68
69const IGNORED = /(?:^|\s)(?:--list-tests|--help|--version)(?=\s|$)/
70
71/** Runners in front of the real tool, with the options they take before it: `npx -y -p typescript tsc` runs `tsc`. */
72const PREFIX =
73 /^(?:rtk(?:\s+proxy)?|time|env|&|(?:npx|bunx|uvx|(?:pnpm|yarn)\s+(?:exec|dlx))(?:\s+(?:--package(?:=|\s+)\S+|-p\s+\S+|-[\w-]+))*|(?:uv|poetry|pipenv|hatch)\s+run|bundle\s+exec|[A-Za-z_]\w*=\S*)\s+/
74
75/** A segment without the wrappers that run the real tool: `rtk`, `npx`, env assignments. */
76function unwrap(segment: string): string {
77 let current = segment.trim().replace(/^[({]\s*/, '')
78 for (let next = current.replace(PREFIX, ''); next !== current; next = current.replace(PREFIX, '')) current = next
79
80 return current
81}
82
83/** A user pattern from the settings; an invalid one is ignored. */
84export function toPattern(source: unknown): RegExp | undefined {
85 if (typeof source !== 'string' || source.trim() === '') return undefined
86 try {
87 return new RegExp(source, 'i')
88 } catch {
89 return undefined
90 }
91}
92
93/** A shell command's segments, each without its wrappers: `cd app && rtk git push` gives `cd app` and `git push`. */
94export const segments = (command: string): string[] => command.split(/\s*(?:&&|\|\||[;|\n])\s*/).map(unwrap)
95
96/** Which checks a command runs, segment by segment: `cd app && npm test` is a test run. */
97export function classify(command: string, custom: Patterns = {}): CheckKind[] {
98 const kinds = new Set<CheckKind>()
99 for (const segment of segments(command)) {
100 if (segment === '' || IGNORED.test(segment)) continue
101 if (custom.test?.test(segment) || TEST.some(pattern => pattern.test(segment))) kinds.add('test')
102 else if (custom.build?.test(segment) || BUILD.some(pattern => pattern.test(segment))) kinds.add('build')
103 }
104
105 return [...kinds]
106}
107
108/** The first value each count has across the readings, in order. */
109const mergeCounts = (...readings: (TestCounts | undefined)[]): TestCounts => ({
110 passed: readings.find(r => r?.passed !== undefined)?.passed,
111 failed: readings.find(r => r?.failed !== undefined)?.failed,
112 skipped: readings.find(r => r?.skipped !== undefined)?.skipped,
113 total: readings.find(r => r?.total !== undefined)?.total,
114})
115
116async function readClean(host: Host, path: string | undefined): Promise<string | undefined> {
117 if (path === undefined) return undefined
118 const text = await host.readFile(path).catch(() => undefined)
119
120 return text === undefined ? undefined : clean(text.slice(-MAX_READ))
121}
122
123type Base = Pick<CheckRun, 'command' | 'at' | 'isAgent'>
124
125function buildRun(base: Base, text: string, whole: string | undefined, isError: boolean): CheckRun {
126 const errors = buildErrors(whole ?? text)
127 const found = errors.length > 0 ? errors : buildErrors(text)
128 const errorCount = buildErrorCount(text) ?? (whole === undefined ? undefined : buildErrorCount(whole)) ?? (found.length || undefined)
129 const isFailed = isError || (errorCount ?? 0) > 0
130 const shown = found.length > 0 ? found : tailLines(text, MAX_ERRORS)
131
132 return { ...base, kind: 'build', status: isFailed ? 'failed' : 'passed', errors: isFailed ? shown : [], errorCount, failures: [] }
133}
134
135function testRun(base: Base, text: string, whole: string | undefined, report: ReturnType<typeof vitestReport>, isError: boolean): CheckRun {
136 const counts = mergeCounts(testCounts(text), whole === undefined ? undefined : testCounts(whole), report?.counts)
137 const listed = failedTests(text)
138 const failures = listed.length > 0 ? listed : [whole === undefined ? [] : failedTests(whole), report?.failures ?? []].find(names => names.length > 0) ?? []
139 const isFailed = isError || (counts.failed ?? 0) > 0 || failures.length > 0
140 const isExplained = failures.length > 0 || (counts.failed ?? 0) > 0
141 const reason = buildErrors(whole ?? text)
142 const errors = isFailed && !isExplained ? (reason.length > 0 ? reason : tailLines(text, MAX_ERRORS)) : []
143
144 return { ...base, kind: 'test', status: isFailed ? 'failed' : 'passed', errors, ...counts, failures }
145}
146
147/** One record per check the command ran, read from its output and any whole copy of it on disk. */
148export async function checkRuns(host: Host, kinds: readonly CheckKind[], shell: ShellRun, at: number): Promise<CheckRun[]> {
149 const base: Base = { command: (shell.command.split('\n')[0] ?? '').trim(), at, isAgent: shell.isAgent }
150 if (shell.taskId !== undefined) return kinds.map(kind => ({ ...base, kind, status: 'running', taskId: shell.taskId, errors: [], failures: [] }))
151
152 const text = clean(shell.text)
153 const whole = await readClean(host, shell.persistedPath ?? fullOutputPath(text, (await host.env()).home))
154 const reportPath = kinds.includes('test') ? vitestReportPath(text) : undefined
155 const report = reportPath === undefined ? undefined : vitestReport((await host.readFile(reportPath).catch(() => undefined)) ?? '')
156
157 return kinds.map(kind => (kind === 'build' ? buildRun(base, text, whole, shell.isError) : testRun(base, text, whole, report, shell.isError)))
158}
159
160export const CHECK_STATUS = {
161 passed: { glyph: '✓', color: 'success' },
162 failed: { glyph: '✖', color: 'error' },
163 running: { glyph: '⟳', color: 'suggestion' },
164} as const satisfies Record<CheckStatus, { glyph: string; color: string }>
165
166/** One check as the pane draws it: a head row, then the error lines or failed test names. */
167export type CheckView = { run: CheckRun; label: string; result: string; details: string[] }
168
169const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
170
171function resultText(run: CheckRun): string {
172 if (run.status === 'running') return 'running in background'
173 if (run.kind === 'build') return run.status === 'failed' && run.errorCount ? plural(run.errorCount, 'error') : run.status
174 if (run.status === 'passed') return run.passed === undefined ? 'passed' : `${run.passed} passed${run.skipped ? ` · ${run.skipped} skipped` : ''}`
175 if (run.failed === undefined) return 'failed'
176 const total = run.total ?? (run.passed === undefined ? undefined : run.failed + run.passed + (run.skipped ?? 0))
177
178 return total === undefined ? `${run.failed} failed` : `${run.failed} failed / ${total}`
179}
180
181function detailLines(run: CheckRun): string[] {
182 const items = run.failures.length > 0 ? run.failures : run.errors
183 const room = run.failures.length > 0 ? MAX_FAILURES : MAX_ERRORS
184 const shown = items.slice(0, room)
185
186 return items.length > room ? [...shown, `… ${items.length - room} more`] : shown
187}
188
189export function checkViews(checks: Checks): CheckView[] {
190 return [checks.build, checks.test]
191 .filter((run): run is CheckRun => run !== null)
192 .map(run => ({ run, label: run.kind === 'build' ? 'Build' : 'Tests', result: resultText(run), details: detailLines(run) }))
193}
194
195/** The head row's right side: `agent · 2m 5s ago`. */
196export const checkWhen = (run: CheckRun, now: number): string =>
197 `${run.isAgent ? 'agent · ' : ''}${duration(now - run.at)} ago`
198
199/** The section header's right side: `✓ build ✖ tests`. */
200export function checkSummary(checks: Checks): string {
201 return checkViews(checks).map(({ run }) => `${CHECK_STATUS[run.status].glyph} ${run.kind === 'build' ? 'build' : 'tests'}`).join(' ')
202}
203
204/** Failed test names the fix prompt lists before the rest fold into `and N more`. */
205const FIX_ROOM = 3
206
207function testFix(run: CheckRun): string {
208 const count = run.failed ?? run.failures.length
209 if (count === 0) return 'Fix the failed test run'
210 const names = run.failures.slice(0, FIX_ROOM).join(', ')
211 const more = run.failures.length > FIX_ROOM ? ` and ${run.failures.length - FIX_ROOM} more` : ''
212
213 return `Fix the ${plural(count, 'failed test')}${names === '' ? '' : `: ${names}${more}`}`
214}
215
216/** The prompt that asks for a fix of the failed build, else of the failed tests, which a failed build often explains; the key names the run. */
217export function fixSuggestion(checks: Checks): { key: string; text: string } | undefined {
218 const run = [checks.build, checks.test].find(one => one?.status === 'failed')
219 if (!run) return undefined
220 const text = run.kind === 'test' ? testFix(run) : run.errorCount ? `Fix the ${plural(run.errorCount, 'build error')}` : 'Fix the failed build'
221
222 return { key: `${run.kind}@${run.at}`, text }
223}
224
225export const checkRowCount = (views: readonly CheckView[]): number =>
226 views.reduce((rows, view) => rows + 2 + view.details.length, 0)
227hooks/ci.ts 183 lines1import type { CiJob, CiRun, CiTriggerKind, CiWatch } from '../types'
2
3import { segments } from './checks'
4import { duration } from './session'
5
6/** Runs a command and resolves its exit code and output; rejects when the command cannot start. */
7export type Runner = (argv: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>
8
9/** How often the section asks GitHub while a run is open. */
10export const CI_POLL_MS = 15_000
11
12/** How long after a push the section looks for the runs it started: GitHub lists a run some seconds after the push. */
13export const CI_DISCOVER_MS = 120_000
14
15/** Runs the section draws. */
16export const CI_ROOM = 3
17
18const RUN_FIELDS = 'databaseId,workflowName,displayTitle,headBranch,headSha,status,conclusion,url,createdAt,updatedAt'
19
20export type CiTrigger = { kind: CiTriggerKind; runId?: number }
21
22const runIdIn = (text: string): number | undefined => {
23 const id = /\/actions\/runs\/(\d+)/.exec(text)?.[1]
24
25 return id === undefined ? undefined : Number(id)
26}
27
28/** What a shell command did that starts GitHub Actions runs; undefined for any other command. */
29export function ciTrigger(command: string, output: string): CiTrigger | undefined {
30 for (const segment of segments(command)) {
31 if (/^git\s+push(?=\s|$)/.test(segment) && !/\s(?:--dry-run|-n)(?=\s|$)/.test(segment)) return { kind: 'push' }
32 if (/^gh\s+pr\s+create(?=\s|$)/.test(segment)) return { kind: 'pr' }
33 if (/^gh\s+workflow\s+run(?=\s|$)/.test(segment)) {
34 const runId = runIdIn(output)
35
36 return runId === undefined ? { kind: 'workflow' } : { kind: 'workflow', runId }
37 }
38 const rerun = /^gh\s+run\s+rerun\s+(\d+)/.exec(segment)?.[1]
39 if (rerun !== undefined) return { kind: 'rerun', runId: Number(rerun) }
40 }
41
42 return undefined
43}
44
45/** A new watch: a push or a pull request follows the runs of the HEAD commit; a dispatch or a rerun follows its run. */
46export function startWatch(trigger: CiTrigger, head: { sha?: string; branch?: string }, now: number): CiWatch {
47 return {
48 trigger: trigger.kind,
49 since: now,
50 ...(head.sha === undefined ? {} : { sha: head.sha }),
51 ...(head.branch === undefined ? {} : { branch: head.branch }),
52 runIds: trigger.runId === undefined ? [] : [trigger.runId],
53 runs: [],
54 }
55}
56
57const text = (value: unknown): string => (typeof value === 'string' ? value : '')
58
59const time = (value: unknown): number => {
60 const at = Date.parse(text(value))
61
62 return Number.isNaN(at) ? 0 : at
63}
64
65const toJob = (raw: Record<string, unknown>): CiJob => ({ name: text(raw.name), status: text(raw.status), conclusion: text(raw.conclusion) })
66
67/** One run from `gh run list` or `gh run view` JSON; `jobs` kept from `prev` when the reading has none. */
68export function toRun(raw: Record<string, unknown>, prev?: CiRun): CiRun {
69 const jobs = Array.isArray(raw.jobs) ? raw.jobs.map(job => toJob(job as Record<string, unknown>)) : (prev?.jobs ?? [])
70
71 return {
72 id: Number(raw.databaseId),
73 workflow: text(raw.workflowName) || text(raw.name),
74 title: text(raw.displayTitle),
75 branch: text(raw.headBranch),
76 sha: text(raw.headSha),
77 status: text(raw.status),
78 conclusion: text(raw.conclusion),
79 url: text(raw.url),
80 createdAt: time(raw.createdAt),
81 updatedAt: time(raw.updatedAt),
82 jobs,
83 }
84}
85
86export const isDone = (run: CiRun): boolean => run.status === 'completed'
87
88const isDiscovering = (watch: CiWatch, now: number): boolean => watch.runIds.length === 0 && now - watch.since < CI_DISCOVER_MS
89
90/** Whether the next tick has anything to ask GitHub. */
91export const isWatching = (watch: CiWatch | null, now: number): watch is CiWatch =>
92 watch !== null && watch.error === undefined && (isDiscovering(watch, now) || watch.runs.some(run => !isDone(run)) || watch.runs.length < watch.runIds.length)
93
94async function gh(run: Runner, args: string[]): Promise<unknown> {
95 const done = await run(['gh', ...args]).catch(() => undefined)
96 if (done === undefined) throw new Error('gh not found: install the GitHub CLI and run gh auth login')
97 if (done.exitCode !== 0) throw new Error((done.stderr || done.stdout).trim().split('\n')[0] || `gh exited with ${done.exitCode}`)
98
99 return JSON.parse(done.stdout)
100}
101
102/**
103 * One tick: lists the runs of the commit while the push is recent, then reads each open run with its jobs.
104 * Resolves the next watch and the runs that completed in this tick; an error from gh ends the watch with its message.
105 */
106export async function pollWatch(run: Runner, watch: CiWatch, now: number): Promise<{ watch: CiWatch; finished: CiRun[] }> {
107 try {
108 const runs = new Map(watch.runs.map(one => [one.id, one]))
109 if (isDiscovering(watch, now)) {
110 const filter = watch.sha === undefined ? ['--event', 'workflow_dispatch', '--limit', '5'] : ['--commit', watch.sha, '--limit', '20']
111 const listed = (await gh(run, ['run', 'list', ...filter, '--json', RUN_FIELDS])) as Record<string, unknown>[]
112 for (const raw of listed) {
113 const found = toRun(raw, runs.get(Number(raw.databaseId)))
114 // A dispatch has no commit to match: only a run created after it counts.
115 if (watch.sha !== undefined || found.createdAt >= watch.since - CI_POLL_MS) runs.set(found.id, found)
116 }
117 }
118 for (const id of watch.runIds) if (!runs.has(id)) runs.set(id, { ...toRun({ databaseId: id }), status: 'queued' })
119
120 const finished: CiRun[] = []
121 for (const [id, known] of runs) {
122 // A run read as completed before keeps its last reading; one first seen completed is read once, for its jobs.
123 if (watch.runs.some(one => one.id === id && isDone(one))) continue
124 const raw = (await gh(run, ['run', 'view', String(id), '--json', `${RUN_FIELDS},jobs`])) as Record<string, unknown>
125 const read = toRun(raw, known)
126 if (isDone(read) && !isDone(known)) finished.push(read)
127 runs.set(id, read)
128 }
129 const sorted = [...runs.values()].sort((a, b) => b.createdAt - a.createdAt)
130
131 return { watch: { ...watch, runs: sorted }, finished }
132 } catch (error) {
133 return { watch: { ...watch, error: error instanceof Error ? error.message : String(error) }, finished: [] }
134 }
135}
136
137export const CI_STATUS = {
138 success: { glyph: '✓', color: 'success' },
139 failure: { glyph: '✖', color: 'error' },
140 cancelled: { glyph: '⊘', color: 'warning' },
141 skipped: { glyph: '○', color: 'inactive' },
142 running: { glyph: '⟳', color: 'suggestion' },
143 queued: { glyph: '◌', color: 'inactive' },
144} as const
145
146export type CiState = keyof typeof CI_STATUS
147
148/** One state for a run or a job from its status and conclusion. */
149export function stateOf(item: { status: string; conclusion: string }): CiState {
150 if (item.status !== 'completed') return item.status === 'in_progress' ? 'running' : 'queued'
151 if (item.conclusion === 'success') return 'success'
152 if (item.conclusion === 'cancelled') return 'cancelled'
153 if (item.conclusion === 'skipped' || item.conclusion === 'neutral') return 'skipped'
154
155 return 'failure'
156}
157
158/** The header: `⟳ running` while a run is open, else the worst conclusion; '' with no runs. */
159export function watchState(watch: CiWatch): CiState | undefined {
160 if (watch.runs.length === 0) return undefined
161 const states = watch.runs.map(stateOf)
162 for (const state of ['running', 'queued', 'failure', 'cancelled', 'success', 'skipped'] as const) if (states.includes(state)) return state
163
164 return undefined
165}
166
167/** `main · a1b2c3d · 2m 10s`: the time runs while the run is open. */
168export function runDetail(run: CiRun, now: number): string {
169 const end = isDone(run) ? run.updatedAt : now
170 const spent = run.createdAt === 0 ? '' : duration(end - run.createdAt)
171
172 return [run.branch, run.sha.slice(0, 7), spent].filter(part => part !== '').join(' · ')
173}
174
175/** The line under a run while nothing lists it yet, or the error that stopped the watch. */
176export function waitingText(watch: CiWatch, now: number): string | undefined {
177 if (watch.error !== undefined) return undefined
178 if (watch.runs.length > 0) return undefined
179 const target = watch.sha === undefined ? 'the dispatched workflow' : watch.sha.slice(0, 7)
180
181 return isDiscovering(watch, now) ? `Waiting for the runs of ${target}…` : `No workflow run started for ${target}.`
182}
183hooks/git.ts 70 lines1import type { Host } from './host'
2import { absolute, baseName, hash, keyOf, norm, relativeTo } from './paths'
3
4/** `git status` of the repository, path (relative to `root`) to its two-letter code. */
5export type GitStatus = { root: string; entries: Map<string, string> }
6
7let repoRoot: string | null | undefined
8let snapshotDir: string | undefined
9
10async function rootOf(host: Host): Promise<string | null> {
11 if (repoRoot !== undefined) return repoRoot
12 const run = await host.run(['git', 'rev-parse', '--show-toplevel'])
13 repoRoot = run.exitCode === 0 ? norm(run.stdout.trim()) : null
14
15 return repoRoot
16}
17
18export async function gitStatus(host: Host): Promise<GitStatus | undefined> {
19 const root = await rootOf(host)
20 if (root === null) return undefined
21 const run = await host.run(['git', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], root)
22 if (run.exitCode !== 0) return undefined
23
24 const entries = new Map<string, string>()
25 const fields = run.stdout.split('\0')
26 for (let i = 0; i < fields.length; i++) {
27 const field = fields[i] ?? ''
28 if (field.length < 4) continue
29 entries.set(field.slice(3), field.slice(0, 2))
30 if (field[0] === 'R' || field[0] === 'C') i++
31 }
32
33 return { root, entries }
34}
35
36export const pathIn = (status: GitStatus, relative: string): string => absolute(status.root, relative)
37
38/** The file's text at HEAD, or undefined outside a repository or for a file HEAD lacks. */
39export async function headText(host: Host, path: string): Promise<string | undefined> {
40 const root = await rootOf(host)
41 if (root === null) return undefined
42 const run = await host.run(['git', 'show', `HEAD:${relativeTo(root, path)}`], root)
43
44 return run.exitCode === 0 ? run.stdout : undefined
45}
46
47async function dir(host: Host): Promise<string> {
48 if (snapshotDir !== undefined) return snapshotDir
49 const env = await host.env()
50 const temp = env.temp ?? env.tmpdir ?? '/tmp'
51 snapshotDir = `${norm(temp)}/claude-session-info/${await host.sessionId()}`
52
53 return snapshotDir
54}
55
56/** Writes the text a file had before the session touched it; the file keeps its name, so a viewer highlights it. */
57export async function snapshot(host: Host, path: string, text: string): Promise<string> {
58 const file = `${await dir(host)}/${hash(keyOf(path))}/${baseName(path)}`
59 await host.writeFile(file, text)
60
61 return file
62}
63
64export async function emptyFile(host: Host, path: string): Promise<string> {
65 const file = `${await dir(host)}/empty/${baseName(path)}`
66 if (!(await host.exists(file))) await host.writeFile(file, '')
67
68 return file
69}
70