SLOPSHOPPER

session-info

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…

newpaneguardcommandtoastprompt
★ 4v0.28.4MITupdated 2026-10-15dougcunha/claude-mods/session-info
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-info
│ ┃ Session info ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ╭──────────────────────────╮╭─────────────── │ session-info │ │ ┃ │ $0.42 cost ││ 8933h 52m elap ⏺ Read(src/auth.ts) │ session-info: TypeError: undefined is not │ │ ┃ ╰──────────────────────────╯╰─────────────── ⎿ Read 6 lines │ an object (evaluating 'path.replace') │ │ ┃ ╭─────────────────────────────────────────── ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ │ section:#session ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ┃ │ ▸ Context 97k / 200k ⏺ Bash(bun test) │ session-info │ │ ┃ │ ███████████████████████─────────────────── ⎿ 3 pass, 1 fail │ session-info: TypeError: undefined is not │ │ ┃ │ 1 turn · ~1.5k output tok/turn · last 4… │ an object (evaluating 'path.replace') │ │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now re╰────────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ section:#turn ✓ answer · 42s · $0.00 │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ claude-opus-5-5 │ │ ┃ │ in 2.1k · out 1.5k · cache 91k read / 4… │ › /session-info │ ┃ │ detail:turn:tools │ ⎿ session-info: Session info pane opened. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ section:#todos │ │ ┃ │ No task list yet. Claude writes one for │ │ ┃ │ work with several steps. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ section:#autopick off │ ┃ │ autopick-switch after autopick-less 10 min │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭──────────────────────────────────────────╮ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session info
╭──────────────────────────╮ ╭──────────────────────────╮ │ $0.42 cost │ │ 8933h 52m elapsed │ ╰──────────────────────────╯ ╰──────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#session │ │ ▸ Context 97k / 200k │ │ ███████████████████████─────────────────────── 49% │ │ 1 turn · ~1.5k output tok/turn · last 42s │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#turn ✓ answer · 42s · $0.00 │ │ claude-opus-5-5 │ │ in 2.1k · out 1.5k · cache 91k read / 4.3k write · 93% … │ │ detail:turn:tools │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#todos │ │ No task list yet. Claude writes one for work with │ │ several steps. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#autopick off │ │ autopick-switch after autopick-less 10 min autopick-more │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#skills │ │ No skill loaded in this conversation yet. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#ci │ │ detail:ci:error │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#usage │ │ Current session │ │ ██████████████──────────────────────────────── 31% │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#agents none │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#background none │ │ No command started in the background in this │ │ conversation yet. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ section:#checks ✖ tests │ │ ✖ Testsdetail:check:test:result 0s ago │ │ detail:check:test:command │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ section:#changes+git tab:#changes │ tab:#git0 files ✚ 0 │ No files changed in this session yet. │ │ [ collapse ] [ expand ] [ clear ] │ Click or Enter: new file opens in the editor, changed or │ deleted one as a diff · folder collapses or expands ╰─────────────────────────────────────────────────────────── l: layout
README

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

✨ Sections

  • 📊 Session: the context window's fill, compactions and /clear commands, the session's cost and duration, and turn stats.
  • 🔁 Last turn: how the last turn ended, how long it took and what it cost, its model, effort and request count, its token and cache counts, the tools it called, and the calls the permission check refused.
  • ✅ Todos: the task list Claude keeps for work with several steps, like OpenCode's todo list. The mod gives Claude a todo_write tool for it.
  • ⏳ Autopick: a switch that answers a question Claude asked and you left unanswered with the option Claude marked as recommended, after a wait you set. A countdown shows the time left, with Pick now and Cancel. The switch starts off.
  • 🧠 Skills: the skills the conversation loaded, which agent loaded each one, and the ones still active.
  • 🚦 CI: the GitHub Actions runs that a push, a pull request, a workflow dispatch or a rerun of the session started, with their jobs, a link and a notification when each run ends.
  • 🎫 Jira: the Jira issues the chat mentions, with summary, status, priority and assignee, read through the Atlassian MCP server you connected.
  • 📈 Usage: your plan's rate-limit windows as bars, like /usage, updated live.
  • 🤖 Agents: the session's subagents and teammates with their status, model and effort, children under the agent that spawned them.
  • 🛰️ Background: the shell commands Claude started in the background, such as a dev server or a long build, with their status and how long they ran.
  • 🧪 Checks: whether the last build and the last test run the session started passed, with the error lines of a failed build or the names of the failed tests, and a prompt that asks Claude to fix a failure. .NET, Node, Bun, Rust, Go and Python tools are recognized.
  • 🌿 Git: the branch and whether it is on the remote, the working-tree counts, the last commit, a merge or rebase in progress, the submodules that changed, and every file 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.
  • 📝 Changes: every file the current session created, modified or deleted, in a treeview. Pressing a new file opens it in your editor; pressing a modified or deleted file opens its diff in the diff viewer configured on your machine.

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

🎨 Cards, tabs and order

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.

🔍 Reading a text that a row cuts

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:

SectionTexts
Last turnThe tools called and the refused calls
AutopickThe 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
CIThe error that stopped the polling
JiraAn issue's summary and the error of a failed read
AgentsAn agent's model, effort and task description
BackgroundA command's name and its whole command, every line of it
ChecksThe command, the result line and the error lines or failed test names, shown together
GitThe 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.

📸 Screenshot

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:

The session-info pane: tiles, Session with the stacked context bar, Usage, Autopick with a countdown, Todos, Last turn, Skills, Agents, Checks and Changes

Each section that follows has its own screenshot from the same session.

🚀 Install

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

🎮 Use

ActionHow
Open the pane/session-info (or /changes). To open it when the session starts, set session-info.openOnStart to true
Fold or unfold a sectionPress its header, for example ▾ Session or ▾ Changes
Open the Git clientPress the branch name in the ▾ Git header, or press g while the pane has the focus
Show the legend of the context barPress ▸ Context
Open a fileClick 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 folderPress the folder
Scroll a long treeA 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 listc, 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 offPress [ ] Answer unanswered questions in the Autopick section, or run /autopick on or /autopick off
Set the autopick waitPress − or + in the Autopick section, or run /autopick <minutes>, 1 to 120
Answer a waiting question now, or stop its countdownPress Pick now or Cancel on its row in the Autopick section
Move or hide a cardPress ≡ 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 againPress 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 promptEsc

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.

📊 What the Session section shows

The Session section with the context legend unfolded: each category's tokens and share, the free space, the compact buffer and the turn stats

RowSource
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 barThe 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
CompactionsCompactions 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
TurnsMain-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.

🔁 What the Last turn section shows

The section shows the last turn of the main conversation. It updates when the turn ends.

The Last turn section: the turn number, result, duration and cost, then the model, effort and request count, the token and cache counts, and the tools called

RowSource
HeaderThe 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
ModelThe model of the last request of the turn, the effort it sent, and how many model requests the turn made
TokensInput, 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))
ToolsThe 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
RefusedThe 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.

🤖 What the Agents section shows

The Agents section with two completed Explore subagents, each with its model, effort and task

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:

GlyphStatus
⟳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.

🛰️ What the Background section shows

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:

GlyphStateMeaning
⟳running · 3m 2sThe command runs, and the time counts up
✓done · 12sThe task ended with the status completed
✖failed · 4sThe task ended with any other status than completed or killed
⊘stopped · 1m 1sThe 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.

🧪 What the Checks section shows

The Checks section with a passed build and a passed test run of 112 tests

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.

CheckCommands
Builddotnet 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
Testsdotnet 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 the buildPattern and testPattern settings.

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:

CheckShows
A failed buildThe 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 run12 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 backgroundrunning 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.

💡 Fix suggestion

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:

FailurePrompt
Build with an error countFix the 2 build errors
Build without an error countFix the failed build
Test run with failed test namesFix the 2 failed tests: Subtract_Fails, Divide_Throws, three names and then and N more
Test run without namesFix 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.

🌿 What the Git section shows

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.

The Git tab of the Changes and Git card: the branch, the working-tree counts, the last commit and the treeview of changed files

▾ 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 ]
RowShows
HeaderThe 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
OperationA merge, rebase (with its step), cherry-pick, revert or bisect that stopped halfway
CountsConflicts, 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 commitShort hash, subject and age
SubmodulesThe 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
FilesEvery 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

Source 32 files
hooks/register.tsx 2032 lines
1import { 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 lines
1import 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}
80
hooks/alerts.ts 54 lines
1import { 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}
54
hooks/autopick.ts 140 lines
1import 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>]'
140
hooks/background.ts 84 lines
1import 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}
84
hooks/cards.tsx 108 lines
1import 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}
108
hooks/client.ts 68 lines
1import 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}
68
hooks/detail.ts 28 lines
1import 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')
28
hooks/context.ts 100 lines
1import 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}`
100
hooks/checks.ts 227 lines
1import 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)
227
hooks/ci.ts 183 lines
1import 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}
183
hooks/git.ts 70 lines
1import 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