Reports session status to showrunner and shows the task context above the prompt

<h1><picture> <img src="assets/wordmark-light.svg" alt="showrunner" width="330">
Formerly claude-manager.
A terminal UI (TUI) for managing multiple coding-agent sessions — Claude Code, Codex CLI and Pi today, more harnesses planned — organized by projects and tasks. Built with Rust using ratatui.
Showrunner uses tmux to run agent sessions in the background, letting you organize them into projects and tasks, monitor their status, review diffs, and attach/detach freely. <img width="800" height="430" alt="showcase-gif" src="https://github.com/user-attachments/assets/63b6a5b1-b821-44b3-b764-481ee40fd33f" />
brew install tmux (macOS) or apt install tmux (Linux)claude, the default), Codex CLI (codex) and/or Pi (pi) — see Agent harnessesr), a terminal diff viewer (modem-dev/hunk). Installed globally (npm i -g hunkdiff) it launches instantly; otherwise it runs via npx hunkdiff automatically (Node.js required), fetched on first use.review_tool = "difit" is set in config. Installed globally (npm i -g difit) it launches instantly; otherwise it runs via npx difit automatically (Node.js required).Homebrew (macOS and Linux):
brew install Bendzae/tap/showrunner
Prebuilt binaries (macOS and Linux):
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Bendzae/showrunner/releases/latest/download/showrunner-installer.sh | sh
Or via cargo:
cargo install showrunner
Data lives in ~/.showrunner/. Upgrading from claude-manager keeps all projects, tasks and sessions: on first start the old ~/.showrunner/ dir is migrated automatically (renamed, worktree git links repaired, live sessions re-pointed). Remove the old binary with cargo uninstall claude-manager.
Or build from source:
git clone git@github.com:Bendzae/showrunner.git
cd showrunner
cargo install --path .
showrunner
Launch from any directory. Configuration is stored in ~/.showrunner/config.toml.
Tip: When you attach to a session (with
Enter), you're inside a tmux session. To detach and get back to showrunner, use your tmux detach binding — with the default prefix that'sCtrl-b d. The session keeps running in the background.
p prompts for path and name).default_agent/--agent) running in a tmux session. Sessions can be created with an optional initial prompt, and (by default) in their own git worktree so they don't collide.◆ main) — Every task has one, created with the task. It works in a worktree with the task branch itself checked out, so its commits land on the task branch directly — no merge step. Extra sessions (n) get their own <task-branch>-<name> branch and merge back into it.A from a project's context menu and grouped under the project. Handy for quick, throwaway work that doesn't warrant a task.All keybindings are customizable via ~/.showrunner/keybindings.toml. The tables below show the defaults. See keybindings.example.toml for a full template.
| Key | Action | Config key |
|---|---|---|
j/k or Up/Down | Navigate | move_down / move_up |
Enter | Attach to session (on a task: its main session), or collapse/expand project or group | — |
Space | Collapse/expand project or task | toggle_collapse |
a | Open context menu | context_menu |
p | Add project | add_project |
P | Add one of the remote's projects (with a [remote] configured) | add_remote_project |
/ | Filter projects/tasks/sessions | search |
Z | Toggle archived view | toggle_archive_view |
K / J | Move the selected project, task or group up / down (order is saved) | move_task_up / move_task_down |
t | Cycle color theme | cycle_theme |
q | Quit | quit |
t cycles through the built-in themes: default, catppuccin, tokyo-night, and dracula.
a to open)The context menu shows actions relevant to the selected item. Press the hotkey character to execute directly, or navigate with j/k and confirm with Enter. Context menu keys are configured under the [context_menu_keys] section.
Project actions:
| Key | Action | Config key |
|---|---|---|
t | Add task | add_task |
T | Add task, choosing the agent harness first | add_task_with_agent |
A | New adhoc session | new_adhoc_session |
x | Run the project's configured run command | run |
b | Checkout branch (fuzzy-search the branch list) | checkout |
f | Fetch & pull all branches (git fetch --all --prune + ff-only pull) | fetch_pull |
y | Copy project path to clipboard | copy_path |
d | Delete | delete |
The branch checkout picker opens a fuzzy finder over the project's local and remote branches — type to filter, ↑/↓ to navigate, Enter to check out (a remote-only branch is checked out as a new local tracking branch).
Run command (x, available on projects, tasks, and sessions) launches a per-project command in a dedicated tmux session and attaches to it. The first time you run it for a project you're prompted for the command (e.g. npm run dev); it's saved to that project's run_command config and reused everywhere afterwards. It runs in the selected item's working directory: the session's worktree for a session, the task's first session worktree (or the project dir) for a task, and the project dir for a project.
Each item shows a green run indicator while it has a live run session — an animated spinner (⠙) while the command is still executing, and a static ▷ once it finishes (the shell stays open so you can read its output). Detaching with Ctrl-b d leaves the command running in the background.
Pressing Run (x) again on an item that already has a live run session opens a small menu instead of relaunching:
| Key | Action |
|---|---|
a | Attach — re-attach to the running session |
r | Restart — kill and relaunch the run command |
k | Kill — stop the run session |
Run sessions are independent per item, so running on a different item starts a separate session. (Outside the TUI you can also list them with tmux ls and attach via tmux attach -t cmrun-<name>.)
Task actions:
| Key | Action | Config key |
|---|---|---|
n | New session (with worktree) | new_session |
S | New session, choosing the agent harness first | new_session_with_agent |
N | New session (without worktree) | new_session_no_worktree |
r | Review branch-vs-base diff (difit or hunk) | review |
x | Run the project's configured run command | run |
u | Update/rebase branch onto its base branch (default main) | update |
B | Set base branch | set_base_branch |
P | Push branch | push |
b | Checkout branch in project dir | checkout |
o | Open/create PR | open_pr |
l | Open linked Linear ticket | open_linear |
g | Set group (fuzzy picker over existing groups; type a new name to create one) | group |
R | Enable/disable Remote Control on the main session (Claude) | remote_control |
A | Archive | archive |
d | Delete | delete |
Task group actions (on a group header):
| Key | Action | Config key |
|---|---|---|
t | Add task to the group | add_task |
g | Rename group | group |
G | Ungroup tasks (dissolve the group) | ungroup |
Session actions:
| Key | Action | Config key |
|---|---|---|
r | Review uncommitted changes (difit or hunk) | review |
m | Merge into task branch | merge |
u | Update/rebase onto task branch | update |
t | Open/attach a terminal in the worktree | terminal |
x | Run the project's configured run command | run |
y | Copy worktree path to clipboard | copy_path |
R | Enable/disable Remote Control (Claude sessions) | remote_control |
d | Delete | delete |
The main session only offers Review, Terminal, Run, Copy path and Remote Control: it is already on the task branch, so Merge and Update have nothing to do, and it can't be deleted on its own — delete or archive the task instead.
Remote Control (R, Claude sessions, also adhoc and on a remote host) makes a session show up in the Claude desktop and mobile apps and on claude.ai/code, by sending /remote-control <name> into it — no restart. It's named <machine> · <project> / <task>[ / <session>], with <machine> from machine_name in that machine's config (default: its hostname). Pressing R again disconnects it. The setting is kept on the session, so a session recreated after tmux dies comes back connected. Same from the CLI: showrunner session remote-control <session> [on|off]. Needs a claude.ai login with Remote Control allowed by your org; the first time on a machine Claude asks for consent in the session.
The Review action (r) launches the configured diff review tool on the relevant diff — branch-vs-base for a task, uncommitted changes for a session. Choose the tool with review_tool in ~/.showrunner/config.toml ("hunk", the default, or "difit"):
# ~/.showrunner/config.toml
review_tool = "difit" # or "hunk" (default)
Sessions display their current status:
! badge (each theme's own orange, used for nothing else) at the end of the session's line; its task's line turns orange (with the badge after the name while collapsed), and a collapsed project shows how many of its sessions need you. While the TUI runs, a session that starts needing you also posts a macOS notification (not for the session you're attached to) — through terminal-notifier when installed, so a click brings your terminal forward, else osascript; set notifications = false in config.toml to turn them offA task whose agent exchanged a message (your prompt or a turn — attaching doesn't count) in the last 2 hours shows a 🔥 after its name, so the tasks you're actually working on stand out from the ones waiting on something. It's read from the end of each Claude session's transcript; the order of tasks doesn't change.
Tasks with a pull request (found via gh) show it in the PR column as #123 plus two glyphs, refreshed about once a minute. Selecting the task spells the same information out in the status bar.
| Cell | Meaning |
|---|---|
#123 / #123 draft / #123 merged / #123 closed | PR number and state (open PRs have no suffix) |
✓ / ✗ / ◌ / · | Review: approved / changes requested / review requested, no decision yet / no reviewers requested |
● green / ● red / ◍ yellow | CI: all checks passed / a check failed / checks still running (omitted when the PR has no checks) |
Merged and closed PRs show only their state, since review and CI no longer apply.
A task can be linked to a Linear ticket; its id (ENG-123) shows next to the task name and l opens it. Linking is done through the CLI — the task's main session agent links a ticket itself when the task's name, branch or prompt refers to one:
showrunner task set-linear <project> <task> https://linear.app/acme/issue/ENG-123
showrunner task set-linear <project> <task> ENG-123 # needs linear_workspace
showrunner task create <project> <name> --linear ENG-123
# ~/.showrunner/config.toml — lets bare ids like ENG-123 be linked
linear_workspace = "acme"
Every session gets a git worktree so it works on an isolated copy of the codebase. The task's main session checks out the task branch itself; additional sessions created with n get their own <task-branch>-<name> branch. Use N to skip worktree creation and work directly in the project directory.
Because the main session's worktree owns the task branch, b (checkout in the project dir) fails while it exists — check out the branch in a worktree instead, or delete the task. Update branch (u) and session Merge (m) run inside that worktree rather than checking the branch out in the project dir. Worktrees are removed when their session is deleted, and all of a task's worktrees when the task (d) or project is deleted.
The project's .claude/ directory is always copied into new worktrees, so project-level agent config and skills are available there. You can copy additional file patterns (e.g. .env files) by adding copy_patterns to your project config. To run commands inside each freshly-created worktree (installing dependencies, configuring git hooks, etc.), add setup_commands (a single string or a list):
[[projects]]
name = "My App"
path = "/path/to/my-app"
copy_patterns = [".env", ".env.local"]
setup_commands = ["npm install", "./scripts/configure-hooks.sh"]
The config file at ~/.showrunner/config.toml is managed automatically through the TUI, but can also be edited manually:
# Global: agent harness new sessions run by default ("claude", "codex" or "pi");
# override per creation with --agent. Codex sessions launch in yolo mode with
# the work dir pre-trusted, and resume via `codex resume --last`. Pi sessions
# launch with `--approve` (project files pre-trusted) and resume via `pi --continue`.
default_agent = "claude"
# Global: skills/slash-commands run in every new session before the initial
# prompt (a single string or a list). Useful for priming context.
startup_skills = ["/prime"]
# Global: names this machine in Remote Control session names (default: hostname).
machine_name = "work-laptop"
[[projects]]
name = "My App"
path = "/home/user/my-app"
copy_patterns = [".env"] # files copied into each new worktree
setup_commands = ["npm install"] # commands run in each new worktree
run_command = "npm run dev" # command launched by the Run action (x)
[[projects.tasks]]
name = "fix-auth-bug"
branch = "fix/auth-bug"
base_branch = "develop" # rebase/diff target (defaults to "main")
[[projects.tasks]]
name = "add-dark-mode"
branch = "feature/dark-mode"
group = "ui" # optional manual grouping in the task list
Most of these fields are set for you through the TUI (run_command on first Run, base_branch via B), so manual editing is rarely necessary. A running TUI picks up external edits on its next idle refresh.
Projects and tasks are listed in the order they appear in the config. K / J move the selected task up / down within its project, or the selected project up / down in the list — local and remote projects alike, so a remote project can sit right under its local counterpart (the change is saved to the config, so the CLI and web UI see the same order). Members of a stack move together as one unit.
Tasks can also be grouped by hand: g on a task opens a fuzzy picker over the project's existing groups (typing a name that matches none offers to create it; (no group) removes the task from its group), and every task in a project sharing that name is listed under a collapsible group header, its members marked with a ┆ rail. A grouped task moves within its group; selecting the header moves the whole group, and its context menu adds a task straight into the group, renames it or dissolves it. The group is stored as group = "..." on each [[projects.tasks]] entry and shows up as group=... in showrunner list (a group field in --json); from the CLI, task create --group and task set-group set it.
Tasks whose base_branch is another task's branch form a stack — the same relationship GitHub's stacked pull requests use (each PR's base is the previous branch in the chain). Showrunner detects these chains automatically and marks each member with its position, ⧉ 2/3, in the TUI task list and in showrunner list (stack=2/3, or a stack object in --json). Stacked tasks are listed consecutively in chain order (root first), regardless of the order they were created in. To stack task B on task A, set B's base branch to A's branch with B. Creating a PR from a stacked task targets its base branch, so the PR lands stacked on GitHub.
Create ~/.showrunner/keybindings.toml to override any default keybinding. Only the keys you specify are overridden; everything else keeps its default. Example:
quit = "Q"
context_menu = "o"
[context_menu_keys]
delete = "x"
Sessions can run any of the supported harnesses, chosen per creation (--agent, or the T/S picker keys) with default_agent as the fallback.
The default harness (claude).
--dangerously-skip-permissions; the session briefing is passed via --append-system-prompt.--plugin-dir), so they're available as /commit-push-task and /manage-sessions slash commands.--session-id at creation, --resume <id> after); sessions from before that use claude --continue.claude-mod/ is a Claude Code mod that ties Claude sessions closer to showrunner. Inside a showrunner session it:
CM_STATUS: running, waiting for input, needs you), which showrunner trusts over reading the pane — an answer that ends by asking you questions (checked by Claude Code's small model when the reply's tail has a ?) counts as needing you, like a question dialog;showrunner session info);/ticket, which opens the task's Linear ticket (description, labels, latest comments) in a pane (from showrunner session ticket);/pr, which opens the task's PR in a pane: checks (failing and pending ones linked), review state and unresolved review comments, with a Send to Claude button that puts them in the prompt (from showrunner session pr);/clear resumes the right one.Ticket state and content need a Linear token: LINEAR_API_KEY, or on macOS the one the linear CLI keeps in the keychain. Clickable links inside tmux need set -as terminal-features ',*:hyperlinks'.
Outside showrunner sessions it does nothing. Install it for every session on a machine:
/plugin install showrunner-mod --marketplace Bendzae/showrunner
codex — must be logged in via codex login.
--dangerously-bypass-approvals-and-sandbox). The work dir is pre-trusted via a [projects] entry in ~/.codex/config.toml, since the first-launch trust dialog appears even in yolo mode..agents/skills/.codex resume --last (scoped to the work dir).pi — needs a provider and default model configured (/login inside pi, or defaultProvider/defaultModel in ~/.pi/agent/settings.json).
--approve, which pre-trusts the project-local files showrunner injects (.agents/skills/) so the one-time project-trust dialog never blocks a session; the briefing is passed via --append-system-prompt..agents/skills/, which pi discovers natively.pi --continue (scoped to the work dir).All showrunner features work with every harness — what differs is the mechanism:
| Claude Code | Codex CLI | Pi | |
|---|---|---|---|
| Session briefing | --append-system-prompt | prepended to first message | --append-system-prompt |
| Showrunner skills | plugin (slash commands) | .agents/skills/ | .agents/skills/ |
| Auto-approval | --dangerously-skip-permissions | yolo flag + pre-trusted work dir | yolo by design, --approve for project files |
| Attention dialogs | permission & question prompts | selectors (trust dialog etc.) | trust dialog & modal selectors |
| Resume | claude --continue | codex resume --last | pi --continue |
Initial prompt, startup skills, status detection, ask/send/output | ✓ | ✓ | ✓ |
serve)showrunner serve starts an HTTP server with a phone-friendly web UI for managing sessions remotely — view session statuses, read live output, send messages and keys (permission prompts included), review diffs, and create projects, tasks, sessions and adhoc sessions, delete tasks, or kill sessions.
showrunner serve # default 127.0.0.1:7878
showrunner serve --bind 0.0.0.0:7878 # bind another interface
The server has no authentication — keep it on localhost and expose it through your tailnet:
tailscale serve --bg 7878
This gives you a valid-HTTPS URL reachable only from your own devices. Open it on your phone and use "Add to Home Screen" to install it as an app.
Besides the TUI and serve, the binary exposes the same task/session operations as commands. They act on the shared state in ~/.showrunner/, so a running TUI picks the changes up on its next refresh. Agent
hooks/register.tsx 603 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 Loaded,
6 PullRequest,
7 SessionInfo,
8 Sibling,
9 Status,
10 Thread,
11 TicketView,
12} from '../types'
13
14const info = atom({ plugin: 'showrunner-mod', key: 'info' } as const, null)
15const ticket = atom({ plugin: 'showrunner-mod', key: 'ticket' } as const, null)
16
17const pr = atom({ plugin: 'showrunner-mod', key: 'pr' } as const, null)
18
19const moduleTags = { plugin: 'monorepo-touched', key: 'tags' } as const
20
21/** The band's colors, each toned a little toward a dark background. */
22const PALETTE = {
23 text: '#F3F4F6',
24 project: tone('#7C3AED'),
25 task: tone('#2563EB'),
26 adhoc: tone('#475569'),
27 session: tone('#F59E0B'),
28 ok: tone('#22C55E'),
29 bad: tone('#DC2626'),
30 warn: tone('#D97706'),
31 idle: tone('#94A3B8'),
32 prOpen: tone('#16A34A'),
33 prFailing: tone('#DC2626'),
34 prPending: tone('#D97706'),
35 prMerged: tone('#8B5CF6'),
36 prDraft: tone('#6B7280'),
37 linear: tone('#5E6AD2'),
38 border: tone('#22D3EE'),
39} as const
40
41const TICKET_PANE = 'ticket'
42const PR_PANE = 'pr'
43
44// Tools that wait on the person even when permissions are skipped.
45const ASKS_PERSON = new Set(['AskUserQuestion', 'ExitPlanMode'])
46
47const REFRESH_MS = 30_000
48
49let tmuxName: string | undefined
50let reported: Status | undefined
51let conversation: string | undefined
52
53/** Keep the session record on the conversation this session runs, so a
54 * recreated session resumes it (it changes on /clear). */
55async function syncConversation($: EngineInterface) {
56 if (tmuxName === undefined) return
57 const id = await $.session.id()
58 if (id === conversation) return
59 conversation = id
60 await $.process
61 .run(['showrunner', 'session', 'set-conversation', tmuxName, id])
62 .catch(() => undefined)
63}
64
65/** Whether an answer ends by asking the person something they need to
66 * answer: only a closing `?` is worth a quick check by the small model. */
67async function asksPerson($: EngineInterface, answer: string): Promise<boolean> {
68 const tail = answerTail(answer)
69 if (!tail.includes('?')) return false
70 const verdict = await $.model
71 .classify(
72 `Does this end of an assistant's reply ask the user one or more questions they need to answer before work can continue? Offers like "Want me to …?" count only if the reply waits on the answer.\n\n${tail}`,
73 ['asks', 'done'],
74 )
75 .catch(() => undefined)
76 return verdict === 'asks'
77}
78
79async function loadPr($: EngineInterface) {
80 if (tmuxName === undefined) return
81 await update($, pr, (): Loaded<PullRequest> => ({ status: 'loading' }))
82 const ran = await $.process
83 .run(['showrunner', 'session', 'pr', tmuxName], { timeoutMs: 30_000 })
84 .catch(() => undefined)
85 const view: Loaded<PullRequest> =
86 ran?.exitCode === 0
87 ? { status: 'ready', value: JSON.parse(ran.stdout) }
88 : {
89 status: 'error',
90 message: failure(ran?.stderr),
91 }
92 await update($, pr, () => view)
93}
94
95async function sendThreadsToClaude($: EngineInterface, threads: Thread[]) {
96 const text = threads
97 .map(
98 t =>
99 `- ${t.path}${t.line ? `:${t.line}` : ''} (${t.author ?? 'reviewer'}): ${firstParagraph(t.body)}`,
100 )
101 .join('\n')
102 await $.prompt.fill({
103 text: `Address these unresolved review comments on the PR:\n${text}\n`,
104 mode: 'insert',
105 })
106}
107
108async function report($: EngineInterface, status: Status) {
109 if (tmuxName === undefined || status === reported) return
110 reported = status
111 await $.process
112 .run(['tmux', 'set-environment', '-t', tmuxName, 'CM_STATUS', status])
113 .catch(() => undefined)
114}
115
116async function refresh($: EngineInterface) {
117 if (tmuxName === undefined) return
118 const ran = await $.process
119 .run(['showrunner', 'session', 'info', tmuxName], { timeoutMs: 20_000 })
120 .catch(() => undefined)
121 if (ran?.exitCode !== 0) return
122 const parsed = JSON.parse(ran.stdout) as SessionInfo
123 await update($, info, () => parsed)
124}
125
126/** What a failed `showrunner` call says, or that showrunner needs updating. */
127function failure(stderr: string | undefined): string {
128 const message = stderr?.replace(/^Error: /, '').trim()
129 if (message?.includes('unknown session command')) {
130 return 'This needs a newer showrunner (0.29.5 or later): update it and try again.'
131 }
132 return message || 'showrunner did not answer'
133}
134
135async function loadTicket($: EngineInterface) {
136 if (tmuxName === undefined) return
137 await update($, ticket, (): TicketView => ({ status: 'loading' }))
138 const ran = await $.process
139 .run(['showrunner', 'session', 'ticket', tmuxName], { timeoutMs: 20_000 })
140 .catch(() => undefined)
141 const view: TicketView =
142 ran?.exitCode === 0
143 ? { status: 'ready', ticket: JSON.parse(ran.stdout) }
144 : {
145 status: 'error',
146 message: failure(ran?.stderr),
147 }
148 await update($, ticket, () => view)
149}
150
151export const register: Register = on => {
152 tmuxName = undefined
153 reported = undefined
154 conversation = undefined
155
156 on('session.start', async ($, e, next) => {
157 const started = await next(e)
158 if (!e.isInteractive) return started
159
160 const pane = await $.env.get('TMUX_PANE')
161 if (pane === undefined) return started
162 const ran = await $.process
163 .run(['tmux', 'display-message', '-p', '-t', pane, '#S'])
164 .catch(() => undefined)
165 const name = ran?.stdout.trim()
166 if (!name?.startsWith('cm__')) return started
167
168 tmuxName = name
169 await $.command
170 .register({ name: 'ticket', description: "Show this task's Linear ticket in a pane" })
171 .catch(() => undefined)
172 await $.command
173 .register({ name: 'pr', description: "Show this task's PR: checks and open review comments" })
174 .catch(() => undefined)
175 await syncConversation($)
176 await report($, 'waiting_input')
177 void refresh($)
178 $.clock.every(REFRESH_MS, () => void refresh($))
179 return started
180 })
181
182 on('command.run', { command: 'ticket' }, async $ => {
183 const current = await read($, info)
184 const id = current?.linear ? linearId(current.linear) : undefined
185 if (id === undefined) return { text: 'No Linear ticket is linked to this task.' }
186 await $.ui.open({ id: TICKET_PANE, title: id, columns: 72, closeOnEscape: true })
187 void loadTicket($)
188 return { text: `Opened ${id}.` }
189 })
190
191 on('ui.render', { component: 'Pane', requestId: TICKET_PANE }, async ($, e) => {
192 const { Box, Link, Markdown, Text } = $.ui.resolve(e)
193 const view = await read($, ticket)
194 if (view === null || view.status === 'loading') {
195 return <Text color="subtle">Loading ticket…</Text>
196 }
197 if (view.status === 'error') {
198 return <Text color="error">{view.message}</Text>
199 }
200 const t = view.ticket
201 const comments = t.comments.nodes
202 return (
203 <Box flexDirection="column" gap={1} paddingX={1}>
204 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
205 <Link href={t.url}>
206 <Pill Text={Text} bg={tone(t.state.color)} fg={PALETTE.text} bold>
207 {ticketGlyph(t.state.type)} {t.identifier} · {t.state.name}
208 </Pill>
209 </Link>
210 {t.labels.nodes.map(label => (
211 <Text key={label.name} color={label.color}>
212 ● {label.name}
213 </Text>
214 ))}
215 </Box>
216 <Text bold>{t.title}</Text>
217 <Text color="subtle">
218 {t.assignee ? `👤 ${t.assignee.displayName}` : '👤 unassigned'}
219 {t.priorityLabel !== 'No priority' ? ` ⚑ ${t.priorityLabel}` : ''}
220 {t.creator ? ` ✎ ${t.creator.displayName}` : ''}
221 </Text>
222 <Markdown text={t.description?.trim() || '_No description._'} />
223 {comments.length > 0 ? (
224 <Box flexDirection="column" gap={1}>
225 <Text bold color="claude">
226 💬 {comments.length} comment{comments.length === 1 ? '' : 's'}
227 </Text>
228 {comments.map(comment => (
229 <Box key={comment.createdAt} flexDirection="column">
230 <Text color="subtle">
231 {comment.user?.displayName ?? 'someone'} · {comment.createdAt.slice(0, 10)}
232 </Text>
233 <Markdown text={comment.body} />
234 </Box>
235 ))}
236 </Box>
237 ) : null}
238 </Box>
239 )
240 })
241
242 on('command.run', { command: 'pr' }, async $ => {
243 const current = await read($, info)
244 if (current === null || current.task === null) return { text: 'Adhoc sessions have no PR.' }
245 await $.ui.open({ id: PR_PANE, title: 'PR', columns: 80, closeOnEscape: true })
246 void loadPr($)
247 return { text: 'Opened the PR pane.' }
248 })
249
250 on('ui.render', { component: 'Pane', requestId: PR_PANE }, async ($, e) => {
251 const { Box, Button, Link, Markdown, Text } = $.ui.resolve(e)
252 const view = await read($, pr)
253 if (view === null || view.status === 'loading') {
254 return <Text color="subtle">Loading PR…</Text>
255 }
256 if (view.status === 'error') {
257 return <Text color="error">{view.message}</Text>
258 }
259 const p = view.value
260 const count = (state: string) => p.checks.filter(c => c.state === state).length
261 const attention = p.checks.filter(c => c.state === 'fail' || c.state === 'pending')
262 const checksState = count('fail') > 0 ? 'failing' : count('pending') > 0 ? 'pending' : null
263 return (
264 <Box flexDirection="column" gap={1} paddingX={1}>
265 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
266 <Link href={p.url}>
267 <Pill Text={Text} bg={prColor(p.state, checksState)} fg={PALETTE.text} bold>
268 {prIcon(p.state, checksState)} PR #{p.number} {p.state}
269 </Pill>
270 </Link>
271 {p.review ? <Text color={reviewColor(p.review)}>{reviewLabel(p.review)}</Text> : null}
272 <Text>
273 <Text color="diffAdded" bold>
274 +{p.additions}
275 </Text>{' '}
276 <Text color="diffRemoved" bold>
277 −{p.deletions}
278 </Text>
279 <Text color="subtle"> in {p.changed_files} files</Text>
280 </Text>
281 </Box>
282 <Text bold>{p.title}</Text>
283 <Text>
284 <Text color={PALETTE.ok}>✓ {count('pass')} passed</Text>
285 <Text color="subtle"> · </Text>
286 <Text color={PALETTE.bad}>✗ {count('fail')} failed</Text>
287 <Text color="subtle"> · </Text>
288 <Text color={PALETTE.warn}>◷ {count('pending')} pending</Text>
289 <Text color="subtle"> · ⤼ {count('skipped')} skipped</Text>
290 </Text>
291 {attention.map((check, i) => (
292 <Text key={`check-${i}`}>
293 <Text color={check.state === 'fail' ? PALETTE.bad : PALETTE.warn}>
294 {check.state === 'fail' ? '✗' : '◷'}{' '}
295 </Text>
296 {check.url ? (
297 <Link href={check.url}>{check.name ?? 'check'}</Link>
298 ) : (
299 <Text>{check.name ?? 'check'}</Text>
300 )}
301 </Text>
302 ))}
303 <Box flexDirection="row" columnGap={1}>
304 <Text bold color="claude">
305 💬 {p.threads.length} unresolved review comment{p.threads.length === 1 ? '' : 's'}
306 </Text>
307 {p.threads.length > 0 ? (
308 <Button
309 key="send"
310 label="Send to Claude"
311 onPress={() => sendThreadsToClaude($, p.threads)}
312 />
313 ) : null}
314 <Button key="refresh" label="Refresh" onPress={() => loadPr($)} />
315 </Box>
316 {p.threads.map((thread, i) => (
317 <Box key={`thread-${i}`} flexDirection="column">
318 <Text>
319 <Link href={thread.url}>
320 {thread.path.split('/').pop()}
321 {thread.line ? `:${thread.line}` : ''}
322 </Link>
323 <Text color="subtle"> · {thread.author ?? 'reviewer'}</Text>
324 </Text>
325 <Markdown text={firstParagraph(thread.body)} />
326 </Box>
327 ))}
328 </Box>
329 )
330 })
331
332 on('session.end', async ($, e, next) => {
333 if (tmuxName !== undefined) {
334 await $.process
335 .run(['tmux', 'set-environment', '-u', '-t', tmuxName, 'CM_STATUS'])
336 .catch(() => undefined)
337 }
338 return next(e)
339 })
340
341 on('turn.start', async ($, e, next) => {
342 await report($, 'running')
343 await syncConversation($)
344 return next(e)
345 })
346
347 on('turn.complete', async ($, e, next) => {
348 if (e.agentId === undefined) {
349 const asks = e.reason === 'answer' && (await asksPerson($, e.answer))
350 await report($, asks ? 'waiting_permission' : 'waiting_input')
351 void refresh($)
352 }
353 return next(e)
354 })
355
356 on('tool.check', async ($, e, next) => {
357 const verdict = await next(e)
358 if (verdict.decision === 'ask') await report($, 'waiting_permission')
359 return verdict
360 }).catch(($, e, next) => next(e))
361
362 on('tool.call', async ($, e, next) => {
363 if (ASKS_PERSON.has(e.tool)) await report($, 'waiting_permission')
364 const ran = await next(e)
365 if (reported === 'waiting_permission') await report($, 'running')
366 return ran
367 }).catch(($, e, next) => next(e))
368
369 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
370 const hint = await next(e)
371 const current = await read($, info)
372 if (current === null) return hint
373
374 const { Box, Link, Text } = $.ui.resolve(e)
375 const diff = current.task_diff ?? current.session_diff
376 const isAdhoc = current.task === null
377 const ticket = current.linear ? linearId(current.linear) : undefined
378 const ticketState = current.linear_state ?? undefined
379 const { value: tags } = await $.state.get(moduleTags)
380
381 return (
382 <Box flexDirection="column">
383 {hint}
384 <Box flexDirection="row" flexWrap="wrap">
385 <Box
386 flexDirection="row"
387 flexWrap="wrap"
388 columnGap={1}
389 borderStyle="round"
390 borderColor={PALETTE.border}
391 paddingX={1}
392 >
393 <Pill Text={Text} bg={PALETTE.project} fg={PALETTE.text} bold>
394 ⌂ {current.project}
395 </Pill>
396 <Pill Text={Text} bg={isAdhoc ? PALETTE.adhoc : PALETTE.task} fg={PALETTE.text} bold>
397 {isAdhoc ? '✦ adhoc' : `◆ ${current.task}`}
398 </Pill>
399 <Text color={PALETTE.session} bold>
400 ▸ {current.session}
401 </Text>
402 {diff && diff.added + diff.removed > 0 ? (
403 <Text>
404 <Text color="diffAdded" bold>
405 +{diff.added}
406 </Text>{' '}
407 <Text color="diffRemoved" bold>
408 −{diff.removed}
409 </Text>
410 </Text>
411 ) : null}
412 {current.pr ? (
413 <Link href={current.pr.url}>
414 <Pill
415 Text={Text}
416 bg={prColor(current.pr.state, current.pr.checks)}
417 fg={PALETTE.text}
418 bold
419 >
420 {prIcon(current.pr.state, current.pr.checks)} PR {prNumber(current.pr.url)}{' '}
421 {current.pr.state}
422 </Pill>
423 </Link>
424 ) : null}
425 {ticket && current.linear ? (
426 <Link href={current.linear}>
427 <Pill
428 Text={Text}
429 bg={ticketState ? tone(ticketState.color) : PALETTE.linear}
430 fg={PALETTE.text}
431 bold
432 >
433 {ticketGlyph(ticketState?.type)} {ticket}
434 {ticketState ? ` · ${ticketState.name}` : ''}
435 </Pill>
436 </Link>
437 ) : null}
438 {current.siblings.map(sibling => (
439 <Text key={sibling.name}>
440 <Text color={statusColor(sibling)}>{statusGlyph(sibling)}</Text>
441 <Text color="subtle"> {sibling.name}</Text>
442 </Text>
443 ))}
444 </Box>
445 {tags?.length ? (
446 <Box flexDirection="row" flexWrap="wrap" columnGap={1} paddingX={1} paddingY={1}>
447 {tags.map(tag => (
448 <Text key={tag.label}>
449 <Pill Text={Text} bg={tag.background} fg={tag.text} bold>
450 {tag.label}
451 </Pill>
452 {tag.added > 0 ? <Text color="diffAdded">{` +${tag.added}`}</Text> : null}
453 {tag.removed > 0 ? <Text color="diffRemoved">{` −${tag.removed}`}</Text> : null}
454 </Text>
455 ))}
456 </Box>
457 ) : null}
458 </Box>
459 </Box>
460 )
461 })
462}
463
464function Pill({
465 Text,
466 bg,
467 fg,
468 bold,
469 children,
470}: {
471 Text: any
472 bg: string
473 fg: string
474 bold?: boolean
475 children?: unknown
476}) {
477 return (
478 <Text backgroundColor={bg} color={fg} bold={bold}>
479 {' '}
480 {children}{' '}
481 </Text>
482 )
483}
484
485/** The last stretch of an answer, code blocks left out. */
486function answerTail(answer: string): string {
487 return answer
488 .replace(/```[\s\S]*?```/g, '')
489 .trim()
490 .slice(-800)
491}
492
493function reviewLabel(review: string): string {
494 switch (review) {
495 case 'APPROVED':
496 return '✓ approved'
497 case 'CHANGES_REQUESTED':
498 return '✗ changes requested'
499 default:
500 return '◷ review required'
501 }
502}
503
504function reviewColor(review: string): string {
505 if (review === 'APPROVED') return PALETTE.ok
506 if (review === 'CHANGES_REQUESTED') return PALETTE.bad
507 return PALETTE.warn
508}
509
510/** A review comment's text without bot footers, badges and HTML. */
511function firstParagraph(body: string): string {
512 return body
513 .split(/\n\s*\n/)
514 .filter(part => !/^\s*(\[!\[|<|!\[)/.test(part))
515 .slice(0, 2)
516 .join('\n\n')
517 .trim()
518}
519
520function statusGlyph(sibling: Sibling): string {
521 switch (sibling.status) {
522 case 'running':
523 return '●'
524 case 'waiting_permission':
525 return '▲'
526 case 'waiting_input':
527 return '○'
528 default:
529 return '·'
530 }
531}
532
533function statusColor(sibling: Sibling): string {
534 switch (sibling.status) {
535 case 'running':
536 return PALETTE.ok
537 case 'waiting_permission':
538 return PALETTE.warn
539 case 'waiting_input':
540 return PALETTE.idle
541 default:
542 return 'subtle'
543 }
544}
545
546function prColor(state: string, checks: string | null): string {
547 if (state === 'merged') return PALETTE.prMerged
548 if (state === 'closed' || checks === 'failing') return PALETTE.prFailing
549 if (state === 'draft') return PALETTE.prDraft
550 if (checks === 'pending') return PALETTE.prPending
551 return PALETTE.prOpen
552}
553
554function prIcon(state: string, checks: string | null): string {
555 if (state === 'merged') return '⇄'
556 if (state === 'closed') return '✕'
557 if (checks === 'failing') return '✗'
558 if (checks === 'pending') return '◷'
559 return '✓'
560}
561
562function prNumber(url: string): string {
563 return `#${url.split('/').pop()}`
564}
565
566function linearId(url: string): string | undefined {
567 return url.match(/\/issue\/([A-Za-z]+-\d+)/)?.[1]?.toUpperCase()
568}
569
570/** Linear's workflow state types, drawn the way Linear's own icons read. */
571function ticketGlyph(type: string | undefined): string {
572 switch (type) {
573 case 'completed':
574 return '✔'
575 case 'canceled':
576 return '✕'
577 case 'started':
578 return '◐'
579 case 'unstarted':
580 return '○'
581 case 'backlog':
582 case 'triage':
583 return '◌'
584 default:
585 return '◇'
586 }
587}
588
589/** A `#rrggbb` color blended a little (22%) toward a dark background. */
590function tone(hex: string): string {
591 const rgb = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
592 if (!rgb) return hex
593 const base = [0x1f, 0x20, 0x23]
594 return (
595 '#' +
596 rgb
597 .slice(1)
598 .map((c, i) => Math.round(parseInt(c, 16) * 0.78 + base[i]! * 0.22))
599 .map(v => v.toString(16).padStart(2, '0'))
600 .join('')
601 )
602}
603types/index.d.ts 82 lines1export type Status = 'running' | 'waiting_input' | 'waiting_permission'
2
3export type Diff = { added: number; removed: number }
4
5export type Sibling = { name: string; agent: string; status: Status | null }
6
7export type SessionInfo = {
8 project: string
9 task: string | null
10 session: string
11 branch: string | null
12 base_branch?: string
13 linear?: string | null
14 linear_state?: TicketState | null
15 task_diff?: Diff | null
16 session_diff: Diff | null
17 pr?: { url: string; state: string; checks: string | null; review: string | null } | null
18 siblings: Sibling[]
19}
20
21export type TicketState = { name: string; color: string; type: string }
22
23export type Ticket = {
24 identifier: string
25 title: string
26 url: string
27 description: string | null
28 priorityLabel: string
29 state: TicketState
30 assignee: { displayName: string } | null
31 creator: { displayName: string } | null
32 labels: { nodes: { name: string; color: string }[] }
33 comments: { nodes: { body: string; createdAt: string; user: { displayName: string } | null }[] }
34}
35
36export type Check = {
37 name: string | null
38 state: 'pass' | 'fail' | 'pending' | 'skipped'
39 url: string | null
40}
41
42export type Thread = {
43 path: string
44 line: number | null
45 author: string | null
46 body: string
47 url: string
48}
49
50export type PullRequest = {
51 number: number
52 title: string
53 url: string
54 state: 'open' | 'draft' | 'merged' | 'closed'
55 review: string | null
56 additions: number
57 deletions: number
58 changed_files: number
59 checks: Check[]
60 threads: Thread[]
61}
62
63export type Loaded<T> =
64 { status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; value: T }
65
66export type TicketView =
67 { status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; ticket: Ticket }
68
69/** Tags the monorepo-touched mod publishes, drawn beside the band. */
70export type ModuleTag = { label: string; background: string; text: string; added: number; removed: number }
71
72declare module 'claude-code' {
73 interface PluginState {
74 'showrunner-mod': {
75 info: SessionInfo | null
76 ticket: TicketView | null
77 pr: Loaded<PullRequest> | null
78 }
79 'monorepo-touched': { tags: ModuleTag[] | null }
80 }
81}
82