SLOPSHOPPER

nexus

Drive Nexus coding-agent threads from Claude Code: a live pane of threads and conversations, /nexus commands, and tools for the model

newpanebandguardcommandtoast
v0.13.0no licenseupdated 2026-10-26ramtinJ95/claude-mods/plugins/nexus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nexus
│ ┃ Nexus ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /nexus │ ┃ ⎿ nexus: Nexus pane open. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ◆ Nexus failed: nexus threads printed something that is not JSON ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ Nexus failed: nexus threads printed something that is not JSON
Pane · Nexus
◆ Nexus threads · All projects not loaded nexus failed (failed): nexus threads printed something that is not JSON. Tip: pick "Dark (ANSI), Nexus pane on the terminal background" in /theme to draw this pane on your terminal's background. ctrl+x tab the keys are at the prompt: ctrl+x then ta…
README

nexus

Drive Nexus from Claude Code instead of its dashboard. Nexus is a self-hosted control plane that runs coding agents in per-thread VMs (pylons) on home-lab nodes, with threads grouped by project. This mod gives you a live pane of those threads beside the chat, a /nexus command, and tools the session's model can call.

Install

/plugin install nexus --marketplace ramtinJ95/claude-mods

Requires the Mac nexus CLI 0.10.0 or later, whose threads carry the control plane's attention and seen marker (an older CLI gets a message naming its version and the one needed), 0.11.0 to compare the changes with the latest turn, 0.17.0 for Claude Code worker threads, and 0.19.0 to mark only the reply shown seen, from the Nexus repository (docs/cli.md there is the contract this mod reads) on PATH, or its path in the mod's cli option (/config). The CLI owns the control plane's URL and proxy, so set those in its own config, not here. Every call goes through the CLI.

The pane

/nexus opens it on the thread whose agent is working (the newest, when several are), else on the thread list, and asks for the keyboard. If the prompt keeps it, the pane's bottom line says so: ctrl+x then tab, or a click, hands it the keys. Docked beside a fullscreen transcript it takes as much width as Claude Code gives a pane, 45% of the terminal, and the full height; above the prompt it takes the full width and at most 24 rows. A title bar at the top says where you are, and the bottom line shows the mode (NORMAL, INSERT or FILTER) and the keys that matter there.

Keys

The pane is driven like vim. Claude Code gives a pane only plain letters and digits, with Shift folded in, so G, ctrl+d, ctrl+u, / and ? are spelled g e, d, u, f and s. The arrows, Tab, Enter and clicks work too.

KeyWhat it does
j k (↓ ↑)Move the cursor: a thread, a menu entry, a changed file, a port, a session, or a block of the conversation. In a diff, scroll it
g g, g eTop, bottom. In a conversation g e follows its end again
d uHalf a page down, up
l hOpen, back. In a thread they walk the tabs Conversation, Changes, Previews and Pylon, and h in a diff goes back to its file in the tree; h h on Conversation goes back to the list with the cursor on the thread you left
Enter, oOpen or press what is under the cursor: a thread, a fold or tool call, a menu entry, a changed file's diff or a folder to fold, a port to publish, a preview's URL to copy. On a session in Pylon, attach in a new Herdr tab when Claude Code runs inside Herdr, else copy nexus attach <thread>
i aWrite to the agent (insert mode). Enter sends, or queues behind a working run; an empty Enter or Esc is back to normal mode
fFilter the list by title (filter mode). Enter keeps the filter, Esc clears it
xUnpublish the preview under the cursor, in Previews. Close the session under the cursor, in Pylon, after asking
yCopy what is under the cursor: a thread's ID, a block's text, a changed file's path, a URL, the attach command, or on a session nexus attach <thread>
mThe Actions menu: abort, retry, retry the pylon, start again, archive, unarchive, copy the attach command, open a session, attach, watch again, delete
nStart a thread, from the list. In a thread, open a session in its pylon
pThe projects: show one project's threads, every project's, or add a project
rRefresh, or watch the thread again. In Changes, read the changes again, waking a paused pylon
cIn Changes, compare with the next base: all the changes since the pylon booted, those of the latest turn, or only the uncommitted ones, then round again. The choice is kept between sessions
sThe key help, which j/k, d/u and g g/g e scroll when the pane is too short for it
qClose the help or the menu, else the pane

Esc in normal mode hands the keyboard back to the prompt and leaves the pane open. When the pane takes the keyboard back after losing it in the filter or a field, as after Esc there, the next Enter on a thread in the list opens nothing and says what a second Enter would open, since it may have been typed for the field.

Views

  • Threads, by project, newest first, under a ▾ project branch header with the project's default branch dim, with the archived and deleted ones folded under each project (▸ 1 archived; Enter on the fold opens it). Each row starts with a status dot, its glyph and colour by state (◉ working in cyan, ✕ failed in red, ○ paused), then the title, the state's word where the pane is wide enough, and how long ago it was active, right-aligned and dim. Where the control plane's attention asks for the person, the word says so instead: new reply, with the title in bold, for a reply no client has marked seen, needs you, also in bold, for a review or landing that ended for your decision, parked for tasks that need /nexus retry, failed for a failed pylon or Claude worker launch, and waiting for a session waiting for input. The row under the cursor opens a line with its node, wants paused when the pylon is not yet where the control plane wants it, when you and the agent were last active, and its ID. The title bar names the project shown (Nexus threads · web, or · All projects) and counts its new replies, its working and open threads and the nodes online.
  • A thread, under a title bar ‹ project / title ◉ working, its dot and word the thread's state, then a dim line with the run's clock, the node, the model and when it started. Four tabs sit under it, Conversation, Changes, Previews and Pylon, as the dashboard's inspector has Changes, Previews and Pylon, with the Actions menu on m, and the composer is always at the bottom.
  • Conversation, drawn as a Claude Code transcript draws its own and filling upward from the composer: your messages after ❯, the agent's answers after ⏺ in Markdown, each tool call as ⏺ Bash(npm test) with its result after ⎿, an edit as a coloured diff, and a failed command with its exit code. Four or more calls in a row fold into one line, such as Read 4 files, ran 1 command, which opens itself while a step runs or failed. A bar marks the block under the cursor, which o opens and y copies; k walks back through older blocks and the window holds still as new ones arrive below, until g e follows the end again. While the run works, ✻ Working… (1m 12s · 6 tools this turn) ticks above the composer, the answer streams in, queued messages show under it and a running command shows its latest output. The pane draws a streaming answer at most ten times a second, however fast the CLI prints it. A very long thread keeps its newest 90,000 characters and says how many older blocks it left out. A Claude Code worker thread's conversation is the transcript Claude Code writes in the pylon, which nexus watch streams only from CLI 0.19.0, so the pane reads it with nexus conversation on each poll of the thread, every five seconds, so it shows the turn's prompts, answers and tool calls in the same way, and Opened pull request #41: <url> once for each pull request Claude opened, but not an answer as it streams or a running command's output. Its run line follows the Claude session's turn, messages Claude has queued show under it, and the composer types into its session: a message to a working Claude is queued, which Claude reads at its next tool step or after the turn, and the notice says whether Claude took the message or queued it, and whether Nexus resumed the session first. Reading the transcript never wakes the pylon, so a paused one shows it as last read, and it is not read while you are attached to the pylon in the Herdr tab the pane opened, until you detach or close it. A send waits up to 160 seconds, since the CLI wakes a paused pylon for up to two minutes first. Abort and Retry the parked tasks are not offered for it, since the harness's run is not Claude's.
  • Changes: the files the agent changed in the pylon's checkout, as the dashboard's Changes panel shows them. A summary line counts the files and lines (6 files · +30017 −5) and says what they are compared with: since the pylon booted, 9abe10e, or with c, since the latest turn began, the latest agent reply's work, its commits and uncommitted changes alike, and with c again since the last commit. Under it is a tree, folders first and then files, each by name, a folder that holds only one folder merged into it (src/checkout). Enter on a folder folds it. A file row has a one-letter badge (A added, M modified, D deleted, R renamed, C copied, T typechange), its name, a renamed file's old path when there is room, and +12 −3 right-aligned, or binary. Enter on a file opens its diff in place of the tree: the badge, the path, vs boot, vs turn or vs last commit and the counts, git's lines about a rename or mode change, then each hunk under a dim ⋯ with its heading and each line with the new file's line number, its sign and the code, on the colours of Claude Code's own diffs. j/k, d/u and g g/g e scroll it, long lines are cut, and h goes back to the file. A diff stops at 20,000 rows and says it was cut. Reading a checkout counts as activity in the pylon and wakes it when paused, so the pane reads the changes only while the pylon runs and the thread is open: when the tab is first shown, when an agent turn ends, on r, and once the pylon runs after you asked to wake it. It never reads on a timer. On a paused pylon the tab says so and Enter or r wakes it and reads; an archived or deleted thread is never read. A refused base, a pylon booted before Nexus recorded it or no latest-turn snapshot yet (a turn not captured, a Claude Code worker, an interactive session), shows the CLI's message and suggests c. The latest turn needs Nexus CLI 0.11.0 or later; an older CLI says so.
  • Previews: each port once, as rows the cursor moves over. A port with a preview is under Previews, as the URL the CLI gives, which is a link while it can be opened (opening a paused one wakes it); Enter or y copies it. A preview whose service the pylon's node reports failed, or whose port has no route into the pylon, shows failed in red with the node's reason and no link, since its URL cannot answer until the service runs again or the port has a route. A port without one is under Ports, where Enter publishes it: the row says so while the CLI waits for the node to serve it, up to two minutes, then the port moves under Previews with its link and the cursor goes with it, or the row shows the CLI's error. A preview stays published, open to every device on the home tailnet, until you unpublish it with x: its row says closing… while the CLI runs, which can take two minutes when the pylon is paused and has to wake first, and then the port is back under Ports. x says why it can't when the preview is a declared service's, the thread is archived, or a publish is still under way. Publishing the port again may give it a new URL. A port a declared service owns without a preview cannot be published, so its row only names the service.
  • Pylon: the pylon's wanted and observed state, its node, when it was last seen, the thread's outputs, for a Claude Code worker thread its launch (Claude Code · claude-opus-5-5 · started, and a stalled launch's newest diagnostic lines in red), a Needs you: line for each reason a review that ended for your decision gives, and the command that attaches to its sessions from a shell in the pylon, which y copies on that block. Under them, the pylon's Herdr sessions, as the dashboard lists them: each one's harness, title, state, pane and session ID. n, or Open a session in the Actions menu, opens a session (below). The session shows as opening while the CLI wakes the pylon and starts its agent, which can take a minute, then as opened until the pylon's node reports it, at most 30 seconds, or the row is gone and the notice gives the CLI's reason it did not open. A paused pylon lists the sessions its next wake brings back as paused, resumes on wake, or resumes on unarchive for an archived thread, and a session the last wake did not bring back as failed, with the reason under it (control planes before CLI 0.8.0 list none while the pylon is paused). x asks before it closes the session under the cursor, with Keep as the default, since closing ends its agent; on a failed one it drops the record, so no wake tries it again. The row says closing… while the CLI runs, and a close the CLI refuses shows its message as it printed it, such as a pane a wake has moved. A paused session cannot be closed until it runs, so x on one says so and closes nothing; Enter on it still attaches, which wakes the pylon. Under the sessions, Attach from this Mac shows nexus attach <thread>, through the CLI the cli option names. Claude Code cannot hand its own terminal to the attach as pi does, so when Claude Code runs inside Herdr, Enter and Attach in the Actions menu run it in a new tab of the same Herdr workspace, labelled nexus attach <thread title>; elsewhere, or when herdr fails, which the notice says, they copy it, to run in a terminal. y always copies it. The pylon's Herdr takes that terminal, where each session is a workspace, until you detach from it; the tab is then back at its shell, any error of the attach still there to read, and you close it or switch back. This is how you log Claude Code in: open a session, attach, pick its workspace and type /login. Attaching needs Nexus CLI 0.9.0 or newer and goes through the control plane; an older CLI attaches over ssh, which upgraded nodes refuse.
  • Open a session, on n in a thread: the harness, Claude Code, pi or a shell, and an optional title, which is the harness's name when left empty. Enter, or Open session, opens it and goes to Pylon; Cancel goes back. Opening and closing sessions need Nexus CLI 0.5.0 or later; an older CLI says so.
  • Start a thread, on n in the list: a title, the project (the one shown, by default), the node and branch it boots (the project's own by default) and a first message. Start again in the Actions menu of a thread whose pylon failed opens it with that thread's project and title.
  • Projects, on p in the list or a thread: All projects, then each project with its repository, then New project…, the one shown marked current. Choosing one shows its threads in the list, the strip and the title's counts, and /nexus opens only its working thread; the start form starts on it. Choosing one from a thread opens its list. The choice is kept between sessions, and a project that no longer exists goes back to all of them, saying so once.
  • Add a project, on New project…: an ID, the repository URL, the node its pylons boot on (the first one online, by default) and the branch they check out (main when left empty). It runs nexus project add and then shows the new project, unless you chose another while it ran. A refusal, such as an ID that exists or is not lowercase letters, digits and dashes, shows the CLI's message as it printed it and keeps the form filled. Adding a project needs Nexus CLI 0.4.0 or later, the form leaves resources, allowed hosts, secrets and logins to the CLI or the model tool below.

Delete in the Actions menu asks first, naming the thread, with Keep as the default. Opening an open thread counts as a visit, which wakes its paused pylon, as the dashboard's page does.

When the control plane cannot be reached, the pane says so in red with the CLI's own message (a proxy that is down points to ts-home status) and keeps showing what it had.

The strip

One row above the prompt shows a chip for each open thread whose attention, as the control plane reports it, wants a look, in the control plane's order: needs you for a failed pylon or Claude worker launch (✕), a review that ended for your decision (!) and parked tasks (✕), new reply for a reply no client has marked seen, the working ones with how long since you last wrote, then waiting for you for a session waiting for input. Enter on a chip, or a click, opens that thread in the pane. Reading a thread in the pane marks its latest reply seen with nexus seen, for every client: the dashboard and pi-nexus drop it too, and a reply you read there is no longer new here. A reply counts as read once the thread's Conversation tab is in front, with no menu or help over it, and has drawn the conversation (the watch's answers, or a Claude Code worker's transcript), at the next poll after that. Leaving the thread marks the reply the pane showed, and a newer reply that came since, or one never drawn, stays new. With CLI 0.19.0 or later the mark names that reply (nexus seen --last-reply-at), so the control plane marks nothing after it, even a reply that lands while the mark runs. An older CLI marks the latest reply, so the pane first reads the thread's details and marks only while they still name the reply shown as the latest unread one. When nexus --version fails or prints no version, the pane marks nothing and says so once for that reply. A mark that fails says so in the pane once, and the reply stays new until a newer reply or opening the thread again asks again. The mod keeps nothing of what you have seen. /nexus strip off hides the strip and stops its poll, and /nexus strip on brings it back. It is on by default.

The pane's background

Claude Code paints a docked pane's gutter and edges with the theme's composerSidebarBackground, grey under the built-in themes, and no element can clear it. The pane paints its own body with the terminal's black, and the mod ships a theme that makes the edges match: pick "Dark (ANSI), Nexus pane on the terminal background" in /theme (its setting value is custom:nexus:nexus-dark-ansi). It is the built-in Dark (ANSI) theme with that one colour changed. The pane says this once, the first time it opens.

Processes

While the pane is open it runs at most two CLI processes: one poll every five seconds, nexus threads for the list or nexus show for the thread shown, followed for a Claude Code worker thread by one nexus conversation, skipped while the pane is behind another, and one nexus watch for that thread. Publishing a port adds one nexus preview --wait until the node serves it, and unpublishing one adds one nexus unpreview until the preview is gone. All of them stop when the pane closes. Opening or closing a session adds one nexus session open or nexus session close, which runs to its end, up to four minutes, even after the pane closes, since the session may open all the same. The Changes tab adds at most one nexus changes and one nexus diff at a time, only on the rules above, and they stop when the pane closes or leaves the thread. Reading a thread with a new reply adds one nexus seen, and leaving a thread one nexus seen, after one nexus show with a CLI before 0.19.0. Each mark reads nexus --version first. It also runs nexus nodes when it opens, on a refresh and with the details or the start form, and nexus projects with the start form and the projects. The strip adds one nexus threads every ten seconds while it is on, one at a time, and none while the pane is polling the list itself. The pane keeps updating while a turn runs, and /nexus runs at once rather than waiting for the turn to end.

/nexus

/nexus                          open the pane
/nexus close                    close it
/nexus open <id>                show a thread in the pane
/nexus new <project> ["title"] <message>
/nexus send <id> <message>      queued behind a running turn
/nexus unpublish <port> [id]    close the preview of a port
/nexus archive|unarchive|abort|retry|retry-pylon|delete [id]
/nexus strip on|off             show or hide the threads above the prompt
/nexus project [<id>|all]       say or choose the project shown
/nexus project add <id> <url> --node <node> [--branch <branch>]
/nexus session open <id> [--harness claude-code|pi|shell] [--title T]
/nexus session close <id> [session]
/nexus session attach <id>

An ID may be a unique prefix, with or without th- (4f2a, th-4f2a). Without one, an action takes the thread open in the pane. new takes its title from the quoted text after the project, else the message's first line. unpublish closes the preview a linked port was published as, as x does in Previews. project with no ID says which project the pane, the strip and /nexus show, and refuses an ID that isn't a project, listing the ones there are. project add adds a project, as New project… does, and shows it; it prints the CLI's message when the CLI refuses. session open opens a session in the thread's pylon, Claude Code unless --harness names another, which can take a minute; a title with spaces goes in quotes, as in a shell (--title "two words" or --title="two words"). session close closes a session by its pane, which ends its agent, or by the ID a failed session in no pane shows; without one it asks which, with Keep them all as the default, and leaves out the paused ones, which close once they run. session attach attaches in a new Herdr tab when Claude Code runs inside Herdr, else gives the command that attaches a terminal to the pylon's sessions, and how to run it. retry runs a thread's parked tasks again, retry-pylon clears a failed pylon so it boots again, and delete names the thread and asks before it deletes, with Keep as the default.

Tools for the model

ToolWhat it does
mcp__nexus__projectsLists projects (read-only)
mcp__nexus__project_addAdds a repository or scratch project and returns the CLI’s Project JSON; refreshes the pane’s catalog without changing the project shown
mcp__nexus__threadsLists threads, optionally for one project, whichever project the pane shows
mcp__nexus__startStarts a thread on a project with a first message and returns its ID. With claude: true the message is a Claude Code worker's first prompt (CLI 0.15.0), model picks the worker's model (CLI 0.16.0), and node picks the node instead of the project's
mcp__nexus__sendSends a message to a thread's agent, or types it into a Claude Code worker's session, saying whether Claude took it or queued it (claude.outcome) and whether Nexus resumed the session (claude.resumed)
mcp__nexus__readA thread's state, the ports its pylon listens on with any preview's URL, and the last 6,000 characters of its conversation; for a Claude Code worker, its transcript and its launch (worker)

| mcp__nexus__wait | Waits until the agent has answered and gone idle, its pylon has failed or the thread can no longer run, or a timeout passes (default 300 s, at most 600), then returns its state and its reply to the latest message (null when that turn wrote none). When parked tasks or a failed pylon stop the thread, needs_human says which /nexus retry command you must run. A Claude Code worker's transcript is read every two seconds instead of watched, a failed or stalled launch settles the wait with its worker diagnostic, a worker still launching is waited on until its launch has started and a turn shows, and an idle worker settles only on a second reading two full seconds later, since a message just typed may not show in its transcript yet; a worker whose transcript ends at a prompt it never answered settles so with a null reply, and a timeout too near for that second reading returns timed_out, unsettled. The timeout covers the first reading and cuts off a reading that outlasts it, returning the last state timed_out; a cancelled wait returns the last state with cancelled: true, unsettled. read a

Source 7 files
hooks/register.tsx 2610 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type {
5  NexusCatalog,
6  NexusChangedFile,
7  NexusBase,
8  NexusCheckout,
9  NexusClaudeShown,
10  NexusDetail,
11  NexusDiffShown,
12  NexusFailure,
13  NexusHarness,
14  NexusList,
15  NexusOpenedSession,
16  NexusSession,
17  NexusPane,
18  NexusPortChange,
19  NexusPreview,
20  NexusProject,
21  NexusProjectDraft,
22  NexusRunState,
23  NexusSessionChanges,
24  NexusSessionDraft,
25  NexusShown,
26  NexusStartDraft,
27  NexusStrip,
28  NexusTab,
29  NexusThread,
30  NexusThreadRow,
31  NexusWatch,
32} from '../types'
33import {
34  ACTIONS,
35  ATTACH_HINT,
36  BASES,
37  type Action,
38  type Reply,
39  FULL_ID,
40  HARNESS_LABEL,
41  NO_CHANGE,
42  NO_SESSION_CHANGES,
43  USAGE,
44  type ClaudeReading,
45  type WatchEntry,
46  type WatchEvent,
47  ago,
48  attachCommand,
49  applyEvent,
50  canReadCheckout,
51  changesArgv,
52  checkoutFacts,
53  checkoutTrigger,
54  diffArgv,
55  fileKey,
56  isNoBase,
57  parseChanges,
58  parseDiff,
59  parseDiffReply,
60  closedKey,
61  groupThreads,
62  herdrError,
63  isSettled,
64  keepTail,
65  kept,
66  foldReply,
67  inScope,
68  leftOut,
69  listOrder,
70  matchThread,
71  parseCommand,
72  parseAdded,
73  projectJson,
74  onlineNode,
75  parseDetail,
76  parseFailure,
77  parseHerdrTab,
78  parseNodes,
79  parseOpenedSession,
80  parsePreview,
81  parseProjects,
82  parseSessionClosed,
83  parseClaudeConversation,
84  parseSent,
85  sentText,
86  type Sent,
87  claudeWatch,
88  claudeState,
89  rowsReply,
90  rowsText,
91  parseVersion,
92  isOlder,
93  isPaneRunning,
94  isWorkerDone,
95  isWorkerIdle,
96  hasDrawnReply,
97  transcriptHolds,
98  isUnread,
99  resumesOn,
100  sessionPlace,
101  parseStarted,
102  parseThreads,
103  parseUnpreviewed,
104  parseWatchEvent,
105  sentence,
106  sessionName,
107  settleSessions,
108  stripChips,
109  transcript,
110} from './model'
111import { COMPOSE, type Effect, FILTER, type MenuAction, fieldKey, fieldOf, press, putFields, ringMoved } from './keys'
112import { StripView } from './strip'
113import { needsHuman, portFacts, projectArgs, TOOLS } from './tools'
114import { type Draw, ListView, PROJECT_KEYS, type PaneActions, ProjectView, SESSION_KEYS, START_KEYS, type Scene, SessionView, StartView, ThreadView, ringOf } from './view'
115
116const PANE = 'nexus'
117const POLL_MS = 5_000
118/** How often the strip reads the list while the pane does not. */
119const STRIP_MS = 10_000
120/** How often the run clock ticks while the thread shown is working. */
121const TICK_MS = 1_000
122/** A `$.process.run` of the CLI: its own requests give up after 30 s without data, and `start` makes two. */
123const RUN_MS = 70_000
124/**
125 * A `$.process.run` of `session open` or `close`: the CLI tries a paused pylon again once a second for up to 120 s, then
126 * waits up to 120 s more for the agent to start.
127 */
128const SESSION_MS = 260_000
129/** A `$.process.run` of `send`: for a Claude Code worker the CLI tries a paused pylon again once a second for up to 120 s. */
130const SEND_MS = 160_000
131/** A `$.process.run` of herdr: a tab made, a command typed into it. */
132const HERDR_MS = 10_000
133const READ_CHARS = 6_000
134const REPLY_CHARS = 4_000
135/**
136 * The most often the pane's watch writes what it read: a streaming answer prints a `live` event ten times a second,
137 * and each write redraws the pane. The first event after a quiet spell is written at once.
138 */
139const FLUSH_MS = 100
140
141const listAtom = atom({ plugin: 'nexus', key: 'list' } as const, { threads: null, failure: null, polledAt: null })
142const WATCH = { plugin: 'nexus', key: 'watch' } as const
143const watchAtom = atom(WATCH, null)
144const LIVE = { plugin: 'nexus', key: 'live' } as const
145const liveAtom = atom(LIVE, null)
146const SHOWN = { plugin: 'nexus', key: 'shown' } as const
147const shownAtom = atom(SHOWN, null)
148const catalogAtom = atom({ plugin: 'nexus', key: 'catalog' } as const, { projects: null, nodes: null, nodesFailure: null, projectsFailure: null })
149const CLAUDE = { plugin: 'nexus', key: 'claude' } as const
150const claudeAtom = atom(CLAUDE, null)
151const NOW = { plugin: 'nexus', key: 'now' } as const
152const nowAtom = atom(NOW, 0)
153const paneAtom = atom({ plugin: 'nexus', key: 'pane' } as const, {
154  view: 'list',
155  tab: 'conversation',
156  selected: null,
157  mode: 'normal',
158  overlay: null,
159  pending: null,
160  listCursor: null,
161  listTop: 0,
162  isRetaken: false,
163  scroll: null,
164  changesCursor: null,
165  diff: null,
166  portCursor: null,
167  pylonCursor: null,
168  toggled: {},
169  filter: '',
170  draft: '',
171  start: null,
172  project: null,
173  session: null,
174  fields: {},
175  notice: null,
176  portChanges: {},
177  sessionChanges: {},
178})
179const STRIP = { plugin: 'nexus', key: 'strip' } as const
180/** What `$.store` keeps under `strip`, mirrored here so a change draws the strip again. */
181const stripAtom = atom(STRIP, { isOn: true })
182const SCOPE = { plugin: 'nexus', key: 'scope' } as const
183/** What `$.store` keeps under `project`, mirrored here so a change draws the list and the strip again. */
184const scopeAtom = atom(SCOPE, null)
185const STRIP_CURSOR = { plugin: 'nexus', key: 'stripCursor' } as const
186const stripCursorAtom = atom(STRIP_CURSOR, null)
187const CHECKOUT = { plugin: 'nexus', key: 'checkout' } as const
188const checkoutAtom = atom(CHECKOUT, null)
189const DIFF = { plugin: 'nexus', key: 'diff' } as const
190const diffAtom = atom(DIFF, null)
191const CHANGES_BASE = { plugin: 'nexus', key: 'changesBase' } as const
192/** What `$.store` keeps under `changesBase`, mirrored here so a change draws the Changes tab again. */
193const changesBaseAtom = atom(CHANGES_BASE, 'start')
194
195/** The CLI to run, from the `cli` option. */
196let cli = 'nexus'
197/** The Herdr workspace Claude Code runs in, read at session start: Enter's attach opens a tab there. Null outside Herdr. */
198let herdrWorkspace: string | null = null
199/**
200 * The pane's two processes at most: one poll at a time, of `threads` for the list or `show` for the thread shown,
201 * and one watch of that thread. The ticker only reads the watch's state, to move the run clock while it works.
202 */
203let poller: { timer: Timer; ticker: Timer; isBusy: boolean; isAgain: boolean; stop: () => void; stopped: Promise<void> } | undefined
204/** The second ask for the keyboard after `/nexus`, cancelled when the pane closes first. */
205let refocus: Timer | undefined
206let watcher: { thread: string; stop: () => void } | undefined
207/**
208 * The Changes tab's children: at most one `changes` and one `diff`, each a child of the pane killed when it closes,
209 * and stopped when the pane leaves their thread or a newer diff takes the place of one.
210 */
211let changesChild: { thread: string; stop: () => void } | undefined
212let diffChild: { thread: string; stop: () => void } | undefined
213/** The Herdr tab's pane where the pane last started `nexus attach`, and its thread. */
214let attached: { thread: string; pane: string } | undefined
215/** The thread whose visit is still to be recorded, once its first `show` says whether it is open. */
216let visit: string | undefined
217/** The strip's poll of the list, which runs while the pane is closed or shows a thread. */
218let stripper: Timer | undefined
219let isStripBusy = false
220/** The pane taking the keys back after Esc left a field or an overlay, scheduled from a drawing. */
221let recovering: Timer | undefined
222/**
223 * The pane's element the ring is on, as far as the mod knows: where its own focus or the engine's `ui.focus` last put
224 * it. Null for one of the engine's stops; undefined before either says.
225 */
226let ringAt: string | null | undefined
227/** The ring being taken home after a drawing left it on nothing, scheduled from that drawing. */
228let rehoming: Timer | undefined
229/** Start submissions so far, which number each form's `request`. */
230let starts = 0
231/** Project adds from the form so far, which number its `request`. */
232let adds = 0
233/**
234 * Orders each port change's confirmation and each `show` asked for, exactly where a clock could give two the same
235 * millisecond: a `show` asked for after a confirmation lists what it confirmed, or what changed since.
236 */
237let sequence = 0
238
239type Outcome<T> = { ok: true; value: T } | { ok: false; failure: NexusFailure }
240
241const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
242const failed = (message: string): Outcome<never> => ({ ok: false, failure: { kind: 'failed', message } })
243const couldNotRun = (error: unknown) =>
244  `could not run ${cli} (${messageOf(error)}); install the Nexus CLI or set its path in the nexus plugin's cli option`
245
246/**
247 * One CLI command with `--json` after its name, `project add`, `session open` and `session close` being its names of
248 * two words: its parsed stdout, or how it failed.
249 */
250async function nexus($: EngineInterface, args: readonly string[], timeoutMs = RUN_MS): Promise<Outcome<unknown>> {
251  const words = args[0] === 'project' || args[0] === 'session' ? 2 : 1
252  let ran: Awaited<ReturnType<EngineInterface['process']['run']>>
253  try {
254    ran = await $.process.run([cli, ...args.slice(0, words), '--json', ...args.slice(words)], { timeoutMs })
255  } catch (error) {
256    return failed(couldNotRun(error))
257  }
258  if (ran.exitCode !== 0) return { ok: false, failure: parseFailure(ran.exitCode, ran.stderr) }
259  try {
260    return { ok: true, value: JSON.parse(ran.stdout) }
261  } catch {
262    return failed(`nexus ${args[0]} printed something that is not JSON`)
263  }
264}
265
266/** The CLI that added the attention, the seen marker and the reply time to every thread, which the list and the details read. */
267const ATTENTION_CLI = [0, 10, 0]
268
269/** `parse` over `threads` or `show`, a contract error from a CLI before 0.10.0 naming the version it needs. */
270async function threadsJson<T>($: EngineInterface, got: Outcome<unknown>, parse: (json: unknown) => T, budget?: Budget): Promise<Outcome<T>> {
271  const value = parsed(got, parse)
272  if (value.ok || !got.ok) return value
273  return { ok: false, failure: await olderThan($, value.failure, ATTENTION_CLI, 'reading threads with their unread replies', budget) }
274}
275
276const threadsFrom = ($: EngineInterface, got: Outcome<unknown>) => threadsJson($, got, parseThreads)
277const detailFrom = ($: EngineInterface, got: Outcome<unknown>, budget?: Budget) => threadsJson($, got, parseDetail, budget)
278
279async function listThreads($: EngineInterface, project?: string): Promise<Outcome<NexusThreadRow[]>> {
280  return threadsFrom($, await nexus($, ['threads', ...(project ? [`--project=${project}`] : [])]))
281}
282
283/** `parse` over a command's JSON, its throw a failure. */
284function parsed<T>(got: Outcome<unknown>, parse: (json: unknown) => T): Outcome<T> {
285  if (!got.ok) return got
286  try {
287    return { ok: true, value: parse(got.value) }
288  } catch (error) {
289    return failed(messageOf(error))
290  }
291}
292
293type ChildEnd = { by: 'taker' } | { by: 'stop' } | { by: 'exit'; failure: NexusFailure | null }
294
295/**
296 * Runs the CLI as a child and hands its stdout to `take`, piece by piece, until `take` answers true, `stop` settles
297 * or the child exits. Leaving the loop kills the child, even while a read is pending, so a stop ends it at once.
298 */
299async function runChild(
300  $: EngineInterface,
301  argv: readonly string[],
302  take: (text: string) => boolean | Promise<boolean>,
303  stop: Promise<void>,
304): Promise<ChildEnd> {
305  const child = $.process.spawn({ argv: [cli, ...argv] })
306  const stopped = stop.then(() => 'stop' as const)
307  let stderr = ''
308  try {
309    for (;;) {
310      const pull = child.next()
311      // A pull the stop outran settles after the loop has gone; nothing reads it.
312      pull.catch(() => undefined)
313      const got = await Promise.race([pull, stopped])
314      if (got === 'stop') return { by: 'stop' }
315      if (got.done) return { by: 'exit', failure: got.value.code === 0 ? null : parseFailure(got.value.code ?? 1, stderr) }
316      if (got.value.stream === 'stderr') stderr += got.value.text
317      else if (await take(got.value.text)) return { by: 'taker' }
318    }
319  } catch (error) {
320    return { by: 'exit', failure: { kind: 'failed', message: couldNotRun(error) } }
321  } finally {
322    void child.return(undefined as never).catch(() => undefined)
323  }
324}
325
326/**
327 * Runs `nexus watch <thread> --json` and hands each event to `take` until it answers true, `stop` settles or the
328 * child exits. The exit is clean (no failure) after the thread's deletion; a line that breaks the contract ends the
329 * watch with a failure that says so.
330 */
331async function followWatch(
332  $: EngineInterface,
333  thread: string,
334  take: (event: WatchEvent) => boolean | Promise<boolean>,
335  stop: Promise<void>,
336): Promise<ChildEnd> {
337  let pending = ''
338  let broken: NexusFailure | undefined
339  const end = await runChild(
340    $,
341    ['watch', thread, '--json'],
342    async text => {
343      pending += text
344      const lines = pending.split('\n')
345      pending = lines.pop()!
346      for (const line of lines) {
347        let event: WatchEvent | undefined
348        try {
349          event = parseWatchEvent(line)
350        } catch (error) {
351          broken = { kind: 'failed', message: messageOf(error) }
352          return true
353        }
354        if (event && (await take(event))) return true
355      }
356      return false
357    },
358    stop,
359  )
360  return broken ? { by: 'exit', failure: broken } : end
361}
362
363/** One CLI command as a child `stop` kills, such as a poll of the pane: a closed pane leaves none running. Undefined once stopped. */
364async function childJson($: EngineInterface, argv: readonly string[], stop: Promise<void>): Promise<Outcome<unknown> | undefined> {
365  let stdout = ''
366  const words = argv[0] === 'project' || argv[0] === 'session' ? 2 : 1
367  const end = await runChild(
368    $,
369    [...argv.slice(0, words), '--json', ...argv.slice(words)],
370    text => {
371      stdout += text
372      return false
373    },
374    stop,
375  )
376  if (end.by !== 'exit') return undefined
377  if (end.failure) return { ok: false, failure: end.failure }
378  try {
379    return { ok: true, value: JSON.parse(stdout) }
380  } catch {
381    return failed(`nexus ${argv[0]} printed something that is not JSON`)
382  }
383}
384
385async function toolJson($: EngineInterface, args: readonly string[], signal: AbortSignal, ms = RUN_MS): Promise<Outcome<unknown>> {
386  if (signal.aborted) return failed('cancelled')
387  if (ms <= 0) return failed('timed out')
388  const { promise, stop } = stopper()
389  const timer = $.clock.after(ms, stop)
390  signal.addEventListener('abort', stop)
391  try {
392    const got = await childJson($, args, promise)
393    return signal.aborted ? failed('cancelled') : got ?? failed('timed out')
394  } finally {
395    timer.cancel()
396    signal.removeEventListener('abort', stop)
397  }
398}
399
400/** A promise `stop()` settles, for `runChild`. */
401function stopper() {
402  let stop = () => {}
403  const promise = new Promise<void>(resolve => {
404    stop = resolve
405  })
406  return { promise, stop }
407}
408
409/**
410 * The thread open in the pane, watched until the pane leaves it. Events for a thread no longer shown are dropped.
411 * What it reads is gathered and written at most every `FLUSH_MS`, in order.
412 */
413function watchInPane($: EngineInterface, thread: string) {
414  watcher?.stop()
415  const { promise, stop } = stopper()
416  /** What this watch has written: only it writes the watch while it is the current one, so it needs no read back. */
417  let watched: NexusWatch = { thread, rows: [], dropped: 0, state: null, model: null, outage: null, ended: null }
418  let gathered: WatchEvent[] = []
419  /** The write about to run, and the wait after a write during which events only gather. */
420  let soon: Timer | undefined
421  let cooling: Timer | undefined
422  // A stopped watch may still hold lines it read: only the current one writes, so a rewatch of the same thread
423  // never takes an older reader's entries or its exit.
424  const mine = {
425    thread,
426    stop: () => {
427      if (watcher === mine) watcher = undefined
428      soon?.cancel()
429      cooling?.cancel()
430      stop()
431    },
432  }
433  watcher = mine
434  // The rows and state, then the newest live event, which stands for the others, are sent with no wait between
435  // them, so no drawing reads one without the other: the CLI prints an answer's entry before the live event that
436  // drops it from `partial`, and a drawing between the two writes would show the answer twice.
437  const write = () => {
438    soon?.cancel()
439    soon = undefined
440    const events = gathered
441    gathered = []
442    cooling = $.clock.after(FLUSH_MS, () => {
443      cooling = undefined
444      if (gathered.length > 0) void write()
445    })
446    if (watcher !== mine) return Promise.resolve()
447    const rows = events.filter(event => event.type !== 'live')
448    const live = events.findLast((event): event is Extract<WatchEvent, { type: 'live' }> => event.type === 'live')
449    watched = rows.reduce(applyEvent, watched)
450    return Promise.all([rows.length > 0 && $.state.set(WATCH, watched), live && $.state.set(LIVE, { thread, live: live.live })])
451  }
452  void (async () => {
453    await Promise.all([$.state.set(WATCH, watched), $.state.set(LIVE, null)])
454    const end = await followWatch(
455      $,
456      thread,
457      event => {
458        if (watcher !== mine) return true
459        gathered.push(event)
460        // A changed state may come with changed details: the pylon, its previews and ports, the parked tasks.
461        if (event.type === 'state') void poll($)
462        // A zero wait lets the rest of the lines read with this one join its write.
463        if (!soon && !cooling) soon = $.clock.after(0, () => void write())
464        return false
465      },
466      promise,
467    )
468    const last = write()
469    cooling?.cancel()
470    await last
471    if (watcher !== mine || end.by !== 'exit') return
472    watcher = undefined
473    watched = { ...watched, ended: end.failure ? `The watch stopped: ${sentence(end.failure.message)} r watches again.` : 'The thread was deleted.' }
474    await $.state.set(WATCH, watched)
475  })()
476}
477
478/** Closes the pane and, if no hook kept it open, stops its processes. */
479async function closePane($: EngineInterface) {
480  await $.ui.close({ id: PANE })
481  if ((await $.ui.panes()).some(one => one.id === PANE)) return false
482  stopPane()
483  return true
484}
485
486function stopPane() {
487  poller?.timer.cancel()
488  poller?.ticker.cancel()
489  poller?.stop()
490  poller = undefined
491  refocus?.cancel()
492  refocus = undefined
493  watcher?.stop()
494}
495
496/**
497 * One poll of what the pane shows, skipped while one runs or the pane is behind another: `threads` for the list,
498 * `show` for a thread. The pane gone stops every process. Closing the pane kills a poll in flight, and one that
499 * ends as the pane closes writes nothing.
500 */
501async function poll($: EngineInterface) {
502  const current = poller
503  if (!current || current.isBusy) return
504  current.isBusy = true
505  try {
506    const pane = (await $.ui.panes()).find(one => one.id === PANE)
507    if (!pane) return stopPane()
508    if (!pane.isShown) return
509    const { view, selected } = await read($, paneAtom)
510    if (view === 'start') return
511    const thread = view === 'list' ? null : selected
512    sequence += 1
513    const askedSeq = sequence
514    const got = await childJson($, thread ? ['show', thread] : ['threads', '--all'], current.stopped)
515    const now = await $.clock.now()
516    if (!got || poller !== current) return
517    await $.state.set(NOW, now)
518    if (!thread) {
519      const threads = await threadsFrom($, got)
520      await update($, listAtom, (list): NexusList => (threads.ok ? { threads: threads.value, failure: null, polledAt: now } : { ...list, failure: threads.failure }))
521      return
522    }
523    const detail = await detailFrom($, got)
524    if ((await read($, paneAtom)).selected !== thread) return
525    const shownBefore = await read($, shownAtom)
526    const before = shownBefore?.thread === thread ? shownBefore.detail : null
527    if (detail.ok) {
528      void recordVisit($, detail.value.thread)
529      await patchRow($, detail.value)
530    }
531    await update($, shownAtom, (shown): NexusShown =>
532      detail.ok
533        ? { thread, detail: detail.value, askedSeq, failure: null }
534        : shown?.thread === thread
535          ? { ...shown, failure: detail.failure }
536          : { thread, detail: null, askedSeq, failure: detail.failure },
537    )
538    if (detail.ok) void noticeCheckout($, thread, before, detail.value)
539    // The opens and closes these details show, or that waited long enough for them, are drawn from the details now.
540    const changes = (await read($, paneAtom)).sessionChanges[thread]
541    if (detail.ok && changes && settleSessions(changes, detail.value.sessions, now) !== changes) {
542      await changeSessions($, thread, was => settleSessions(was, detail.value.sessions, now))
543    }
544    if (!detail.ok) return
545    // While the person is attached to the pylon in a Herdr tab, the worker's transcript is not read: they see it there.
546    const isDrawn =
547      detail.value.conversation === 'claude'
548        ? !(await isAttached($, thread)) && transcriptHolds(await readClaude($, thread, current), detail.value.thread.lastReplyAt)
549        : hasDrawnReply(await read($, watchAtom), thread)
550    const { selected: still, view: on, tab, overlay } = await read($, paneAtom)
551    if (!isDrawn || still !== thread || on !== 'thread' || tab !== 'conversation' || overlay) return
552    // The person reads the reply in the pane: once every client counts it seen, the details read again drop it.
553    const reply = detail.value.thread.lastReplyAt
554    displayed.set(thread, reply)
555    if (isUnread(detail.value.thread) && refusedSeen.get(thread) !== reply) void seenMark($).then(mark => markSeen($, thread, reply, mark).then(seen => seen && pollAgain($)))
556  } finally {
557    current.isBusy = false
558    if (current.isAgain && poller === current) {
559      current.isAgain = false
560      void poll($)
561    }
562  }
563}
564
565/**
566 * The Claude Code worker transcript of the thread shown, as part of its poll, since `nexus watch` streams none: one
567 * `nexus conversation`, which never wakes the pylon and reads it only while it runs. A failed read keeps the rows it had.
568 * Says when the transcript was read from the pylon, null when it never was or the read failed.
569 */
570async function readClaude($: EngineInterface, thread: string, current: NonNullable<typeof poller>): Promise<number | null> {
571  const got = await childJson($, ['conversation', thread], current.stopped)
572  if (!got || poller !== current || (await read($, paneAtom)).selected !== thread) return null
573  const reading = parsed(got, parseClaudeConversation)
574  await update($, claudeAtom, (was): NexusClaudeShown =>
575    reading.ok
576      ? { thread, ...kept(reading.value.rows, reading.value.dropped), working: reading.value.working, queued: reading.value.queued, readAt: reading.value.readAt, failure: null }
577      : { ...(was?.thread === thread ? was : { thread, rows: [], dropped: 0, working: false, queued: [], readAt: null }), failure: reading.failure.message },
578  )
579  return reading.ok ? reading.value.readAt : null
580}
581
582/** Polls now, or once the poll under way ends, since that one may have read what was there before a change. */
583function pollAgain($: EngineInterface) {
584  if (poller?.isBusy) poller.isAgain = true
585  else void poll($)
586}
587
588/** Moves the run clock while the thread shown works, so its timer and spinner tick. */
589async function tick($: EngineInterface) {
590  const [watch, claude] = await Promise.all([read($, watchAtom), read($, claudeAtom)])
591  if (watch?.state?.run === 'working' || claude?.working) await $.state.set(NOW, await $.clock.now())
592}
593
594/** `projects` and `nodes` for the start form, the project picker, the list's nodes line and the previews' links. */
595async function loadCatalog($: EngineInterface, wanted: { projects: boolean }) {
596  const asked = scopeRevision
597  const [nodes, projects] = await Promise.all([
598    nexus($, ['nodes']).then(got => parsed(got, parseNodes)),
599    wanted.projects ? nexus($, ['projects']).then(got => parsed(got, parseProjects)) : undefined,
600  ])
601  await update($, catalogAtom, (catalog): NexusCatalog => ({
602    ...catalog,
603    ...(nodes.ok ? { nodes: nodes.value, nodesFailure: null } : { nodesFailure: nodes.failure }),
604    ...(projects ? (projects.ok ? { projects: projects.value, projectsFailure: null } : { projectsFailure: projects.failure }) : {}),
605  }))
606  if (projects?.ok) await checkScope($, projects.value, asked)
607}
608
609/** The scope as `$.store` kept it, and a check that its project still exists. */
610async function loadScope($: EngineInterface) {
611  const asked = scopeRevision
612  const stored = await $.store.get('project')
613  if (typeof stored !== 'string') return
614  if ((await setScope($, stored, asked)).isSet) void loadCatalog($, { projects: true })
615}
616
617/** Counts the scope's writes, so a write decided on an older scope can tell that another came between. */
618let scopeRevision = 0
619let scopeTurn: Promise<unknown> = Promise.resolve()
620
621/**
622 * Shows `project`'s threads, or every project's for null, in this session and later ones. Every write of the scope
623 * comes here and runs in turn, the atom and then `$.store`, so the store never ends on an older scope than the pane
624 * shows. With `asked`, the revision its caller read, it writes only if no other write came since: comparing projects
625 * would miss a choice undone, as api and then All again while an add asked from All ran. Says what the pane shows.
626 */
627function setScope($: EngineInterface, project: string | null, asked?: number): Promise<{ isSet: boolean; shown: string | null }> {
628  const turn = scopeTurn.then(async () => {
629    if (asked !== undefined && asked !== scopeRevision) return { isSet: false, shown: await read($, scopeAtom) }
630    scopeRevision += 1
631    await $.state.set(SCOPE, project)
632    await $.store.set('project', project)
633    return { isSet: true, shown: project }
634  })
635  scopeTurn = turn.catch(() => undefined)
636  return turn
637}
638
639/**
640 * Goes back to every project, saying so once, when the project shown is not among `projects`, as after it was
641 * deleted; saving that keeps the notice from coming back. `asked` is the scope's revision when the list was asked for:
642 * a list read before another project was chosen, or added, cannot tell whether that one exists.
643 */
644async function checkScope($: EngineInterface, projects: readonly NexusProject[], asked: number) {
645  const gone = await read($, scopeAtom)
646  if (gone === null || projects.some(project => project.id === gone)) return
647  if ((await setScope($, null, asked)).isSet) await $.ui.toast(`The project ${gone} no longer exists, so Nexus shows every project again.`)
648}
649
650/**
651 * Shows the project just added, unless the scope changed while the CLI added it, from `asked`, its revision then:
652 * that choice is the newer one. Says which the pane shows.
653 */
654async function showAdded($: EngineInterface, asked: number, id: string) {
655  const { isSet, shown } = await setScope($, id, asked)
656  if (isSet) return 'Nexus shows its threads.'
657  return `Nexus still shows ${shown === null ? 'every project' : `the project ${shown}`}, chosen while it was added.`
658}
659
660/**
661 * `nexus project add`: the project added, its list read again. A CLI before 0.4.0 says only that it does not know the
662 * command.
663 */
664async function addProject($: EngineInterface, add: { id: string; repoUrl: string; node: string; branch: string }): Promise<Outcome<NexusProject>> {
665  // After `--` the CLI's parseArgs reads the ID and URL as given, though one starts with dashes.
666  const got = await nexus($, ['project', 'add', `--node=${add.node}`, `--branch=${add.branch}`, '--', add.id, add.repoUrl])
667  if (!got.ok && got.failure.message === 'unknown command project') {
668    return { ok: false, failure: { kind: got.failure.kind, message: `${cli} has no project add command: adding a project needs Nexus CLI 0.4.0 or later (nexus --version)` } }
669  }
670  const added = parsed(got, parseAdded)
671  if (added.ok) void loadCatalog($, { projects: true })
672  return added
673}
674
675/** Starts what an open pane needs, keeping what runs: the poll, polled now, the ticker and the watch of the thread it shows. */
676async function startPane($: EngineInterface) {
677  if (!poller) {
678    const { promise, stop } = stopper()
679    poller = {
680      timer: $.clock.every(POLL_MS, () => void poll($)),
681      ticker: $.clock.every(TICK_MS, () => void tick($)),
682      isBusy: false,
683      isAgain: false,
684      stop,
685      stopped: promise,
686    }
687    // The projects too: the list heads each with its default branch.
688    void loadCatalog($, { projects: true })
689    // A port's CLI ran under the poller before this one, or the module before a reload: none is under way now. A
690    // session's open or close outlives the pane, so only those a reload ended go.
691    await update($, paneAtom, pane => ({
692      ...pane,
693      portChanges: {},
694      sessionChanges: Object.fromEntries(
695        Object.entries(pane.sessionChanges).map(([thread, changes]) => [
696          thread,
697          { ...changes, opening: changes.opening.filter(one => sessionOps.has(`open:${one.id}`)), closing: changes.closing.filter(pane => sessionOps.has(`close:${thread}:${pane}`)) },
698        ]),
699      ),
700    }))
701  }
702  void poll($)
703  const { selected, view } = await read($, paneAtom)
704  if (selected && view !== 'list' && watcher?.thread !== selected) watchInPane($, selected)
705}
706
707/**
708 * The dock is as wide as the engine allows, at most 45% of the terminal, so the conversation reads like the
709 * transcript beside it. Seated above the prompt, the pane takes the full width.
710 */
711const OPEN = { id: PANE, title: 'Nexus', focus: true, columns: 200 } as const
712/** How long after `/nexus` the pane asks for the keys again, once the composer has let go of the command line. */
713const REFOCUS_MS = 300
714
715/** Asks for the keyboard again, only for a pane still open and not yet focused: a closed one stays closed. */
716async function refocusPane($: EngineInterface) {
717  refocus = undefined
718  const pane = (await $.ui.panes()).find(one => one.id === PANE)
719  if (pane && !pane.isFocused) await $.ui.open(OPEN)
720}
721
722async function openPane($: EngineInterface): Promise<string> {
723  const opened = await $.ui.open(OPEN)
724  await startPane($)
725  if (!opened.isPlaced) return ` It is not shown yet: ${opened.reason}.`
726  await themeNotice($)
727  // `focus` is only granted over an empty composer, which an immediate command may still be clearing. Asking once
728  // more is refused in turn if the person has started typing, so it never takes keys they are using.
729  const pane = (await $.ui.panes()).find(one => one.id === PANE)
730  if (pane?.isFocused) return ''
731  refocus?.cancel()
732  refocus = $.clock.after(REFOCUS_MS, () => void refocusPane($))
733  return ''
734}
735
736/** Says once, the first time the pane opens, how to draw it on the terminal's own background. */
737async function themeNotice($: EngineInterface) {
738  if (await $.store.get('themeNotice')) return
739  await $.store.set('themeNotice', true)
740  await update($, paneAtom, (pane): NexusPane => ({
741    ...pane,
742    notice: { text: `Tip: pick "${THEME_NAME}" in /theme to draw this pane on your terminal's background.`, tone: 'dim' },
743  }))
744}
745
746const THEME_NAME = 'Dark (ANSI), Nexus pane on the terminal background'
747
748/**
749 * The pane opened by `/nexus` with nothing shown opens on the thread whose agent works in the project shown, the
750 * newest when several do, reading the list first when no poll has.
751 */
752async function openWorking($: EngineInterface) {
753  const pane = await read($, paneAtom)
754  if (pane.selected || pane.view !== 'list') return
755  let threads = (await read($, listAtom)).threads
756  if (!threads) {
757    const got = await threadsFrom($, await nexus($, ['threads', '--all']))
758    if (!got.ok) return
759    const now = await $.clock.now()
760    await update($, listAtom, (): NexusList => ({ threads: got.value, failure: null, polledAt: now }))
761    threads = got.value
762  }
763  const working = inScope(threads, await read($, scopeAtom))
764    .filter(thread => thread.agentTurnRunning && (thread.status === 'active' || thread.status === 'idle'))
765    .sort((a, b) => b.lastActivityAt - a.lastActivityAt)[0]
766  if (working) await showThread($, working.id)
767}
768
769async function setPane($: EngineInterface, change: (pane: NexusPane) => Partial<NexusPane>) {
770  await update($, paneAtom, pane => ({ ...pane, notice: null, ...change(pane) }))
771}
772
773/** The pane showing `thread`, keeping what the person opened, closed and chose in it when it was shown already. */
774const threadView = (pane: NexusPane, thread: string): Partial<NexusPane> => {
775  const isSame = pane.selected === thread
776  return {
777    view: 'thread',
778    selected: thread,
779    tab: isSame ? pane.tab : 'conversation',
780    mode: 'normal',
781    overlay: null,
782    pending: null,
783    scroll: isSame ? pane.scroll : null,
784    changesCursor: isSame ? pane.changesCursor : null,
785    diff: isSame ? pane.diff : null,
786    portCursor: isSame ? pane.portCursor : null,
787    pylonCursor: isSame ? pane.pylonCursor : null,
788    listCursor: thread,
789    draft: '',
790    fields: putFields(pane.fields, { [COMPOSE]: '' }),
791    start: null,
792    session: null,
793    notice: null,
794    toggled: isSame ? pane.toggled : {},
795  }
796}
797
798/** Shows a thread: its watch, and its details polled, the first of which records the visit as the dashboard's page does. */
799async function showThread($: EngineInterface, thread: string) {
800  let left: string | null = null
801  await setPane($, pane => {
802    left = pane.view === 'thread' && pane.selected !== thread ? pane.selected : null
803    return threadView(pane, thread)
804  })
805  if (left) void markLeft($, left)
806  refusedSeen.delete(thread)
807  await followThread($, thread)
808}
809
810/**
811 * Whether the strip shows, as `$.store` kept it. A store written before the control plane kept the seen marker also
812 * holds what the person saw then: it is written again without it.
813 */
814async function loadStrip($: EngineInterface) {
815  const stored = (await $.store.get('strip')) as Record<string, unknown> | undefined
816  const strip: NexusStrip = { isOn: stored?.isOn !== false }
817  await $.state.set(STRIP, strip)
818  if (stored && Object.keys(stored).some(key => key !== 'isOn')) await $.store.set('strip', strip)
819}
820
821async function saveStrip($: EngineInterface, change: (strip: NexusStrip) => NexusStrip) {
822  await $.store.set('strip', await update($, stripAtom, change))
823}
824
825/** The threads whose `nexus seen` runs, so a poll that comes again before it ends does not start a second. */
826const seeing = new Set<string>()
827/** The reply whose mark failed, by thread: polls leave it until another reply comes or the thread is opened again. */
828const refusedSeen = new Map<string, number | null>()
829/** The latest reply the pane showed in each thread's conversation, by its `lastReplyAt`: what leaving the thread may mark. */
830const displayed = new Map<string, number | null>()
831
832/** The CLI whose `seen` takes the reply shown (`--last-reply-at`), so a mark never passes it. */
833const SEEN_AT_CLI = [0, 19, 0]
834
835/**
836 * Leaving `thread` marks the reply the pane showed there, and no newer one. A CLI before 0.19.0 marks the latest reply,
837 * so it reads the details first and marks only while they still name the reply shown as the latest and unread.
838 */
839async function markLeft($: EngineInterface, thread: string): Promise<boolean> {
840  if (!displayed.has(thread)) return false
841  const reply = displayed.get(thread) ?? null
842  displayed.delete(thread)
843  if (reply === null) return false
844  const mark = await seenMark($)
845  if (mark === 'latest') {
846    const detail = await detailFrom($, await nexus($, ['show', thread]))
847    if (!detail.ok || detail.value.thread.lastReplyAt !== reply || !isUnread(detail.value.thread)) return false
848  }
849  return markSeen($, thread, reply, mark)
850}
851
852/**
853 * How `seen` can mark, read once for each mark: only up to the reply shown (`exact`, CLI 0.19.0), the latest reply (an
854 * older CLI), or `unknown` when `nexus --version` failed or printed no version, which marks nothing.
855 */
856type SeenMark = 'exact' | 'latest' | 'unknown'
857
858async function seenMark($: EngineInterface): Promise<SeenMark> {
859  const found = await cliVersion($)
860  return found === null ? 'unknown' : isOlder(found, SEEN_AT_CLI) ? 'latest' : 'exact'
861}
862
863/**
864 * Marks the reply the pane showed, `reply`, seen on the control plane, for every client. A CLI before 0.19.0 takes no
865 * reply time and marks the reply latest when it runs, which the pane draws within a moment of its arrival. A failure
866 * says so in the pane, as the reply stays new everywhere, and polls do not ask again for that reply. With the CLI's
867 * version unknown it marks nothing, since a plain `seen` could mark a reply never shown, and says so once for the reply.
868 * Whether it marked.
869 */
870async function markSeen($: EngineInterface, thread: string, reply: number | null, mark: SeenMark): Promise<boolean> {
871  if (mark === 'unknown') {
872    if (refusedSeen.get(thread) === reply) return false
873    refusedSeen.set(thread, reply)
874    await setPane($, () => ({ notice: { text: `The reply in ${thread} was not marked seen, so it still shows as new: ${cli} --version gave no version, so the pane cannot mark only the reply it showed.`, tone: 'error' } }))
875    return false
876  }
877  if (seeing.has(thread)) return false
878  seeing.add(thread)
879  try {
880    const got = await nexus($, ['seen', thread, ...(mark === 'exact' && reply !== null ? [`--last-reply-at=${reply}`] : [])])
881    if (got.ok) {
882      refusedSeen.delete(thread)
883      return true
884    }
885    refusedSeen.set(thread, reply)
886    const failure = newer(got.failure, 'seen', 'marking a reply seen', '0.10.0')
887    await setPane($, () => ({ notice: { text: `The reply in ${thread} was not marked seen, so it still shows as new: ${sentence(failure.message)}`, tone: 'error' } }))
888    return false
889  } finally {
890    seeing.delete(thread)
891  }
892}
893
894/** The thread's row in the list as its details just read it, so the list and the strip need not wait for their poll. */
895async function patchRow($: EngineInterface, detail: NexusDetail) {
896  const { thread, attention } = detail
897  await update($, listAtom, (list): NexusList =>
898    list.threads?.some(one => one.id === thread.id)
899      ? { ...list, threads: list.threads.map(one => (one.id === thread.id ? { ...one, attention, seenAt: thread.seenAt, lastReplyAt: thread.lastReplyAt } : one)) }
900      : list,
901  )
902}
903
904/**
905 * The strip's poll of the list, one `threads --all` at a time, while the strip is on. Skipped while the pane polls the
906 * list itself, so the two never both run.
907 */
908async function pollStrip($: EngineInterface) {
909  if (isStripBusy || !(await read($, stripAtom)).isOn) return
910  if (poller && (await read($, paneAtom)).view === 'list' && (await $.ui.panes()).some(one => one.id === PANE && one.isShown)) return
911  isStripBusy = true
912  try {
913    const asked = await $.clock.now()
914    const threads = await threadsFrom($, await nexus($, ['threads', '--all']))
915    // The pane may have read the list while this poll ran, or the strip been turned off: then this answer is stale.
916    if (!(await read($, stripAtom)).isOn) return
917    await update($, listAtom, (list): NexusList => (list.polledAt !== null && list.polledAt >= asked ? list : threads.ok ? { threads: threads.value, failure: null, polledAt: asked } : { ...list, failure: threads.failure }))
918  } finally {
919    isStripBusy = false
920  }
921}
922
923/**
924 * A chip pressed: the pane opens on that thread. The band holds the keys during the press, so the open's focus is
925 * refused (live test, 2.1.310) and the pane's mode line says how to hand it the keys.
926 */
927async function openFromStrip($: EngineInterface, id: string) {
928  await showThread($, id)
929  await openPane($)
930}
931
932/**
933 * Esc in a field or an overlay hands the keys to the prompt and raises no event, so a drawing that finds the pane
934 * unfocused in insert mode, filter mode or under an overlay sends it here: back to normal mode, the filter cleared,
935 * and the keys asked back. The prompt refuses them while it holds a draft; the mode line then says how to take them.
936 */
937async function recover($: EngineInterface) {
938  recovering = undefined
939  const pane = await update($, paneAtom, (was): NexusPane =>
940    was.mode === 'normal' && !was.overlay
941      ? was
942      : { ...was, mode: 'normal', overlay: null, pending: null, isRetaken: was.view === 'list', ...(was.mode === 'filter' ? { filter: '', fields: putFields(was.fields, { [FILTER]: '' }) } : {}) },
943  )
944  // A pane closed since stays closed; asking for the keys it holds already changes nothing.
945  if (!(await $.ui.panes()).some(one => one.id === PANE)) return
946  await $.ui.open(OPEN)
947  await focusRing($, pane)
948}
949
950/** Puts the ring where normal mode wants it: the list's cursor row, or the thread's tab, whose Enter acts as o. */
951function focusRing($: EngineInterface, pane: NexusPane) {
952  const key = pane.view === 'list' ? pane.listCursor : pane.view === 'thread' ? `tab-${pane.tab}` : null
953  return key ? focusOn($, key) : Promise.resolve()
954}
955
956/**
957 * After a drawing: an element taken away while it held the ring (a port published into a preview, rows the help or
958 * a closed menu hid, a form left) leaves the keys with nothing until Tab, and the composer or filter holding it in
959 * normal mode takes letters as text. Either way the ring goes home: the view's cursor, else its tab. Insert and
960 * filter mode keep it, the field being where the person put it; a pane without the keys has none to move.
961 */
962function rehome($: EngineInterface, tree: unknown, pane: NexusPane, isFocused: boolean) {
963  if (!isFocused || pane.mode !== 'normal' || !ringAt || rehoming) return
964  const { keys, home } = ringOf(tree)
965  const isLost = !keys.has(ringAt) || fieldOf(ringAt) === COMPOSE || fieldOf(ringAt) === FILTER
966  if (!isLost || !home || home === ringAt) return
967  const lost = ringAt
968  rehoming = $.clock.after(0, () => {
969    rehoming = undefined
970    // The mod may have put the ring somewhere since (a form's field put back under a new key): that stands.
971    if (ringAt === lost) void focusOn($, home)
972  })
973}
974
975/**
976 * The list filtered by `text`. Each change of the filter opens the closed groups it matches afresh, as the dashboard's
977 * does, so a group the person folded or opened before follows the new filter.
978 */
979const filtered = (pane: NexusPane, text: string): NexusPane =>
980  text === pane.filter ? pane : { ...pane, filter: text, toggled: Object.fromEntries(Object.entries(pane.toggled).filter(([key]) => !key.startsWith(closedKey('')))) }
981
982/**
983 * Enter in a form's field: once the submit's hooks are done, the engine empties the field it delivered, unless it was
984 * typed in since (2.1.310's field store). A form that stays (a refusal, a field missing) must still show what it
985 * would send, so the field is put again with its text, under a new key, and the ring follows it there. A field
986 * typed in since holds `form`'s newer text, which the engine keeps: it is left alone.
987 */
988async function putBack<Field extends string>(
989  $: EngineInterface,
990  keys: Record<Field, string>,
991  change: Partial<Record<Field, string>>,
992  form: (pane: NexusPane) => Partial<Record<NoInfer<Field>, unknown>> | null,
993) {
994  let name: string | undefined
995  const pane = await update($, paneAtom, was => {
996    const held = form(was)
997    const texts = Object.fromEntries(Object.entries(change).flatMap(([field, text]) => (held?.[field as Field] === text ? [[keys[field as Field], text as string]] : [])))
998    name = Object.keys(texts)[0]
999    return name ? { ...was, fields: putFields(was.fields, texts) } : was
1000  })
1001  if (name) await focusOn($, fieldKey(pane.fields, name))
1002}
1003
1004/**
1005 * Runs the pane's field changes one after another, in the order the engine delivered them. Each handler answers the
1006 * engine at once and writes later, and `update` tries again when its write misses: a burst (held backspace, fast
1007 * typing) could otherwise land out of order, the older text written last. A submit's snapshot queues here too.
1008 */
1009let fieldTurn: Promise<unknown> = Promise.resolve()
1010function inTurn<T>(fn: () => Promise<T>): Promise<T> {
1011  const turn = fieldTurn.then(fn)
1012  fieldTurn = turn.catch(() => undefined)
1013  return turn
1014}
1015
1016/** Whether the field drawn as `key` is still drawn so: a put draws it anew, and keys typed into the old one go with it. */
1017const isDrawn = (pane: NexusPane, key: string) => fieldKey(pane.fields, fieldOf(key)) === key
1018
1019/** Asks for the ring on `key`; a refusal (not drawn yet, or the pane lacks the keys) leaves it where it is. */
1020const focusOn = ($: EngineInterface, key: string) =>
1021  $.ui.focus({ requestId: PANE, key }).then(
1022    result => {
1023      if (!result.deny) ringAt = key
1024    },
1025    () => undefined,
1026  )
1027
1028
1029/** What the thread just shown runs: its details polled afresh and its watch, while the pane is open. */
1030async function followThread($: EngineInterface, thread: string) {
1031  const shown = await read($, shownAtom)
1032  if (shown?.thread !== thread) await Promise.all([$.state.set(SHOWN, null), $.state.set(CLAUDE, null)])
1033  await leaveCheckout($, thread)
1034  // The person may have moved on during those awaits: only the thread still shown is followed.
1035  const pane = await read($, paneAtom)
1036  if (pane.selected !== thread || pane.view === 'list' || pane.view === 'start') return
1037  visit = thread
1038  // A closed pane watches nothing: opening it again starts the watch of the thread it shows.
1039  if (poller) watchInPane($, thread)
1040  void poll($)
1041}
1042
1043/** Records a visit to an open thread as human activity, which wakes its paused pylon; a closed one's stays paused. */
1044async function recordVisit($: EngineInterface, thread: NexusThread) {
1045  if (visit !== thread.id) return
1046  visit = undefined
1047  if (thread.status !== 'active' && thread.status !== 'idle') return
1048  const woken = await nexus($, ['wake', thread.id])
1049  if (!woken.ok) await setPane($, () => ({ notice: { text: `This visit was not recorded, so a paused pylon stays paused: ${sentence(woken.failure.message)}`, tone: 'error' } }))
1050}
1051
1052/** A full thread ID for `arg`: as given, else the one thread whose ID starts with it, from the last poll or a fresh one. */
1053async function resolveThread($: EngineInterface, arg: string): Promise<Outcome<string>> {
1054  if (FULL_ID.test(arg)) return { ok: true, value: arg }
1055  const cached = (await read($, listAtom)).threads
1056  const first = cached ? matchThread(arg, cached) : { unknown: true as const }
1057  if ('id' in first) return { ok: true, value: first.id }
1058  if ('error' in first) return failed(first.error)
1059  const got = await listThreads($)
1060  if (!got.ok) return got
1061  const found = matchThread(arg, got.value)
1062  if ('id' in found) return { ok: true, value: found.id }
1063  return failed('error' in found ? found.error : `no thread starts with ${arg}`)
1064}
1065
1066const titleOf = async ($: EngineInterface, id: string) =>
1067  (await read($, listAtom)).threads?.find(thread => thread.id === id)?.title
1068
1069/** The thread's title from the last poll, else from a fresh list: a confirmation must name what it removes. */
1070async function lookUpTitle($: EngineInterface, id: string): Promise<string | undefined> {
1071  const cached = await titleOf($, id)
1072  if (cached) return cached
1073  const got = await listThreads($)
1074  return got.ok ? got.value.find(thread => thread.id === id)?.title : undefined
1075}
1076
1077/** The thread an action names, else the one the pane shows. */
1078async function targetOf($: EngineInterface, arg: string | undefined): Promise<Outcome<string>> {
1079  if (arg) return resolveThread($, arg)
1080  const { selected } = await read($, paneAtom)
1081  return selected ? { ok: true, value: selected } : failed('name a thread, or open one in the pane first')
1082}
1083
1084/** `nexus preview <thread> <port> --wait`: the port published, and its preview once the node serves it. Undefined once stopped. */
1085async function previewPort($: EngineInterface, thread: string, port: number, stop: Promise<void>): Promise<Outcome<NexusPreview> | undefined> {
1086  const got = await childJson($, ['preview', thread, String(port), '--wait'], stop)
1087  return got && parsed(got, json => parsePreview(json, 'preview'))
1088}
1089
1090/** `nexus unpreview <thread> <port>`: the port's linked preview closed, waking a paused pylon first. Undefined once stopped. */
1091async function unpreviewPort($: EngineInterface, thread: string, port: number, stop: Promise<void>): Promise<Outcome<null> | undefined> {
1092  const got = await childJson($, ['unpreview', thread, String(port)], stop)
1093  if (!got || got.ok) return got && parsed(got, parseUnpreviewed)
1094  // A CLI before 0.3.0 has no unpreview, and says only that it does not know the command.
1095  if (got.failure.message === 'unknown command unpreview') {
1096    return { ok: false, failure: { kind: got.failure.kind, message: `${cli} has no unpreview command: unpublishing a preview needs Nexus CLI 0.3.0 or later (nexus --version)` } }
1097  }
1098  return got
1099}
1100
1101/**
1102 * The ports with a publish or unpublish under way, as `thread:port`, whether the pane, /nexus or a model tool started
1103 * it. A second operation on one is refused rather than queued: the two could finish in either order, so an unpublish
1104 * that ends first would be undone by the publish it raced.
1105 */
1106const changing = new Set<string>()
1107
1108/** Runs `operation` while it holds the port, or says why it cannot: another operation holds it. */
1109async function holdingPort<T>(thread: string, port: number, operation: () => Promise<T>): Promise<{ value: T } | { refused: string }> {
1110  const key = `${thread}:${port}`
1111  if (changing.has(key)) return { refused: `Port ${port} on ${thread} is already being published or unpublished. Try again when it finishes.` }
1112  changing.add(key)
1113  try {
1114    return { value: await operation() }
1115  } finally {
1116    changing.delete(key)
1117  }
1118}
1119
1120/**
1121 * Publishes or unpublishes a port of the thread shown, saying so while the CLI runs, then what it did until the
1122 * details show it, or why it failed. A port with an operation under way takes no other: the pane offers none.
1123 * Closing the pane kills the CLI; the port changes or is refused all the same.
1124 */
1125async function changePort($: EngineInterface, selected: string, port: number, action: 'open' | 'close') {
1126  const current = poller
1127  if (!current || (await read($, paneAtom)).selected !== selected) return
1128  const key = String(port)
1129  const write = (change: (was: NexusPortChange) => NexusPortChange) =>
1130    update($, paneAtom, (pane): NexusPane => {
1131      const ports = pane.portChanges[selected] ?? {}
1132      return { ...pane, portChanges: { ...pane.portChanges, [selected]: { ...ports, [key]: change(ports[key] ?? NO_CHANGE) } } }
1133    })
1134  const held = await holdingPort(selected, port, async () => {
1135    await write(was => ({ ...was, pending: action, failure: null }))
1136    const got = action === 'open' ? await previewPort($, selected, port, current.stopped) : await unpreviewPort($, selected, port, current.stopped)
1137    if (!got) return write(was => ({ ...was, pending: null }))
1138    if (!got.ok) return write(was => ({ ...was, pending: null, failure: { action, message: sentence(got.failure.message) } }))
1139    sequence += 1
1140    const seq = sequence
1141    await write(() => ({ confirmed: { preview: got.value, seq }, pending: null, failure: null }))
1142    // The details list the port afresh: under Previews once opened, under Ports once closed.
1143    void poll($)
1144  })
1145  // The pane offers no second operation of its own, so what holds the port is /nexus or a model tool.
1146  if ('refused' in held) await setPane($, () => ({ notice: { text: held.refused, tone: 'error' } }))
1147}
1148
1149// The Changes tab. Every read of the checkout counts as human activity and wakes a paused pylon, so the changes are
1150// read only while the pylon runs and the thread is open, when the tab is first shown, when an agent turn ends, when
1151// the pylon runs after the person asked to wake it, and on r; never on a timer. A paused pylon is woken only when the
1152// person says so. One `changes` child and one `diff` child at most, as children of the pane.
1153
1154const emptyCheckout = (thread: string): NexusCheckout => ({ thread, changes: null, failure: null, isReading: false, isWanted: false })
1155
1156/** Changes the Changes tab's reads of `thread`, if they are still the ones the pane holds. */
1157const changeCheckout = ($: EngineInterface, thread: string, change: (was: NexusCheckout) => NexusCheckout) =>
1158  update($, checkoutAtom, was => (was?.thread === thread ? change(was) : was))
1159
1160/** What Changes compares with, as `$.store` kept it. A store from before the latest turn kept only whether to show the uncommitted changes. */
1161async function loadChangesBase($: EngineInterface) {
1162  const stored = await $.store.get('changesBase')
1163  const old = await $.store.get('uncommitted')
1164  const base = stored === 'start' || stored === 'turn' || stored === 'head' ? stored : old === true ? 'head' : 'start'
1165  await $.state.set(CHANGES_BASE, base)
1166  if (old === undefined) return
1167  await $.store.set('changesBase', base)
1168  await $.store.delete('uncommitted')
1169}
1170
1171/**
1172 * A failed checkout read: a CLI too old for the command names the version it needs, and one too old for `--turn`,
1173 * which its parseArgs refuses as an unknown option, the version that added it.
1174 */
1175async function checkoutFailure($: EngineInterface, failure: NexusFailure, base: NexusBase['kind'], command: string, doing: string): Promise<NexusFailure> {
1176  const older = newer(failure, command, doing, '0.6.0')
1177  if (older !== failure || base !== 'turn' || failure.kind !== 'invalid') return older
1178  return olderThan($, failure, [0, 11, 0], "comparing with the latest turn")
1179}
1180
1181/**
1182 * Reads `thread`'s changes, if the rule allows: its details say the pylon runs and the thread is open, or the person
1183 * asked to wake it (`wake`, or an earlier ask still standing). One read at a time: a second ask while one runs is the
1184 * same read. A reply for the base before a toggle of c is dropped, and the read runs again for the base now chosen.
1185 */
1186async function readChanges($: EngineInterface, thread: string, wake: boolean): Promise<void> {
1187  const current = poller
1188  if (!current || (await read($, paneAtom)).selected !== thread) return
1189  const shown = await read($, shownAtom)
1190  const detail = shown?.thread === thread ? shown.detail : null
1191  // Until the first details say what the pylon is doing, nothing is read: their arrival reads (`noticeCheckout`).
1192  if (!detail) return
1193  const facts = checkoutFacts(detail)
1194  if (facts.status !== 'active' && facts.status !== 'idle') return
1195  const checkout = await update($, checkoutAtom, was => (was?.thread === thread ? was : emptyCheckout(thread)))
1196  if (!canReadCheckout(facts)) {
1197    if (!wake && !checkout?.isWanted) return
1198    if (!checkout?.isWanted) await changeCheckout($, thread, was => ({ ...was, isWanted: true }))
1199  }
1200  const base = await read($, changesBaseAtom)
hooks/model.ts 1578 lines
1// Everything the mod knows about Nexus that needs no `$`: parsing what the CLI
2// prints, folding a watch stream into rows, the /nexus grammar and what the
3// pane derives from them. docs/cli.md in the Nexus repository is the contract.
4import type {
5  NexusActual,
6  NexusAttention,
7  NexusBase,
8  NexusChangedFile,
9  NexusChanges,
10  NexusClaudeShown,
11  NexusDesired,
12  NexusDiffRow,
13  NexusFileStatus,
14  NexusDetail,
15  NexusFailure,
16  NexusLive,
17  NexusNode,
18  NexusPort,
19  NexusPortChange,
20  NexusPreview,
21  NexusProject,
22  NexusRow,
23  NexusRunState,
24  NexusHarness,
25  NexusOpenedSession,
26  NexusSession,
27  NexusSessionChanges,
28  NexusThread,
29  NexusThreadRow,
30  NexusWatch,
31} from '../types'
32
33export const FULL_ID = /^th-[0-9a-f]{12}$/
34
35const KINDS: Record<number, NexusFailure['kind']> = { 1: 'failed', 2: 'unreachable', 3: 'refused', 4: 'not-found', 5: 'conflict', 64: 'invalid' }
36
37/** A failed command's error: the JSON line it printed on stderr, else its exit code. */
38export function parseFailure(exitCode: number, stderr: string): NexusFailure {
39  const kind = KINDS[exitCode] ?? 'failed'
40  for (const line of stderr.trim().split('\n').reverse()) {
41    try {
42      const parsed = JSON.parse(line) as { error?: unknown }
43      if (typeof parsed.error === 'string') return { kind, message: parsed.error }
44    } catch {
45      // Not the CLI's JSON line: a crash or a shim's own message.
46    }
47  }
48  const text = stderr.trim().split('\n').pop()
49  return { kind, message: text || `the CLI exited with ${exitCode}` }
50}
51
52type Raw = Record<string, unknown>
53const isRecord = (value: unknown): value is Raw => typeof value === 'object' && value !== null && !Array.isArray(value)
54const num = (value: unknown) => (typeof value === 'number' ? value : 0)
55const str = (value: unknown) => (typeof value === 'string' ? value : '')
56const strOrNull = (value: unknown) => (typeof value === 'string' ? value : null)
57const list = (value: unknown) => (Array.isArray(value) ? value : [])
58
59const ACTUAL = new Set<string>(['spawning', 'running', 'pausing', 'paused', 'deleting', 'absent', 'failed', 'unknown'])
60const actual = (value: unknown): NexusActual => (typeof value === 'string' && ACTUAL.has(value) ? (value as NexusActual) : 'unknown')
61const desired = (value: unknown): NexusDesired | undefined => (value === 'running' || value === 'paused' || value === 'deleted' ? value : undefined)
62
63/** A field docs/cli.md requires is missing: the CLI and this mod disagree on the contract. */
64const contractError = (command: string, what: string) => new Error(`nexus ${command} printed ${what}, which docs/cli.md requires: the CLI and this mod disagree on the contract`)
65
66/** A thread's reply time or seen marker: a number or null, docs/cli.md says since CLI 0.10.0, so one missing is a contract error. */
67function stamp(raw: Raw, key: 'lastReplyAt' | 'seenAt', command: string): number | null {
68  const value = raw[key]
69  if (value === null || typeof value === 'number') return value
70  throw contractError(command, `a thread (${String(raw.id)}) without ${key}`)
71}
72
73function parseThread(raw: unknown, command: string): NexusThread | undefined {
74  if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.title !== 'string' || typeof raw.project !== 'string') return undefined
75  return {
76    id: raw.id,
77    title: raw.title,
78    project: raw.project,
79    status: raw.status as NexusThread['status'],
80    agentTurnRunning: raw.agentTurnRunning === true || raw.sessionTurnRunning === true,
81    createdAt: num(raw.createdAt),
82    lastHumanActivityAt: num(raw.lastHumanActivityAt),
83    lastAgentActivityAt: num(raw.lastAgentActivityAt),
84    lastActivityAt: Math.max(num(raw.lastAgentActivityAt), num(raw.lastHumanActivityAt), num(raw.createdAt)),
85    lastReplyAt: stamp(raw, 'lastReplyAt', command),
86    seenAt: stamp(raw, 'seenAt', command),
87  }
88}
89
90const ATTENTION = new Set<string>(['failed', 'decision', 'parked', 'reply', 'working', 'waiting'])
91
92/** A row's or a detail's `attention`, required since CLI 0.10.0. A value this mod does not know asks nothing of it. */
93function parseAttention(raw: Raw, command: string, id: string): NexusAttention {
94  if (raw.attention === undefined) throw contractError(command, `a thread (${id}) without its attention`)
95  return typeof raw.attention === 'string' && ATTENTION.has(raw.attention) ? (raw.attention as NexusAttention) : null
96}
97
98/** Whether the thread's latest reply came after the seen marker, as docs/cli.md counts an unread reply. */
99export const isUnread = (thread: Pick<NexusThread, 'lastReplyAt' | 'seenAt'>) => thread.lastReplyAt !== null && (thread.seenAt === null || thread.lastReplyAt > thread.seenAt)
100
101/** `threads --json`, kept to the fields the mod reads. A row without its pylon, `desired` or attention fails the whole list. */
102export function parseThreads(json: unknown): NexusThreadRow[] {
103  if (!Array.isArray(json)) throw new Error('nexus threads did not print a list')
104  return json.flatMap(raw => {
105    const thread = parseThread(raw, 'threads')
106    if (!thread || !isRecord(raw)) return []
107    const { pylon } = raw
108    if (!isRecord(pylon) || typeof pylon.node !== 'string') throw contractError('threads', `a row (${thread.id}) without its pylon`)
109    const wants = desired(raw.desired)
110    if (!wants) throw contractError('threads', `a row (${thread.id}) without desired`)
111    return [{ ...thread, pylon: { node: pylon.node, actualState: actual(pylon.actualState), failed: strOrNull(pylon.failed) }, desired: wants, attention: parseAttention(raw, 'threads', thread.id) }]
112  })
113}
114
115export function parseStarted(json: unknown): NexusThread {
116  // Only the started thread's ID and title are read, so a CLI before 0.10.0 starts a thread without its stamps rather than failing after it.
117  const thread = parseThread(isRecord(json) ? { lastReplyAt: null, seenAt: null, ...json } : json, 'start')
118  if (!thread) throw new Error('nexus start did not print a thread')
119  return thread
120}
121
122/** A `Preview`, its `url` required: the CLI owns the URL, so one missing is a contract error rather than one rebuilt. */
123export function parsePreview(raw: unknown, command: string): NexusPreview {
124  if (!isRecord(raw) || typeof raw.service !== 'string') throw contractError(command, 'a preview without its service')
125  if (typeof raw.url !== 'string') throw contractError(command, `a preview (${raw.service}) without its url`)
126  return { service: raw.service, httpsPort: num(raw.httpsPort), ready: raw.ready === true, failed: strOrNull(raw.failed), url: raw.url }
127}
128
129/** `unpreview` prints a literal null; anything else is a CLI this mod does not know, so the close is not taken as done. */
130export function parseUnpreviewed(raw: unknown): null {
131  if (raw !== null) throw new Error(`nexus unpreview printed ${clip(JSON.stringify(raw), 60)} where docs/cli.md says null: the CLI and this mod disagree on the contract`)
132  return null
133}
134
135/** `ListeningPort[] | null`, as `show` and a watch `state` print it: a missing list is a contract error, not "unknown". */
136export function parsePorts(raw: unknown, command: string): NexusPort[] | null {
137  if (raw === null) return null
138  if (!Array.isArray(raw)) throw contractError(command, 'no ports')
139  return raw.map(one => {
140    if (!isRecord(one) || typeof one.port !== 'number') throw contractError(command, 'a port without its number')
141    return { port: one.port, service: strOrNull(one.service), preview: one.preview === null ? null : parsePreview(one.preview, command) }
142  })
143}
144
145/** The port a preview links, from its service `port-<port>`; null for a declared or ad hoc service's preview, which `unpreview` refuses. */
146export function linkedPort(preview: NexusPreview): number | null {
147  const match = /^port-(\d+)$/.exec(preview.service)
148  return match ? Number(match[1]) : null
149}
150
151export const NO_CHANGE: NexusPortChange = { confirmed: null, pending: null, failure: null }
152
153/** What the person did to `port`, its `confirmed` kept only while the details shown (asked as `askedSeq`) were asked before it. */
154export function portChange(changes: Record<string, NexusPortChange>, port: number, askedSeq: number): NexusPortChange {
155  const change = changes[String(port)] ?? NO_CHANGE
156  return change.confirmed && change.confirmed.seq < askedSeq ? { ...change, confirmed: null } : change
157}
158
159/** The agents `session open` can start in a pylon, its default first. */
160export const HARNESSES: readonly NexusHarness[] = ['claude-code', 'pi', 'shell']
161const HARNESS = new Set<string>(HARNESSES)
162const SESSION = new Set(['working', 'idle', 'blocked', 'done', 'unknown'])
163const OUTPUT = new Set(['branch', 'diff', 'preview', 'file'])
164
165/** `show --json`: the ThreadDetail, kept to what the pane draws. */
166export function parseDetail(json: unknown): NexusDetail {
167  const thread = isRecord(json) ? parseThread(json.thread, 'show') : undefined
168  if (!isRecord(json) || !thread || !isRecord(json.pylon)) throw new Error('nexus show did not print a thread')
169  const { pylon } = json
170  const wants = desired(json.desired)
171  if (!wants) throw contractError('show', 'no desired')
172  if (typeof json.attach !== 'string') throw contractError('show', 'no attach')
173  return {
174    thread,
175    attention: parseAttention(json, 'show', thread.id),
176    pylon: {
177      id: str(pylon.id),
178      node: str(pylon.node),
179      actualState: actual(pylon.actualState),
180      failed: strOrNull(pylon.failed),
181      observedAt: strOrNull(pylon.observedAt),
182      retryRefused: strOrNull(pylon.retryRefused),
183    },
184    sessions: list(json.sessions).map(parseSession),
185    previews: list(json.previews).map(raw => parsePreview(raw, 'show')),
186    ports: parsePorts(json.ports, 'show'),
187    outputs: list(json.outputs).flatMap(raw =>
188      isRecord(raw) && OUTPUT.has(str(raw.kind)) ? [{ kind: raw.kind as NexusDetail['outputs'][number]['kind'], ref: str(raw.ref) }] : [],
189    ),
190    agent: isRecord(json.agent) ? { parkedTasks: num(json.agent.parkedTasks) } : null,
191    conversation: isRecord(json.claudeWorker) ? 'claude' : isRecord(json.thread) && typeof json.thread.conversationId === 'string' ? 'agent' : null,
192    worker: isRecord(json.claudeWorker) ? { state: str(json.claudeWorker.state), model: strOrNull(json.claudeWorker.model), message: strOrNull(json.claudeWorker.message) } : null,
193    decisions: isRecord(json.review) && isRecord(json.review.phase) && json.review.phase.kind === 'needs-person' ? list(json.review.phase.reasons).filter((reason): reason is string => typeof reason === 'string') : [],
194    desired: wants,
195    attach: json.attach,
196  }
197}
198
199const projectOf = (id: string, raw: Record<string, unknown>): NexusProject => ({ id, repoUrl: str(raw.repoUrl), defaultBranch: str(raw.defaultBranch), defaultNode: str(raw.defaultNode) })
200
201export function parseProjects(json: unknown): NexusProject[] {
202  if (!Array.isArray(json)) throw new Error('nexus projects did not print a list')
203  return json.flatMap(raw => (isRecord(raw) && typeof raw.id === 'string' ? [projectOf(raw.id, raw)] : []))
204}
205
206/**
207 * One of `show`'s sessions. A control plane older than CLI 0.8.0 lists only running sessions and sends no `state`,
208 * `id` or `message`: no `state` is running, and no `id` is the pane. A state this mod does not know, a session with
209 * no ID, a running one with no pane or a failed one with no message is a contract error, since the Pylon tab offers
210 * a close by state and ID.
211 */
212function parseSession(raw: unknown): NexusSession {
213  if (!isRecord(raw)) throw contractError('show', 'a session that is not an object')
214  const state = raw.state ?? 'running'
215  const herdrPane = typeof raw.herdrPane === 'string' ? raw.herdrPane : null
216  const id = raw.id ?? herdrPane
217  if (typeof id !== 'string' || !id) throw contractError('show', 'a session without its id')
218  const common = {
219    id,
220    harness: HARNESS.has(str(raw.harness)) ? (raw.harness as NexusHarness) : 'shell',
221    harnessSessionId: strOrNull(raw.harnessSessionId),
222    status: SESSION.has(str(raw.status)) ? (raw.status as NexusSession['status']) : 'unknown',
223    // Absent from a control plane older than CLI 0.5.0, which means none.
224    title: strOrNull(raw.title),
225  } as const
226  switch (state) {
227    case 'running':
228      if (herdrPane === null) throw contractError('show', `a running session (${id}) without its herdrPane`)
229      return { ...common, state: 'running', herdrPane, message: null }
230    case 'paused':
231      return { ...common, state: 'paused', herdrPane, message: null }
232    case 'failed':
233      if (typeof raw.message !== 'string') throw contractError('show', `a failed session (${id}) without its message`)
234      return { ...common, state: 'failed', herdrPane, message: raw.message }
235    default:
236      throw contractError('show', `a session (${id}) in a state it does not know, ${clip(JSON.stringify(state), 40)}`)
237  }
238}
239
240/** `nexus project add`'s answer: the project it added. */
241/** `session open`'s `OpenedSession`, each field required: the pane is what a close and the Pylon tab name it by. */
242export function parseOpenedSession(json: unknown): NexusOpenedSession {
243  if (!isRecord(json) || typeof json.herdrPane !== 'string' || typeof json.title !== 'string' || !HARNESS.has(str(json.harness))) {
244    throw contractError('session open', 'no OpenedSession')
245  }
246  return { herdrPane: json.herdrPane, harness: json.harness as NexusHarness, title: json.title }
247}
248
249/** `session close` prints a literal null. */
250export function parseSessionClosed(raw: unknown): null {
251  if (raw !== null) throw new Error(`nexus session close printed ${clip(JSON.stringify(raw), 60)} where docs/cli.md says null: the CLI and this mod disagree on the contract`)
252  return null
253}
254
255export function projectJson(json: unknown, command: 'projects' | 'project add'): unknown {
256  const projects = command === 'projects' ? json : [json]
257  if (!Array.isArray(projects)) throw contractError(command, 'no project list')
258  for (const project of projects) {
259    if (!isRecord(project) || typeof project.id !== 'string' || (project.repoUrl !== null && typeof project.repoUrl !== 'string') || typeof project.defaultNode !== 'string' || typeof project.defaultBranch !== 'string') {
260      throw contractError(command, 'a malformed project')
261    }
262  }
263  return json
264}
265
266export function onlineNode(json: unknown): string | undefined {
267  if (!Array.isArray(json) || json.some(node => !isRecord(node) || typeof node.name !== 'string' || typeof node.state !== 'string')) throw contractError('nodes', 'a malformed node list')
268  return json.find(node => node.state === 'online')?.name
269}
270
271export function parseAdded(json: unknown): NexusProject {
272  if (!isRecord(json) || typeof json.id !== 'string') throw new Error('nexus project add did not print the project it added')
273  return projectOf(json.id, json)
274}
275
276/** The threads of `project`, or every thread for null: the scope the list, the strip and `/nexus` show. */
277export const inScope = <T extends { project: string }>(threads: readonly T[], project: string | null): T[] =>
278  project === null ? [...threads] : threads.filter(thread => thread.project === project)
279
280/** The scope as the list's title and the strip name it. */
281export const scopeLabel = (project: string | null) => project ?? 'All projects'
282
283export function parseNodes(json: unknown): NexusNode[] {
284  if (!Array.isArray(json)) throw new Error('nexus nodes did not print a list')
285  return json.flatMap(raw =>
286    isRecord(raw) && typeof raw.name === 'string'
287      ? [{ name: raw.name, tailnetHost: str(raw.tailnetHost), state: raw.state === 'offline' ? ('offline' as const) : ('online' as const) }]
288      : [],
289  )
290}
291
292// The pi-ai message shapes an entry's `model` holds, as far as rows read them.
293type Part = { type?: string; text?: string; id?: string; name?: string; arguments?: unknown }
294type Message = {
295  role?: string
296  content?: string | Part[]
297  toolCallId?: string
298  toolName?: string
299  isError?: boolean
300  stopReason?: string
301  errorMessage?: string
302  timestamp?: number
303  model?: string
304  details?: unknown
305}
306
307/** An entry as the watch prints it: its model messages, and the CLI's plain rendering of them. */
308export type WatchEntry = { model: Message[] | null; text: string }
309
310export type WatchEvent =
311  | { type: 'reset'; entries: WatchEntry[] }
312  | { type: 'entry'; entry: WatchEntry }
313  | { type: 'state'; state: NexusRunState }
314  | { type: 'live'; live: NexusLive }
315  | { type: 'error'; kind: 'unreachable' | 'failed'; message: string }
316
317function parseEntry(entry: unknown, text: unknown): WatchEntry {
318  const model = isRecord(entry) && Array.isArray(entry.model) ? (entry.model as Message[]) : null
319  return { model, text: typeof text === 'string' ? text : '' }
320}
321
322/** Live output kept per running call: its tail, enough for the few lines a row shows. */
323const LIVE_CHARS = 2_000
324
325/**
326 * One line of `watch --json`; undefined for a line or an event type this version does not know. A `state` without
327 * a field docs/cli.md requires and the mod reads throws.
328 */
329export function parseWatchEvent(line: string): WatchEvent | undefined {
330  let raw: unknown
331  try {
332    raw = JSON.parse(line)
333  } catch {
334    return undefined
335  }
336  if (!isRecord(raw)) return undefined
337  switch (raw.type) {
338    case 'reset':
339      return {
340        type: 'reset',
341        entries: Array.isArray(raw.entries) ? raw.entries.map(one => (isRecord(one) ? parseEntry(one.entry, one.text) : parseEntry(null, ''))) : [],
342      }
343    case 'entry':
344      return { type: 'entry', entry: parseEntry(raw.entry, raw.text) }
345    case 'state':
346      return {
347        type: 'state',
348        state: {
349          status: raw.status as NexusRunState['status'],
350          run: raw.run === 'working' || raw.run === 'parked' ? raw.run : 'idle',
351          parkedTasks: num(raw.parkedTasks),
352          queued: num(raw.queued),
353          pylon: typeof raw.pylon === 'string' ? raw.pylon : 'unknown',
354          failed: typeof raw.failed === 'string' ? raw.failed : null,
355          ports: parsePorts(raw.ports, 'watch'),
356        },
357      }
358    case 'live':
359      return {
360        type: 'live',
361        live: {
362          queued: list(raw.queued).filter((text): text is string => typeof text === 'string'),
363          tools: list(raw.tools).flatMap(tool =>
364            isRecord(tool) && typeof tool.callId === 'string' ? [{ callId: tool.callId, output: keepTail(str(tool.output), LIVE_CHARS) }] : [],
365          ),
366          partial: strOrNull(raw.partial),
367          retry: strOrNull(raw.retry),
368        },
369      }
370    case 'error':
371      return {
372        type: 'error',
373        kind: raw.kind === 'failed' ? 'failed' : 'unreachable',
374        message: typeof raw.message === 'string' ? raw.message : 'the control plane failed',
375      }
376    default:
377      return undefined
378  }
379}
380
381const contentText = (content: Message['content']) =>
382  typeof content === 'string' ? content : (content ?? []).map(part => (part.type === 'text' ? (part.text ?? '') : part.type === 'image' ? '[image]' : '')).join('')
383
384const SUMMARY_KEYS = ['command', 'path', 'file_path', 'pattern', 'query', 'url', 'description']
385
386/** A tool call in one line: the argument that says what it does, else the arguments as JSON. */
387export function toolSummary(args: unknown): string {
388  if (!isRecord(args) || Object.keys(args).length === 0) return ''
389  const key = SUMMARY_KEYS.find(name => typeof args[name] === 'string')
390  const text = key ? (args[key] as string) : (Object.values(args).find(value => typeof value === 'string') as string | undefined) ?? JSON.stringify(args)
391  return text.replace(/\s+/g, ' ').trim()
392}
393
394/** What a row keeps of a call's arguments and result: `$.state` holds every row as JSON for each redraw. */
395const ARG_CHARS = 4_000
396const OUTPUT_CHARS = 3_000
397const PATCH_CHARS = 20_000
398
399/** `value` with every string longer than the bound cut, for a row's arguments. */
400function bounded(value: unknown, depth = 0): unknown {
401  if (typeof value === 'string') return value.length > ARG_CHARS ? `${value.slice(0, ARG_CHARS)}…` : value
402  if (depth > 4) return null
403  if (Array.isArray(value)) return value.slice(0, 50).map(item => bounded(item, depth + 1))
404  if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, bounded(item, depth + 1)]))
405  return value
406}
407
408const lineCount = (text: string) => (text.trim() === '' ? 0 : text.trimEnd().split('\n').length)
409
410function patchOf(details: unknown): string | null {
411  const patch = isRecord(details) ? details.patch : undefined
412  return typeof patch === 'string' && patch.length <= PATCH_CHARS ? patch : null
413}
414
415/** The rows after one more entry: its messages in order, a tool result settling its call's row. */
416export function appendEntry(rows: readonly NexusRow[], entry: WatchEntry): NexusRow[] {
417  const next = [...rows]
418  if (!entry.model) {
419    if (entry.text) next.push({ kind: 'notice', tone: 'dim', text: entry.text })
420    return next
421  }
422  for (const message of entry.model) {
423    switch (message.role) {
424      case 'user': {
425        const text = contentText(message.content).trim()
426        if (text) next.push({ kind: 'user', text, at: typeof message.timestamp === 'number' ? message.timestamp : null })
427        break
428      }
429      case 'assistant': {
430        for (const part of Array.isArray(message.content) ? message.content : []) {
431          if (part.type === 'text' && part.text?.trim()) next.push({ kind: 'assistant', text: part.text.trim() })
432          if (part.type === 'toolCall') next.push(toolRow(part.id ?? '', part.name ?? 'tool', part.arguments))
433        }
434        if (message.stopReason === 'error') next.push({ kind: 'notice', tone: 'error', text: `The model failed: ${message.errorMessage ?? 'no reason given'}` })
435        if (message.stopReason === 'aborted') next.push({ kind: 'notice', tone: 'dim', text: 'Aborted.' })
436        break
437      }
438      case 'toolResult':
439        settle(next, message.toolCallId ?? '', message.toolName ?? 'tool', contentText(message.content), message.isError === true, patchOf(message.details))
440        break
441    }
442  }
443  return next
444}
445
446/** `rows` with the call `id` settled by its result, in place. A result whose call is not in the context still shows, as the dashboard shows it. */
447function settle(rows: NexusRow[], id: string, name: string, output: string, isError: boolean, patch: string | null, entry?: number, outputChars = OUTPUT_CHARS): NexusRow[] {
448  const settled = { outcome: isError ? ('error' as const) : ('ok' as const), output: keepTail(output, outputChars), lines: lineCount(output), patch }
449  const at = rows.findLastIndex(row => row.kind === 'tool' && row.id === id)
450  const row = rows[at]
451  if (row?.kind === 'tool') rows[at] = { ...row, ...settled }
452  else rows.push({ ...toolRow(id, name, {}), ...settled, ...(entry === undefined ? {} : { entry }) })
453  return rows
454}
455
456function toolRow(id: string, name: string, args: unknown): Extract<NexusRow, { kind: 'tool' }> {
457  return { kind: 'tool', id, name, summary: toolSummary(args), outcome: 'running', args: isRecord(args) ? (bounded(args) as Raw) : {}, output: '', lines: 0, patch: null }
458}
459
460/** The model of the newest answer in `entry`, else `model`. */
461function modelIn(model: string | null, entry: WatchEntry): string | null {
462  const answers = (entry.model ?? []).filter(message => message.role === 'assistant' && typeof message.model === 'string')
463  return answers.at(-1)?.model ?? model
464}
465
466/** Rows kept per thread: the pane shows a tail, and `$.state` holds JSON for every redraw. */
467const KEPT_ROWS = 400
468
469/** `rows` cut to the tail kept, counting what the cut dropped onto `dropped`. */
470export function kept(rows: NexusRow[], dropped: number): Pick<NexusWatch, 'rows' | 'dropped'> {
471  const cut = Math.max(0, rows.length - KEPT_ROWS)
472  return { rows: rows.slice(cut), dropped: dropped + cut }
473}
474
475/** The watch after one event. A `live` event changes nothing here: the pane keeps it apart from the rows. */
476export function applyEvent(watch: NexusWatch, event: WatchEvent): NexusWatch {
477  switch (event.type) {
478    case 'reset':
479      return { ...watch, ...kept(event.entries.reduce(appendEntry, [] as NexusRow[]), 0), model: event.entries.reduce(modelIn, null) }
480    case 'entry':
481      return { ...watch, ...kept(appendEntry(watch.rows, event.entry), watch.dropped), model: modelIn(watch.model, event.entry) }
482    case 'state':
483      return { ...watch, state: event.state, outage: null }
484    case 'live':
485      return watch
486    case 'error':
487      return { ...watch, outage: `${event.kind === 'failed' ? 'The control plane is failing' : 'Nexus is unreachable'}: ${sentence(event.message)}` }
488  }
489}
490
491/**
492 * The agent is done for now. Either the thread cannot run on (archived, deleted, parked, or its pylon failed), or
493 * its run is idle with nothing queued and either the last row is not the person's message or this watch saw the
494 * run working first. A message just sent shows as queued or as a trailing user row, so it is not missed; a turn
495 * aborted before it wrote any text leaves a trailing user row, which the working-then-idle run settles.
496 */
497export function isSettled(state: NexusRunState | null, rows: readonly NexusRow[], sawWorking: boolean): boolean {
498  if (!state) return false
499  if (state.status === 'archived' || state.status === 'deleted' || state.run === 'parked') return true
500  if (state.pylon === 'failed' || state.failed !== null) return true
501  if (state.run !== 'idle' || state.queued > 0) return false
502  return sawWorking || (rows.length > 0 && rows.at(-1)!.kind !== 'user')
503}
504
505/**
506 * Every text the agent wrote since the person's last message, its parts a blank line apart, folded in as the watch
507 * reads it: its last `max` characters and how many came before them. It reads each entry whole, so a turn longer
508 * than the rows a watch keeps still gives its first text.
509 */
510export type Reply = { tail: string; dropped: number }
511
512export function foldReply(reply: Reply | undefined, event: WatchEvent, max: number): Reply | undefined {
513  if (event.type === 'reset') return event.entries.reduce<Reply | undefined>((sofar, entry) => addReply(sofar, appendEntry([], entry), max), undefined)
514  if (event.type === 'entry') return addReply(reply, appendEntry([], event.entry), max)
515  return reply
516}
517
518function addReply(reply: Reply | undefined, rows: readonly NexusRow[], max: number): Reply | undefined {
519  for (const row of rows) {
520    if (row.kind === 'user') reply = undefined
521    if (row.kind !== 'assistant') continue
522    const text = reply ? `${reply.tail}\n\n${row.text}` : row.text
523    reply = { tail: text.slice(-max), dropped: (reply?.dropped ?? 0) + Math.max(0, text.length - max) }
524  }
525  return reply
526}
527
528/** The last `max` characters of `text`, cut at a line where it can be, saying what was left out. */
529export const keepTail = (text: string, max: number) => leftOut(text.slice(-max), Math.max(0, text.length - max))
530
531/** The tail of a longer text, opened at a line where it can be, saying how many characters came before it. */
532export function leftOut(cut: string, dropped: number): string {
533  if (dropped === 0) return cut
534  const line = cut.indexOf('\n')
535  return `[${dropped} earlier characters left out]\n${line >= 0 && line < cut.length / 4 ? cut.slice(line + 1) : cut}`
536}
537
538/** Claude Code's tools as the rows name pi's, so they draw, fold and diff alike. Any other keeps its name and input. */
539const CLAUDE_TOOLS: Record<string, (input: Raw) => { name: string; args: Raw }> = {
540  Bash: input => ({ name: 'bash', args: input }),
541  // A `!` command typed at the session's prompt.
542  shell: input => ({ name: 'bash', args: input }),
543  Read: input => ({ name: 'read', args: { ...input, path: input.file_path } }),
544  Edit: input => ({ name: 'edit', args: { path: input.file_path, edits: [{ oldText: input.old_string, newText: input.new_string }] } }),
545  MultiEdit: input => ({ name: 'edit', args: { path: input.file_path, edits: list(input.edits).map(edit => (isRecord(edit) ? { oldText: edit.old_string, newText: edit.new_string } : {})) } }),
546  Write: input => ({ name: 'write', args: { path: input.file_path, content: input.content } }),
547  Grep: input => ({ name: 'grep', args: input }),
548  Glob: input => ({ name: 'find', args: input }),
549}
550
551/**
552 * A Claude Code worker's transcript: every row of the CLI's window, the entries left out before it, the run and its
553 * queue, and whether a turn shows in it (`hasTurn`: a prompt Claude took, a reply, a tool call or a turn's end).
554 */
555export type ClaudeReading = { rows: NexusRow[]; dropped: number; working: boolean; queued: string[]; readAt: number | null; hasTurn: boolean }
556
557const TURN_KINDS = new Set(['prompt', 'reply', 'tool', 'turn'])
558
559/**
560 * `conversation --json` for a Claude Code worker thread (CLI 0.17.0), its entries folded into rows as a watch's are: a
561 * prompt from the person or a slash command as theirs, a reply as an answer, a tool call settled by the result that
562 * names it, and the rest as notices, a record of a kind the CLI does not know included. A pull request Claude opened
563 * shows once, by its URL, since Claude Code writes its record again with each save of the session (CLI 0.19.0). A queue
564 * operation moves only the queue, which Claude takes from by text, or its oldest without one. An entry kind this mod
565 * does not know is skipped. Each tool result keeps its last `outputChars`, the pane's bound unless a caller asks for more.
566 */
567export function parseClaudeConversation(json: unknown, outputChars = OUTPUT_CHARS): ClaudeReading {
568  if (!isRecord(json) || json.kind !== 'claude' || !Array.isArray(json.entries)) throw contractError('conversation', "a Claude worker's conversation")
569  const rows: NexusRow[] = []
570  const queued: string[] = []
571  const opened = new Set<string>()
572  const dropped = num(json.dropped)
573  // Each row is keyed by its entry's place in the whole transcript, so a window that slides past an entry no row shows moves no key.
574  const push = (at: number, row: NexusRow) => rows.push({ ...row, entry: dropped + at })
575  for (const [at, entry] of json.entries.entries()) {
576    if (!isRecord(entry)) continue
577    switch (entry.kind) {
578      case 'prompt':
579        if (!str(entry.text).trim()) break
580        push(at, entry.from === 'other' ? { kind: 'notice', tone: 'dim', text: str(entry.text).trim() } : { kind: 'user', text: str(entry.text).trim(), at: typeof entry.at === 'number' ? entry.at : null })
581        break
582      case 'reply':
583        if (str(entry.text).trim()) push(at, { kind: 'assistant', text: str(entry.text).trim() })
584        break
585      case 'tool': {
586        const input = isRecord(entry.input) ? entry.input : {}
587        const { name, args } = CLAUDE_TOOLS[str(entry.name)]?.(input) ?? { name: str(entry.name), args: input }
588        push(at, toolRow(str(entry.id), name, args))
589        break
590      }
591      case 'result':
592        settle(rows, str(entry.toolUseId), 'tool', str(entry.output), entry.isError === true, null, dropped + at, outputChars)
593        break
594      case 'task':
595        push(at, { kind: 'notice', tone: 'dim', text: [`Background task ${str(entry.status)}: ${str(entry.summary)}`, strOrNull(entry.result)].filter(Boolean).join('\n') })
596        break
597      case 'queue': {
598        const text = strOrNull(entry.text)
599        if (entry.op === 'enqueue') {
600          queued.push(text ?? '')
601          break
602        }
603        const taken = text === null ? 0 : queued.indexOf(text)
604        if (taken !== -1) queued.splice(taken, 1)
605        break
606      }
607      case 'notice':
608        push(at, { kind: 'notice', tone: entry.tone === 'error' ? 'error' : 'dim', text: str(entry.text) })
609        break
610      case 'record':
611        push(at, { kind: 'notice', tone: 'dim', text: str(entry.summary) })
612        break
613      case 'pull-request':
614        if (opened.has(str(entry.url))) break
615        opened.add(str(entry.url))
616        push(at, { kind: 'notice', tone: 'dim', text: `Opened pull request #${num(entry.number)}: ${str(entry.url)}` })
617        break
618    }
619  }
620  const hasTurn = dropped > 0 || json.entries.some(entry => isRecord(entry) && TURN_KINDS.has(str(entry.kind)))
621  return { rows, dropped, working: json.working === true, queued, readAt: typeof json.readAt === 'number' ? json.readAt : null, hasTurn }
622}
623
624/**
625 * A Claude Code worker thread's conversation in the watch's place, since `nexus watch` streams none of it: the rows,
626 * the run and its queue from `nexus conversation`, the thread's status, pylon and ports still from the watch, the model
627 * the worker was asked for, and a failed read as the outage.
628 */
629export function claudeWatch(watch: NexusWatch, claude: NexusClaudeShown | null, model: string | null): { watch: NexusWatch; live: NexusLive } {
630  const run = watch.state?.run === 'parked' ? 'parked' : claude?.working ? 'working' : 'idle'
631  return {
632    watch: {
633      ...watch,
634      rows: claude?.rows ?? [],
635      dropped: claude?.dropped ?? 0,
636      state: watch.state && { ...watch.state, run, queued: claude?.queued.length ?? 0 },
637      model: model ?? watch.model,
638      outage: claude?.failure ? `The conversation could not be read: ${sentence(claude.failure)}` : watch.outage,
639    },
640    live: { queued: claude?.queued.filter(Boolean) ?? [], tools: [], partial: null, retry: null },
641  }
642}
643
644/**
645 * Whether a worker's reading means it is done for now: it can no longer run (archived, deleted, parked, a failed
646 * pylon), or its session's turn is over with nothing queued, even when the transcript ends at a prompt it never answered.
647 */
648export const isWorkerIdle = (state: NexusRunState) => isWorkerDone(state) || (state.run === 'idle' && state.queued === 0)
649
650/** Whether a worker can no longer run: archived, deleted, parked or its pylon failed, whatever its launch says. */
651export const isWorkerDone = (state: NexusRunState) =>
652  state.status === 'archived' || state.status === 'deleted' || state.run === 'parked' || state.pylon === 'failed' || state.failed !== null
653
654/**
655 * Whether the pane's watch of `thread` has drawn the reply the details name, as pi-nexus judges it: the watch runs on
656 * with no outage and reports the run not working, since it prints an answer's entries before the state that ends its turn.
657 */
658export const hasDrawnReply = (watch: NexusWatch | null, thread: string) =>
659  watch?.thread === thread && watch.state !== null && watch.state.run !== 'working' && watch.ended === null && watch.outage === null
660
661/**
662 * Whether a worker's transcript holds the reply that ended at `reply`, the details' `lastReplyAt` read before it: only a
663 * transcript read from the pylon at or after it does, since the control plane answers with the one it stored when the
664 * pylon cannot be read. A later turn running does not matter.
665 */
666export const transcriptHolds = (readAt: number | null, reply: number | null) => reply !== null && readAt !== null && readAt >= reply
667
668/** Whether `herdr pane process-info` shows a program in front of the pane's shell, such as a `nexus attach` not yet detached. */
669export function isPaneRunning(stdout: string): boolean {
670  try {
671    const json: unknown = JSON.parse(stdout)
672    const info = isRecord(json) && isRecord(json.result) ? json.result.process_info : undefined
673    return isRecord(info) && typeof info.shell_pid === 'number' && info.foreground_process_group_id !== info.shell_pid
674  } catch {
675    return false
676  }
677}
678
679/** A Claude Code worker thread's state for the tools, from its details and its conversation, as a watch `state` reports a harness thread's. */
680export const claudeState = (detail: NexusDetail, reading: ClaudeReading): NexusRunState => ({
681  status: detail.thread.status,
682  run: detail.agent?.parkedTasks ? 'parked' : reading.working ? 'working' : 'idle',
683  parkedTasks: detail.agent?.parkedTasks ?? 0,
684  queued: reading.queued.length,
685  pylon: detail.pylon.actualState,
686  failed: detail.pylon.failed,
687  ports: detail.ports,
688})
689
690/** The agent's texts since the person's last message in `rows`, as `foldReply` gathers them from a watch. */
691export const rowsReply = (rows: readonly NexusRow[], max: number) => addReply(undefined, rows, max)
692
693/** Rows as plain text for the model: each message, call and notice, a call with its output, as the CLI renders a harness conversation. */
694export function rowsText(rows: readonly NexusRow[], max: number): string {
695  const text = rows
696    .map(row => (row.kind === 'user' ? `> ${row.text}` : row.kind === 'tool' ? [`${toolName(row.name)}(${row.summary})`, row.output.trimEnd()].filter(Boolean).join('\n') : row.text))
697    .join('\n\n')
698  return keepTail(text || 'No messages yet.', max)
699}
700
701/** What `send` did: a harness thread's message queued for its agent, or a Claude Code worker's typed into its session (CLI 0.17.0). */
702export type Sent = { kind: 'agent' } | { kind: 'claude'; outcome: 'sent' | 'queued'; resumed: boolean }
703
704export function parseSent(json: unknown): Sent {
705  const claude = isRecord(json) ? json.claude : undefined
706  if (claude === undefined) return { kind: 'agent' }
707  if (!isRecord(claude) || (claude.outcome !== 'sent' && claude.outcome !== 'queued')) throw contractError('send', 'a Claude send without its outcome')
708  return { kind: 'claude', outcome: claude.outcome, resumed: claude.resumed === true }
709}
710
711/** What a send says it did, in the person's words. */
712export function sentText(thread: string, sent: Sent): string {
713  if (sent.kind === 'agent') return `Sent to ${thread}.`
714  const resumed = sent.resumed ? ' Nexus resumed the session first, as no pane ran it.' : ''
715  return sent.outcome === 'sent'
716    ? `Sent to the Claude Code session of ${thread}.${resumed}`
717    : `Queued in the Claude Code session of ${thread}: Claude reads it at its next tool step, or after this turn.${resumed}`
718}
719
720/** A conversation for the model: the CLI's own rendering of each entry. */
721export const transcript = (entries: readonly WatchEntry[], max: number) =>
722  keepTail(entries.map(entry => entry.text).filter(Boolean).join('\n\n') || 'No messages yet.', max)
723
724export type MatchResult = { id: string } | { error: string } | { unknown: true }
725
726/** A thread ID or a prefix of one, with or without `th-`, among `threads`. */
727export function matchThread(arg: string, threads: readonly NexusThread[]): MatchResult {
728  if (FULL_ID.test(arg)) return { id: arg }
729  const hex = arg.startsWith('th-') ? arg.slice(3) : arg
730  if (!/^[0-9a-f]+$/.test(hex)) return { error: `${arg} is not a thread ID or a prefix of one.` }
731  const found = threads.filter(thread => thread.id.slice(3).startsWith(hex))
732  if (found.length === 1) return { id: found[0]!.id }
733  if (found.length > 1) return { error: `${arg} matches ${found.length} threads: ${found.slice(0, 5).map(thread => thread.id).join(', ')}.` }
734  return { unknown: true }
735}
736
737export type ProjectGroup = {
738  project: string
739  /** Active and idle threads, newest first. */
740  open: NexusThreadRow[]
741  /** Archived and deleted ones, folded under the open ones. */
742  closed: NexusThreadRow[]
743  /** Whether the fold shows its threads: as the person left it, else when a filter matches one of them. */
744  isUnfolded: boolean
745}
746
747const isClosed = (thread: NexusThread) => thread.status === 'archived' || thread.status === 'deleted'
748
749/**
750 * Threads by project, A to Z, each group newest first with its closed threads folded below, as the dashboard
751 * lists them. A filter keeps the threads whose title holds it, opens a fold it matches and drops empty groups.
752 */
753export function groupThreads(threads: readonly NexusThreadRow[], filter: string, toggled: Readonly<Record<string, boolean>>): ProjectGroup[] {
754  const query = filter.trim().toLowerCase()
755  const groups = new Map<string, NexusThreadRow[]>()
756  for (const thread of threads) {
757    if (query && !thread.title.toLowerCase().includes(query)) continue
758    groups.set(thread.project, [...(groups.get(thread.project) ?? []), thread])
759  }
760  return [...groups.entries()]
761    .sort(([a], [b]) => a.localeCompare(b))
762    .map(([project, members]) => {
763      const sorted = members.toSorted((a, b) => b.createdAt - a.createdAt)
764      const closed = sorted.filter(isClosed)
765      return { project, open: sorted.filter(thread => !isClosed(thread)), closed, isUnfolded: toggled[closedKey(project)] ?? (query !== '' && closed.length > 0) }
766    })
767}
768
769export const closedKey = (project: string) => `closed-${project}`
770
771/** The list's rows in the order drawn, each group's fold where it folds: what the cursor walks. */
772export const listOrder = (groups: readonly ProjectGroup[]): { id: string }[] =>
773  groups.flatMap(group => [...group.open, ...(group.closed.length > 0 ? [{ id: closedKey(group.project) }] : []), ...(group.isUnfolded ? group.closed : [])])
774
775/** "3 archived, 1 deleted". */
776export function closedLabel(threads: readonly NexusThread[]): string {
777  const deleted = threads.filter(thread => thread.status === 'deleted').length
778  const archived = threads.length - deleted
779  return [archived > 0 && `${archived} archived`, deleted > 0 && `${deleted} deleted`].filter(Boolean).join(', ')
780}
781
782/** The colour of a state: `work` is an agent mid-turn, `busy` a pylon changing phase. The dashboard's tones. */
783export type Tone = 'ok' | 'work' | 'busy' | 'idle' | 'bad' | 'muted'
784export const TONE_COLOR: Record<Tone, string> = { ok: 'success', work: 'claude', busy: 'warning', idle: 'inactive', bad: 'error', muted: 'subtle' }
785
786export const ACTUAL_BADGE: Record<NexusActual, [label: string, tone: Tone]> = {
787  running: ['running', 'ok'],
788  spawning: ['spawning', 'busy'],
789  pausing: ['pausing', 'busy'],
790  deleting: ['deleting', 'busy'],
791  paused: ['paused', 'idle'],
792  absent: ['not on its node', 'muted'],
793  failed: ['failed', 'bad'],
794  unknown: ['unknown', 'muted'],
795}
796export const DESIRED_TONE: Record<NexusDesired, Tone> = { running: 'ok', paused: 'idle', deleted: 'muted' }
797
798/** What a thread is doing, most urgent first: the agent mid-turn hides a running pylon, never a failed one. */
799export function threadState(thread: Pick<NexusThread, 'status' | 'agentTurnRunning'>, pylon: NexusActual): [label: string, tone: Tone] {
800  if (thread.status === 'archived' || thread.status === 'deleted') return [thread.status, 'muted']
801  if (thread.agentTurnRunning && pylon !== 'failed') return ['agent working', 'work']
802  return ACTUAL_BADGE[pylon]
803}
804
805/** The word a list row shows for what an open thread asks of the person; working is the thread's state's own. */
806const ATTENTION_LOOK: Partial<Record<Exclude<NexusAttention, null>, [label: string, tone: Tone]>> = {
807  failed: ['failed', 'bad'],
808  decision: ['needs you', 'busy'],
809  parked: ['parked', 'bad'],
810  reply: ['new reply', 'ok'],
811  waiting: ['waiting', 'busy'],
812}
813
814/** A list row's word and tone: what the control plane's attention asks of the person, else what the thread is doing. */
815export function rowState(thread: NexusThreadRow): [label: string, tone: Tone] {
816  const isOpen = thread.status === 'active' || thread.status === 'idle'
817  return (isOpen && thread.attention && ATTENTION_LOOK[thread.attention]) || threadState(thread, thread.pylon.actualState)
818}
819
820/** "5 min", or null under 45 seconds: the dashboard's spans. */
821export function span(ms: number): string | null {
822  const seconds = Math.max(0, Math.round(ms / 1000))
823  return seconds < 45
824    ? null
825    : seconds < 45 * 60
826      ? `${Math.round(seconds / 60)} min`
827      : seconds < 36 * 3600
828        ? `${Math.round(seconds / 3600)} h`
829        : `${Math.round(seconds / 86_400)} d`
830}
831
832/** "5 min ago", "just now", or "never" for a time of 0. */
833export function since(at: number, now: number): string {
834  if (at === 0) return 'never'
835  const label = span(now - at)
836  return label === null ? 'just now' : `${label} ago`
837}
838
839/** A run's length as a clock reads it, such as 1m 12s. */
840export function clock(ms: number): string {
841  const s = Math.max(0, Math.floor(ms / 1000))
842  return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
843}
844
845/**
846 * A paused pylon's preview keeps its link, because a visit wakes the pylon. An archived thread's stays paused
847 * whatever the visit, so its link would only show the waking page.
848 */
849export function previewLook(detail: Pick<NexusDetail, 'thread' | 'pylon'>, preview: NexusPreview) {
850  if (detail.thread.status === 'archived') return { linked: false, label: 'paused', tone: 'idle' as Tone, hint: 'Unarchive the thread to open it.' }
851  if (detail.pylon.actualState === 'paused') return { linked: true, label: 'paused', tone: 'idle' as Tone, hint: 'Opening it wakes the pylon.' }
852  // A failed service's URL answers 502, and an unrouted port's 404, so it is no link; the reason is the node's.
853  if (preview.failed) return { linked: false, label: 'failed', tone: 'bad' as Tone, hint: preview.failed }
854  return preview.ready ? { linked: true, label: 'ready', tone: 'ok' as Tone, hint: null } : { linked: false, label: 'starting', tone: 'busy' as Tone, hint: null }
855}
856
857/** Who opened the thread's pane, what it runs and where: the dashboard's session labels. */
858export const HARNESS_LABEL: Record<NexusSession['harness'], string> = { 'claude-code': 'Claude Code', pi: 'pi', shell: 'Shell' }
859export const SESSION_TONE: Record<ShownSession['status'], Tone> = { working: 'work', idle: 'idle', blocked: 'bad', done: 'ok', unknown: 'muted', opened: 'muted' }
860
861/** The command that attaches a terminal on this Mac to the thread's pylon, through the CLI `cli` names, as a shell takes it. */
862export const attachCommand = (cli: string, thread: string) => `${shellQuote(cli)} attach ${shellQuote(thread)}`
863/** `word` as one word to a POSIX shell or zsh: bare when nothing in it is special, else single-quoted. */
864export const shellQuote = (word: string) => (/^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`)
865
866/** The pane of the tab `herdr tab create` printed, or null for output that names none. */
867export function parseHerdrTab(stdout: string): string | null {
868  try {
869    const json: unknown = JSON.parse(stdout)
870    const pane = isRecord(json) && isRecord(json.result) && isRecord(json.result.root_pane) ? json.result.root_pane.pane_id : undefined
871    return typeof pane === 'string' && pane ? pane : null
872  } catch {
873    return null
874  }
875}
876
877/** Why a herdr command failed: the message of the error it printed, else its output, else its exit code. */
878export function herdrError(exitCode: number, stdout: string, stderr: string): string {
879  for (const text of [stderr, stdout]) {
880    try {
881      const json: unknown = JSON.parse(text)
882      if (isRecord(json) && isRecord(json.error) && typeof json.error.message === 'string') return json.error.message
883    } catch {
884      // Not herdr's JSON: its usage text or a shell's message.
885    }
886  }
887  return stderr.trim() || stdout.trim() || `herdr exited with ${exitCode}`
888}
889
890/** What attaching from this Mac needs, in pi-nexus's words too. */
891export const ATTACH_HINT = 'Attaching needs Nexus CLI 0.9.0 or newer and goes through the control plane.'
892
893/** A session as a person names it: its harness, and its title when it has one, such as `Claude Code "login"`. */
894export const sessionName = (harness: NexusHarness, title: string | null | undefined) => `${HARNESS_LABEL[harness]}${title ? ` "${title}"` : ''}`
895
896/** How a person names the session `id` in a notice: by its pane while it has that pane, else by the ID `session close` takes. */
897export const sessionPlace = (id: string, session: Pick<NexusSession, 'herdrPane'> | undefined) => (session?.herdrPane === id || !session ? `pane ${id}` : `session ${id}`)
898
899/** What brings a paused session back: its pylon's wake, which for an archived thread only its unarchive gives. */
900export const resumesOn = (status: NexusThread['status'] | undefined) => (status === 'archived' ? 'unarchive' : 'wake')
901
902/** Why a paused session is not closed: `session close` refuses one without waking its pylon, so the pane does not ask. */
903export const pausedClose = (name: string, status: NexusThread['status'] | undefined) => `${name} is paused: it resumes on ${resumesOn(status)}, and can be closed once it runs.`
904
905/** The version `nexus --version` printed (`nexus 0.8.0`), or null when it printed none. */
906export function parseVersion(stdout: string): number[] | null {
907  const found = /(\d+)\.(\d+)\.(\d+)/.exec(stdout)
908  return found ? found.slice(1).map(Number) : null
909}
910
911/** Whether `version` comes before `than`, both as `parseVersion` gives them. */
912export const isOlder = (version: readonly number[], than: readonly number[]) => {
913  const at = version.findIndex((part, index) => part !== than[index])
914  return at !== -1 && version[at]! < than[at]!
915}
916
917/**
918 * How long an open or close confirmed in the pane outweighs details that do not show it. The node reads its sessions
919 * again once the CLI has answered, so the next details can still show them as they were.
920 */
921export const SESSION_SETTLE_MS = 30_000
922
923export const NO_SESSION_CHANGES: NexusSessionChanges = { opening: [], opened: [], closing: [], closed: {} }
924
925/** A session as the Pylon tab lists it: `opened` is one the CLI opened that the node has not reported yet. */
926export type ShownSession = NexusSession | (Omit<Extract<NexusSession, { state: 'running' }>, 'status'> & { status: 'opened' })
927
928type Opened = NexusSessionChanges['opened'][number]
929type Closed = NexusSessionChanges['closed'][string]
930
931/**
932 * The opens the details list, each against a session of its own. A pane names one session, so an open listed in its
933 * pane is matched first, unless that session's harness session ID was listed before the open: stale details then show
934 * the pane's earlier session. A wake can move a session to another pane, so an open is also matched by a session of its
935 * harness and title whose harness session ID the details did not list when the open was confirmed (`before`), which an
936 * existing session that moved keeps. A session with no harness session ID could be one that moved, so it matches only
937 * by pane, and an open nothing matches keeps its row until `SESSION_SETTLE_MS`.
938 */
939function listedOpens(opens: readonly Opened[], sessions: readonly NexusSession[]): Set<Opened> {
940  const taken = new Set<NexusSession>()
941  const listed = new Set<Opened>()
942  const claim = (one: Opened, matches: (session: NexusSession) => boolean) => {
943    const session = sessions.find(candidate => !taken.has(candidate) && matches(candidate))
944    if (!session) return
945    taken.add(session)
946    listed.add(one)
947  }
948  // Stale details can still list the pane's earlier session there: one whose harness session ID was listed before is not the open's.
949  const wasListed = (one: Opened, session: NexusSession) => session.harnessSessionId !== null && (one.before?.harnessSessionIds.includes(session.harnessSessionId) ?? false)
950  for (const one of opens) claim(one, session => session.herdrPane === one.session.herdrPane && !wasListed(one, session))
951  for (const one of opens) {
952    const { before } = one
953    if (listed.has(one) || !before) continue
954    claim(
955      one,
956      session =>
957        session.harnessSessionId !== null &&
958        !wasListed(one, session) &&
959        !before.ids.includes(session.id) &&
960        session.harness === one.session.harness &&
961        session.title === one.session.title,
962    )
963  }
964  return listed
965}
966
967/** Whether the details list `session` as the one closed as `id`: by its ID, or by its harness's session ID, which a wake that moves its pane keeps. */
968const isClosedAs = (id: string, closed: Closed, session: NexusSession) =>
969  session.id === id || (closed.harnessSessionId !== null && session.harnessSessionId === closed.harnessSessionId)
970
971/**
972 * The sessions as the details list them, with the confirmations they do not show yet, for `SESSION_SETTLE_MS` at
973 * most: a session just closed is gone, and a session just opened is there, `opened` until the node reports it.
974 */
975export function shownSessions(sessions: readonly NexusSession[], changes: NexusSessionChanges, now: number): ShownSession[] {
976  const isFresh = (at: number) => now - at < SESSION_SETTLE_MS
977  const closes = Object.entries(changes.closed).filter(([, closed]) => isFresh(closed.at))
978  const listed = sessions.filter(session => !closes.some(([id, closed]) => isClosedAs(id, closed, session)))
979  const listedOpen = listedOpens(changes.opened, sessions)
980  const opened = changes.opened
981    .filter(one => isFresh(one.at) && !closes.some(([id]) => id === one.session.herdrPane) && !listedOpen.has(one))
982    .map(({ session }): ShownSession => ({ ...session, id: session.herdrPane, state: 'running', message: null, status: 'opened', harnessSessionId: null }))
983  return [...listed, ...opened]
984}
985
986/** `changes` without the confirmations that `sessions`, just read, show, or that have waited `SESSION_SETTLE_MS`; the same object when none went. */
987export function settleSessions(changes: NexusSessionChanges, sessions: readonly NexusSession[], now: number): NexusSessionChanges {
988  const listedOpen = listedOpens(changes.opened, sessions)
989  const opened = changes.opened.filter(one => !listedOpen.has(one) && now - one.at < SESSION_SETTLE_MS)
990  const closed = Object.fromEntries(
991    Object.entries(changes.closed).filter(([id, one]) => sessions.some(session => isClosedAs(id, one, session)) && now - one.at < SESSION_SETTLE_MS),
992  )
993  if (opened.length === changes.opened.length && Object.keys(closed).length === Object.keys(changes.closed).length) return changes
994  return { ...changes, opened, closed }
995}
996
997// The Changes tab: the pylon's checkout as `changes` and `diff` print it, the tree the dashboard draws of it, and when
998// a read may run, since every read wakes a paused pylon.
999
1000const FILE_STATUS = new Set<string>(['added', 'modified', 'deleted', 'renamed', 'copied', 'typechange'])
1001
1002function parseBase(raw: unknown, command: string): NexusBase {
1003  if (!isRecord(raw) || (raw.kind !== 'start' && raw.kind !== 'turn' && raw.kind !== 'head')) throw contractError(command, 'no base')
1004  return { kind: raw.kind, commit: strOrNull(raw.commit) }
1005}
1006
1007const countOrNull = (value: unknown) => (typeof value === 'number' ? value : null)
1008
1009/** `changes --json`. A file without its path or a known status fails the read: the CLI and the mod disagree. */
1010export function parseChanges(json: unknown): NexusChanges {
1011  if (!isRecord(json) || !Array.isArray(json.files)) throw contractError('changes', 'no files')
1012  return {
1013    base: parseBase(json.base, 'changes'),
1014    files: json.files.map((raw): NexusChangedFile => {
1015      if (!isRecord(raw) || typeof raw.path !== 'string' || !FILE_STATUS.has(str(raw.status))) throw contractError('changes', 'a file without its path or status')
1016      return { path: raw.path, oldPath: strOrNull(raw.oldPath), status: raw.status as NexusFileStatus, additions: countOrNull(raw.additions), deletions: countOrNull(raw.deletions) }
1017    }),
1018    truncated: json.truncated === true,
1019  }
1020}
1021
1022/** `diff --json`: the unified diff as text, and whether the CLI cut it. */
1023export function parseDiffReply(json: unknown): { base: NexusBase; diff: string; truncated: boolean } {
1024  if (!isRecord(json) || typeof json.diff !== 'string') throw contractError('diff', 'no diff')
1025  return { base: parseBase(json.base, 'diff'), diff: json.diff, truncated: json.truncated === true }
1026}
1027
1028/** The one-letter badge of a file's status, as the dashboard's tree shows it. */
1029export const STATUS_LETTER: Record<NexusFileStatus, string> = { added: 'A', modified: 'M', deleted: 'D', renamed: 'R', copied: 'C', typechange: 'T' }
1030
1031/** The facts a Changes read hangs on, from the thread's details. */
1032export type CheckoutFacts = { status: NexusThread['status']; pylon: NexusActual; turn: boolean; agentAt: number }
1033
1034export const checkoutFacts = (detail: Pick<NexusDetail, 'thread' | 'pylon'>): CheckoutFacts => ({
1035  status: detail.thread.status,
1036  pylon: detail.pylon.actualState,
1037  turn: detail.thread.agentTurnRunning,
1038  agentAt: detail.thread.lastAgentActivityAt,
1039})
1040
1041/** A read wakes nothing only on a running pylon, and an archived or deleted thread's checkout is never read. */
1042export const canReadCheckout = (facts: CheckoutFacts) => facts.pylon === 'running' && (facts.status === 'active' || facts.status === 'idle')
1043
1044/**
1045 * Whether new details call for a read, as the dashboard's `noticeFacts` decides: an agent turn ended on a running
1046 * pylon (the run went idle, or the agent was active since with no turn running, so a turn between two polls counts
1047 * too), or the pylon started running after the person asked to wake it, or while the Changes tab waits for its first
1048 * read. Never on a timer: a read counts as human activity and keeps the pylon awake.
1049 */
1050export function checkoutTrigger(before: CheckoutFacts | null, now: CheckoutFacts, waiting: { isWanted: boolean; isFirst: boolean }): boolean {
1051  if (!before || !canReadCheckout(now)) return false
1052  const started = before.pylon !== 'running'
1053  const turnEnded = !now.turn && (before.turn || now.agentAt !== before.agentAt)
1054  return turnEnded || (started && (waiting.isWanted || waiting.isFirst))
1055}
1056
1057/** `6 files · +30017 −5`, and what they are compared with: `since the pylon booted, 9abe10e`. */
1058export function changesSummary(changes: NexusChanges): { counts: string; added: number; deleted: number; since: string } {
1059  const added = changes.files.reduce((sum, file) => sum + (file.additions ?? 0), 0)
1060  const deleted = changes.files.reduce((sum, file) => sum + (file.deletions ?? 0), 0)
1061  const { kind, commit } = changes.base
1062  // The base is the empty tree before a checkout's first commit, and at the start of a scratch project's pylon. A turn's is a hidden snapshot, no commit of the branch.
1063  const at = kind === 'turn' ? null : commit === null ? (kind === 'head' ? 'no commit yet' : 'an empty repository') : commit.slice(0, 7)
1064  return { counts: `${plural(changes.files.length, 'file', 'files')}`, added, deleted, since: at === null ? BASES[kind].since : `${BASES[kind].since}, ${at}` }
1065}
1066
1067/** A row of the Changes tree as drawn: a folder, which Enter folds, or a file, which Enter opens. */
1068export type TreeRow =
1069  | { kind: 'dir'; key: string; path: string; name: string; depth: number; isOpen: boolean }
1070  | { kind: 'file'; key: string; name: string; depth: number; file: NexusChangedFile }
1071
1072type Folder = { dirs: Map<string, Folder>; files: { name: string; file: NexusChangedFile }[] }
1073
1074export const dirKey = (path: string) => `dir-${path}`
1075export const fileKey = (path: string) => `file-${path}`
1076
1077/**
1078 * The changed files as the dashboard's tree: folders first, then files, each by name, a folder that holds only one
1079 * folder merged into it (`src/checkout`), and the folders the person folded (`toggled`, keyed `dir-<path>`) closed.
1080 */
1081export function changeTree(files: readonly NexusChangedFile[], toggled: Readonly<Record<string, boolean>>): TreeRow[] {
1082  const root: Folder = { dirs: new Map(), files: [] }
1083  for (const file of files) {
1084    const parts = file.path.split('/')
1085    let folder = root
1086    for (const part of parts.slice(0, -1)) {
1087      if (!folder.dirs.has(part)) folder.dirs.set(part, { dirs: new Map(), files: [] })
1088      folder = folder.dirs.get(part)!
1089    }
1090    folder.files.push({ name: parts.at(-1)!, file })
1091  }
1092  const rows: TreeRow[] = []
1093  const walk = (folder: Folder, path: string, depth: number) => {
1094    for (const [part, inner] of [...folder.dirs].sort(([a], [b]) => a.localeCompare(b))) {
1095      let name = part
1096      let deepest = inner
1097      while (deepest.files.length === 0 && deepest.dirs.size === 1) {
1098        const [child, next] = [...deepest.dirs][0]!
1099        name = `${name}/${child}`
1100        deepest = next
1101      }
1102      const at = path === '' ? name : `${path}/${name}`
1103      const isOpen = toggled[dirKey(at)] ?? true
1104      rows.push({ kind: 'dir', key: dirKey(at), path: at, name, depth, isOpen })
1105      if (isOpen) walk(deepest, at, depth + 1)
1106    }
1107    for (const { name, file } of folder.files.toSorted((a, b) => a.name.localeCompare(b.name))) rows.push({ kind: 'file', key: fileKey(file.path), name, depth, file })
1108  }
1109  walk(root, '', 0)
1110  return rows
1111}
1112
1113/** The most rows a diff shows, as the dashboard caps it: the rest is said to be cut. */
1114export const DIFF_ROWS = 20_000
1115/** What a diff row keeps of its line: the pane cuts long lines at its width, and `$.state` holds the rows as JSON. */
1116const DIFF_CHARS = 400
1117/** What a diff says of a file beyond its lines, such as a rename or a new mode: the dashboard's `metaLine`. */
1118const META_LINE = /^(old mode|new mode|new file mode|deleted file mode|similarity index|rename from|rename to|copy from|copy to) /
1119
1120/**
1121 * A unified diff's rows, each line with the new file's number or none for a removed one, the hunk separators with
1122 * their heading, and the lines that say what changed beyond the lines, as the dashboard's `parseDiff` reads it.
1123 */
1124export function parseDiff(text: string, max = DIFF_ROWS): { rows: NexusDiffRow[]; meta: string[]; isCut: boolean; isBinary: boolean } {
1125  const rows: NexusDiffRow[] = []
1126  const meta: string[] = []
1127  let next = 0
1128  let inHunk = false
1129  for (const line of text.split('\n')) {
1130    if (rows.length >= max) break
1131    if (line.startsWith('diff --git ')) {
1132      inHunk = false
1133      continue
1134    }
1135    const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(line)
1136    if (hunk) {
1137      next = Number(hunk[1])
1138      inHunk = true
1139      rows.push({ kind: 'hunk', text: hunk[2]!.trim().slice(0, DIFF_CHARS) })
1140      continue
1141    }
1142    if (!inHunk) {
1143      if (META_LINE.test(line)) meta.push(line)
1144      continue
1145    }
1146    if (line === '' || line[0] === '\\') continue
1147    const kind = line[0] === '+' ? 'add' : line[0] === '-' ? 'del' : 'ctx'
1148    rows.push({ kind, line: kind === 'del' ? null : next, text: line.slice(1, DIFF_CHARS + 1) })
1149    if (kind !== 'del') next += 1
1150  }
1151  return { rows, meta, isCut: rows.length >= max, isBinary: /^Binary files /m.test(text) }
1152}
1153
1154/** What the Changes tab can compare with, in the order `c` steps through them: the CLI's flag and the tab's words. */
1155export const BASES: Record<NexusBase['kind'], { flag: string[]; next: NexusBase['kind']; name: string; shows: string; since: string; against: string; none: string }> = {
1156  start: { flag: [], next: 'turn', name: 'all changes', shows: 'all the changes since the pylon booted', since: 'since the pylon booted', against: 'vs boot', none: 'Nothing has changed since the pylon booted.' },
1157  turn: { flag: ['--turn'], next: 'head', name: 'latest turn', shows: "the latest turn's changes", since: 'since the latest turn began', against: 'vs turn', none: 'Nothing changed in the latest turn.' },
1158  head: { flag: ['--uncommitted'], next: 'start', name: 'uncommitted', shows: 'the uncommitted changes', since: 'since the last commit', against: 'vs last commit', none: 'No uncommitted changes.' },
1159}
1160
1161/** The changes reading needs: its argv, the base's flag, the thread after `--` as the CLI's parseArgs takes it. */
1162export const changesArgv = (thread: string, base: NexusBase['kind']) => ['changes', ...BASES[base].flag, '--', thread]
1163
1164/** A file's diff: a renamed file's old path goes too, or the diff shows it as added. */
1165export const diffArgv = (thread: string, file: NexusChangedFile, base: NexusBase['kind']) => ['diff', ...BASES[base].flag, '--', thread, file.path, ...(file.oldPath ? [file.oldPath] : [])]
1166
1167/**
1168 * A refusal of the base: a pylon that booted before Nexus recorded it, or no latest-turn snapshot yet. The CLI then
1169 * says to ask since HEAD.
1170 */
1171export const isNoBase = (message: string) => /refs\/nexus\/base|latest-turn snapshot|since HEAD/i.test(message)
1172
1173export type ToolRow = Extract<NexusRow, { kind: 'tool' }>
1174
1175/** A tool call as drawn: still running in a busy run, without a result once the run stopped, or settled. */
1176export type ToolStatus = 'running' | 'none' | 'ok' | 'error'
1177
1178export const toolStatus = (tool: ToolRow, isWorking: boolean): ToolStatus =>
1179  tool.outcome !== 'running' ? tool.outcome : isWorking ? 'running' : 'none'
1180
1181/** One block of the conversation as drawn. A run of tool calls this long folds into one line. */
1182export const FOLD_AT = 4
1183
1184/**
1185 * A block's `key` names the row it starts at, counted from the watch's first, so keeping a shorter tail of rows
1186 * changes no key. `isLive` marks what the run shows of itself, which the next live event replaces.
1187 */
1188export type Block = { key: string; isLive: boolean } & (
1189  | { kind: 'user'; text: string }
1190  | { kind: 'assistant'; text: string }
1191  | { kind: 'notice'; tone: 'error' | 'dim' | 'warning'; text: string }
1192  | { kind: 'tools'; tools: ToolRow[] }
1193)
1194
1195/**
1196 * The rows, `dropped` of them before the first, then what the run shows of itself: the answer streaming, a retry
1197 * waiting, and the queued messages. The answer streaming takes the key of the row it becomes once committed.
1198 */
1199export function blocks(rows: readonly NexusRow[], dropped: number, live: NexusLive | null): Block[] {
1200  const out: Block[] = []
hooks/keys.ts 469 lines
1// The pane's keys: one table of the letters it answers, and what a press does to
2// the pane, as a pure step from the pane and what the drawing laid out to the next
3// pane and the effects the hooks module carries out. Claude Code gives a pane's
4// hotkeys one lowercase letter or digit, Shift folded in, so vim's G, ctrl+d and
5// ctrl+u, / and ? are spelled ge, d, u, f and s here.
6import type { NexusChangedFile, NexusField, NexusMode, NexusPane, NexusTab } from '../types'
7import type { Action } from './model'
8
9export type Command =
10  | 'down'
11  | 'up'
12  | 'g'
13  | 'e'
14  | 'halfDown'
15  | 'halfUp'
16  | 'left'
17  | 'right'
18  | 'open'
19  | 'insert'
20  | 'filter'
21  | 'unpublish'
22  | 'yank'
23  | 'menu'
24  | 'new'
25  | 'projects'
26  | 'refresh'
27  | 'base'
28  | 'help'
29  | 'close'
30
31/** Every letter the pane answers, drawn as a hidden Button each, in every view but the start form. */
32export const KEYMAP: readonly { hotkey: string; command: Command }[] = [
33  { hotkey: 'j', command: 'down' },
34  { hotkey: 'k', command: 'up' },
35  { hotkey: 'g', command: 'g' },
36  { hotkey: 'e', command: 'e' },
37  { hotkey: 'd', command: 'halfDown' },
38  { hotkey: 'u', command: 'halfUp' },
39  { hotkey: 'h', command: 'left' },
40  { hotkey: 'l', command: 'right' },
41  { hotkey: 'o', command: 'open' },
42  { hotkey: 'i', command: 'insert' },
43  { hotkey: 'a', command: 'insert' },
44  { hotkey: 'f', command: 'filter' },
45  { hotkey: 'x', command: 'unpublish' },
46  { hotkey: 'y', command: 'yank' },
47  { hotkey: 'm', command: 'menu' },
48  { hotkey: 'n', command: 'new' },
49  { hotkey: 'p', command: 'projects' },
50  { hotkey: 'r', command: 'refresh' },
51  { hotkey: 'c', command: 'base' },
52  { hotkey: 's', command: 'help' },
53  { hotkey: 'q', command: 'close' },
54]
55
56/** The key help, by where it applies: what `s` shows. */
57export const HELP: readonly { title: string; keys: readonly [keys: string, what: string][] }[] = [
58  {
59    title: 'Everywhere',
60    keys: [
61      ['j k ↓ ↑', 'move the cursor, or scroll a block at a time'],
62      ['g g · g e', 'top · bottom (the end follows the conversation again)'],
63      ['d u', 'half a page down · up'],
64      ['enter o', 'open or press what is under the cursor'],
65      ['p', "projects: show one project's threads, every project's, or add a project"],
66      ['s', 'this help'],
67      ['q', 'close what is open: the help, the menu, else the pane'],
68      ['esc', 'leave the composer or the filter; else hand the keys to the prompt'],
69    ],
70  },
71  { title: 'Threads', keys: [['l', 'open the thread'], ['f', 'filter by title'], ['n', 'start a thread'], ['y', "copy the thread's ID"], ['r', 'refresh']] },
72  {
73    title: 'A thread',
74    keys: [
75      ['h l', 'previous · next tab: Conversation, Changes, Previews, Pylon; h on Conversation goes back to the threads'],
76      ['i a', 'write a message (insert mode); enter sends, an empty enter or esc leaves'],
77      ['m', 'actions: abort, retry, archive, delete, start again, copy attach, sessions'],
78      ['n', 'open a session in the pylon: Claude Code, pi or a shell'],
79      ['y', 'copy the block under the cursor, a path, a URL, or the attach command'],
80      ['r', 'watch again; in Changes, read the changes again'],
81    ],
82  },
83  {
84    title: 'Changes',
85    keys: [
86      ['enter o', "open a file's diff, or fold a folder; on a paused pylon, wake it and read"],
87      ['c', 'compare with what the pylon booted at, the latest turn or the last commit, in turn'],
88      ['r', 'read the changes again, waking a paused pylon'],
89      ['y', "copy the file's path"],
90      ['h', 'from a diff, back to the files'],
91    ],
92  },
93  { title: 'Previews', keys: [['enter', 'publish a port, or copy a preview’s URL'], ['x', 'unpublish the linked preview under the cursor']] },
94  {
95    title: 'Pylon',
96    keys: [
97      ['enter', 'on a session or Attach: attach in a new Herdr tab, inside Herdr; else copy nexus attach'],
98      ['y', 'copy the attach command: from a shell in the pylon, or nexus attach on a session'],
99      ['x', 'close the session under the cursor, after asking'],
100      ['n', 'open a session'],
101    ],
102  },
103]
104
105/** What a single press asks for that only the hooks module can do. */
106export type Effect =
107  | { kind: 'open'; thread: string }
108  | { kind: 'back' }
109  | { kind: 'tab'; tab: NexusTab }
110  | { kind: 'focus'; key: string }
111  /** A port change names the thread it was drawn for: a press that lands after the pane moved on changes nothing. */
112  | { kind: 'publish'; thread: string; port: number }
113  | { kind: 'unpublish'; thread: string; port: number }
114  | { kind: 'copy'; text: string }
115  /** A notice instead of a press that cannot do what it asks, such as x on a declared service's preview. */
116  | { kind: 'say'; text: string }
117  | { kind: 'run'; action: MenuAction }
118  | { kind: 'insert' }
119  | { kind: 'filter' }
120  | { kind: 'new' }
121  /** The project picker opened: its projects read afresh. */
122  | { kind: 'projects' }
123  /** A project chosen in the picker, null for every project. */
124  | { kind: 'scope'; project: string | null }
125  | { kind: 'new-project' }
126  /** The open a session form, for the thread shown. */
127  | { kind: 'new-session' }
128  /** Asks, then closes the session `id` of `thread`: a running one's pane, or a failed one. */
129  | { kind: 'close-session'; thread: string; id: string }
130  /** `nexus attach` for `thread`'s sessions: run in a new Herdr tab inside Herdr, else copied; `isCopy` always copies. */
131  | { kind: 'attach'; thread: string; isCopy: boolean }
132  | { kind: 'refresh' }
133  /** Changes read again for `thread`, waking a paused pylon: `r`, or Enter on the paused note. */
134  | { kind: 'read-changes'; thread: string }
135  /** Compares the changes with the next base: what the pylon booted at, the latest turn, the last commit. */
136  | { kind: 'base' }
137  /** A changed file's diff, in place of the tree. */
138  | { kind: 'open-diff'; thread: string; file: NexusChangedFile }
139  | { kind: 'close' }
140
141/** What the Actions menu runs: the CLI's verbs on a thread, and the pane's own. */
142export type MenuAction = Action | 'start-again' | 'copy-attach' | 'open-session' | 'attach' | 'rewatch'
143
144/** A press on an item that changes only the pane: a fold, a tool row or a closed group opened or closed. */
145export type Toggle = { kind: 'toggle'; key: string; isOpen: boolean }
146
147/**
148 * One row the cursor stops on, as laid out: its key, its place in rows from the top of its list, and what Enter, x
149 * and y do on it.
150 */
151export type NavItem = { key: string; top: number; height: number; enter?: Effect | Toggle; x?: Effect; y?: Effect }
152
153/** What the drawing laid out for the keys to move over, `area` being the rows the scrolled part shows. */
154export type Nav =
155  | { context: 'list'; items: NavItem[]; area: number }
156  | { context: 'conversation'; items: NavItem[]; total: number; area: number; canInsert: boolean }
157  /** The tree, or the note in its place; `thread` is what r reads again. */
158  | { context: 'changes'; items: NavItem[]; area: number; canInsert: boolean; cursor: string | null; thread: string }
159  /** A diff in place of the tree, scrolled by the keys; h goes back to the file. */
160  | { context: 'diff'; total: number; area: number; canInsert: boolean; thread: string; path: string; wake?: Effect }
161  /** `cursor` as drawn: a port that moved between the sections keeps it. */
162  | { context: 'previews'; items: NavItem[]; area: number; canInsert: boolean; cursor: string | null }
163  /** The pylon's details, each session and the attach from this Mac, as stops. */
164  | { context: 'pylon'; items: NavItem[]; area: number; canInsert: boolean; cursor: string | null }
165  | { context: 'menu'; items: NavItem[]; area: number }
166  | { context: 'projects'; items: NavItem[]; area: number; drawn: string }
167  | { context: 'help'; total: number; area: number }
168  | { context: 'none' }
169
170export const TABS: readonly NexusTab[] = ['conversation', 'changes', 'previews', 'pylon']
171
172/** The keys of the composer and the list's filter, the two fields that take typed letters. */
173export const COMPOSE = 'compose'
174export const FILTER = 'filter'
175
176/** `fields` with each of `texts`, keyed by field name, put in its field. */
177export const putFields = (fields: Readonly<Record<string, NexusField>>, texts: Readonly<Record<string, string>>): Record<string, NexusField> => ({
178  ...fields,
179  ...Object.fromEntries(Object.entries(texts).map(([name, text]) => [name, { text, puts: (fields[name]?.puts ?? 0) + 1 }])),
180})
181
182/**
183 * The key the field `name` is drawn under: a new one for each put. The engine keeps what is typed in a field by key,
184 * and a `value` replaces it only when it differs from the one this mount last drew, so a put drawn under the same key
185 * could leave older typing in its place.
186 */
187export const fieldKey = (fields: Readonly<Record<string, NexusField>>, name: string) => `${name}#${fields[name]?.puts ?? 0}`
188
189/** The field a drawn key belongs to, or the key itself when it is no field's. */
190export const fieldOf = (key: string) => key.replace(/#\d+$/, '')
191
192type Step = { pane: NexusPane; effects: Effect[] }
193
194const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value))
195
196/** The key of the item a motion lands on, from the one under the cursor (the first when none is). */
197function moveOver(items: readonly NavItem[], current: string | null, command: Command, area: number): string | undefined {
198  if (items.length === 0) return undefined
199  const at = Math.max(0, items.findIndex(item => item.key === current))
200  const here = items[at]!
201  const half = Math.max(1, Math.floor(area / 2))
202  switch (command) {
203    case 'down':
204      return items[Math.min(items.length - 1, at + 1)]!.key
205    case 'up':
206      return items[Math.max(0, at - 1)]!.key
207    case 'g':
208      return items[0]!.key
209    case 'e':
210      return items.at(-1)!.key
211    case 'halfDown':
212      return (items.find(item => item.top >= here.top + half) ?? items.at(-1)!).key
213    case 'halfUp':
214      return (items.findLast(item => item.top <= here.top - half) ?? items[0]!).key
215    default:
216      return here.key
217  }
218}
219
220const isMotion = (command: Command) => command === 'down' || command === 'up' || command === 'g' || command === 'e' || command === 'halfDown' || command === 'halfUp'
221
222/** The first row to show so that `item` shows whole, moving `top` as little as it can. */
223export function keepInView(top: number, item: Pick<NavItem, 'top' | 'height'> | undefined, area: number): number {
224  if (!item) return 0
225  if (item.top < top) return item.top
226  if (item.top + item.height > top + area) return item.top + item.height - area
227  return top
228}
229
230/** Rows the conversation's bottom edge stands above its end: 0 follows it. */
231export function scrolledRows(scroll: NexusPane['scroll'], items: readonly NavItem[], total: number, area: number): number {
232  const anchor = scroll && items.find(item => item.key === scroll.anchor)
233  if (!anchor) return 0
234  return clamp(total - (anchor.top + anchor.height - scroll.rows), 0, Math.max(0, total - area))
235}
236
237/** The block under the conversation's cursor, which o, Enter and y act on: the anchor, else the newest. */
238export function blockAt(items: readonly NavItem[], scroll: NexusPane['scroll']): number {
239  const at = scroll ? items.findIndex(item => item.key === scroll.anchor) : -1
240  return at >= 0 ? at : items.length - 1
241}
242
243/** The scroll whose bottom edge stands `rows` above the end, the cursor on the block that edge falls in; null at the end. */
244function scrollTo(items: readonly NavItem[], total: number, rows: number): NexusPane['scroll'] {
245  if (rows <= 0 || items.length === 0) return null
246  const edge = total - rows
247  const block = items.find(item => item.top < edge && item.top + item.height >= edge) ?? items[0]!
248  return { anchor: block.key, rows: block.top + block.height - edge }
249}
250
251/** The cursor on `items[index]`, the window moved as little as shows the block's top. */
252function moveTo(items: readonly NavItem[], total: number, area: number, rows: number, index: number): NexusPane['scroll'] {
253  const target = items[index]!
254  let edge = total - rows
255  if (target.top >= edge) edge = Math.min(target.top + target.height, target.top + area)
256  else if (target.top < edge - area) edge = target.top + area
257  edge = clamp(edge, Math.min(area, total), total)
258  return { anchor: target.key, rows: target.top + target.height - edge }
259}
260
261function scrollConversation(pane: NexusPane, command: Command, nav: Extract<Nav, { context: 'conversation' }>): NexusPane['scroll'] {
262  const { items, total, area } = nav
263  if (items.length === 0) return null
264  const rows = scrolledRows(pane.scroll, items, total, area)
265  const half = Math.max(1, Math.floor(area / 2))
266  const at = blockAt(items, pane.scroll)
267  // j and k step the cursor a block; d and u move the window half its height, through a long block too.
268  switch (command) {
269    case 'down':
270      return at + 1 >= items.length ? null : moveTo(items, total, area, rows, at + 1)
271    case 'up':
272      return moveTo(items, total, area, rows, Math.max(0, at - 1))
273    case 'g':
274      return moveTo(items, total, area, rows, 0)
275    case 'e':
276      return null
277    case 'halfDown':
278      return scrollTo(items, total, rows - half)
279    case 'halfUp':
280      return scrollTo(items, total, Math.min(Math.max(0, total - area), rows + half)) ?? moveTo(items, total, area, rows, Math.max(0, at - 1))
281    default:
282      return pane.scroll
283  }
284}
285
286/** The pane after `command` with what the drawing laid out, and what the hooks module must do for it. */
287export function press(before: NexusPane, pressed: Command, nav: Nav): Step {
288  let command = pressed
289  // g waits for its second key: g g is the top, g e the bottom, and any other key goes on as itself.
290  if (before.pending === 'g' && (pressed === 'g' || pressed === 'e')) command = pressed
291  else if (pressed === 'g') return { pane: { ...before, pending: 'g' }, effects: [] }
292  else if (pressed === 'e') return { pane: { ...before, pending: null }, effects: [] }
293  const pane: NexusPane = { ...before, pending: null, notice: null }
294  const done = (change: Partial<NexusPane> = {}, effects: Effect[] = []): Step => ({ pane: { ...pane, ...change }, effects })
295  const apply = (effect: Effect | Toggle | undefined, change: Partial<NexusPane> = {}): Step => {
296    if (!effect) return done(change)
297    if (effect.kind === 'toggle') return done({ ...change, toggled: { ...pane.toggled, [effect.key]: !effect.isOpen } })
298    return done(change, [effect])
299  }
300  const mode = (next: NexusMode): Partial<NexusPane> => ({ mode: next })
301
302  if (pane.overlay?.kind === 'help') {
303    if (command === 'close' || command === 'help' || command === 'left') return done({ overlay: null })
304    // Taller than the body, the help scrolls; it takes no other key.
305    const most = nav.context === 'help' ? Math.max(0, nav.total - nav.area) : 0
306    const half = nav.context === 'help' ? Math.max(1, Math.floor(nav.area / 2)) : 1
307    const moved: Partial<Record<Command, number>> = { down: 1, up: -1, halfDown: half, halfUp: -half, g: -Infinity, e: Infinity }
308    const by = moved[command]
309    return by === undefined ? done() : done({ overlay: { kind: 'help', top: clamp(pane.overlay.top + by, 0, most) } })
310  }
311  if (nav.context === 'menu' && pane.overlay?.kind === 'menu') {
312    if (command === 'close' || command === 'menu' || command === 'left') return done({ overlay: null })
313    const chosen = pane.overlay.cursor
314    const current = nav.items.find(item => item.key === chosen) ?? nav.items[0]
315    if (isMotion(command)) {
316      const key = moveOver(nav.items, current?.key ?? null, command, nav.area)
317      return done({ overlay: { kind: 'menu', cursor: key ?? null } }, key ? [{ kind: 'focus', key }] : [])
318    }
319    if (command === 'open' || command === 'right') return apply(current?.enter, { overlay: null })
320    return done()
321  }
322  if (nav.context === 'projects' && pane.overlay?.kind === 'projects') {
323    if (command === 'close' || command === 'projects' || command === 'left') return done({ overlay: null })
324    // Until a key moves it, the cursor is the row drawn current.
325    const chosen = pane.overlay.cursor ?? nav.drawn
326    const current = nav.items.find(item => item.key === chosen) ?? nav.items[0]
327    if (isMotion(command)) {
328      const key = moveOver(nav.items, current?.key ?? null, command, nav.area)
329      return done({ overlay: { kind: 'projects', cursor: key ?? null } }, key ? [{ kind: 'focus', key }] : [])
330    }
331    if (command === 'open' || command === 'right') return apply(current?.enter, { overlay: null })
332    return done()
333  }
334
335  // What every thread tab answers alike.
336  const thread = nav.context === 'conversation' || nav.context === 'changes' || nav.context === 'diff' || nav.context === 'previews' || nav.context === 'pylon' ? nav : null
337  if (thread) {
338    // In Changes, c switches what the changes compare with, and r reads them again rather than watching again.
339    if (nav.context === 'changes' || nav.context === 'diff') {
340      if (command === 'base') return done({}, [{ kind: 'base' }])
341      if (command === 'refresh') return done({}, [{ kind: 'read-changes', thread: nav.thread }])
342    }
343    // h on a diff goes back to the tree, the cursor on its file.
344    if (nav.context === 'diff' && command === 'left') {
345      const key = `file-${nav.path}`
346      return done({ diff: null, changesCursor: key, ...mode('normal') }, [{ kind: 'focus', key }])
347    }
348    switch (command) {
349      case 'menu':
350        return done({ overlay: { kind: 'menu', cursor: null } })
351      case 'insert':
352        return thread.canInsert ? done({ tab: 'conversation', ...mode('insert') }, [{ kind: 'insert' }]) : done()
353      case 'left': {
354        const at = TABS.indexOf(pane.tab)
355        if (at <= 0) return done(mode('normal'), [{ kind: 'back' }])
356        return done({ tab: TABS[at - 1]!, ...mode('normal') }, [{ kind: 'tab', tab: TABS[at - 1]! }])
357      }
358      case 'right': {
359        const at = TABS.indexOf(pane.tab)
360        if (at >= TABS.length - 1) return done()
361        return done({ tab: TABS[at + 1]!, ...mode('normal') }, [{ kind: 'tab', tab: TABS[at + 1]! }])
362      }
363    }
364  }
365  switch (command) {
366    case 'help':
367      return done({ overlay: { kind: 'help', top: 0 } })
368    case 'projects':
369      return nav.context === 'list' || thread ? done({ overlay: { kind: 'projects', cursor: null } }, [{ kind: 'projects' }]) : done()
370    case 'close':
371      return done({}, [{ kind: 'close' }])
372    // In a thread, n opens a session in its pylon, as in pi-nexus; the list starts a thread.
373    case 'new':
374      return done({}, [thread ? { kind: 'new-session' } : { kind: 'new' }])
375    case 'refresh':
376      return done({}, [{ kind: 'refresh' }])
377  }
378
379  switch (nav.context) {
380    case 'list': {
381      const current = nav.items.find(item => item.key === pane.listCursor) ?? nav.items[0]
382      if (isMotion(command)) {
383        const key = moveOver(nav.items, current?.key ?? null, command, nav.area)
384        const item = nav.items.find(one => one.key === key)
385        return done({ listCursor: key ?? null, listTop: keepInView(pane.listTop, item, nav.area) }, key ? [{ kind: 'focus', key }] : [])
386      }
387      if (command === 'open' || command === 'right') return apply(current?.enter)
388      if (command === 'yank') return apply(current?.y)
389      if (command === 'filter') return done(mode('filter'), [{ kind: 'filter' }])
390      return done()
391    }
392    case 'conversation': {
393      if (isMotion(command)) {
394        // The ring follows the cursor: onto the block when it is a button, else back to the tab, so Tab and ⏎ act where the marker is.
395        const scroll = scrollConversation(pane, command, nav)
396        const block = nav.items[blockAt(nav.items, scroll)]
397        return done({ scroll }, [{ kind: 'focus', key: block?.enter ? block.key : 'tab-conversation' }])
398      }
399      const current = nav.items[blockAt(nav.items, pane.scroll)]
400      if (command === 'open') return apply(current?.enter)
401      if (command === 'yank') return apply(current?.y)
402      return done()
403    }
404    case 'changes': {
405      const current = nav.items.find(item => item.key === nav.cursor) ?? nav.items[0]
406      if (isMotion(command)) {
407        const key = moveOver(nav.items, current?.key ?? null, command, nav.area)
408        return done({ changesCursor: key ?? null }, key ? [{ kind: 'focus', key }] : [])
409      }
410      if (command === 'open') return apply(current?.enter)
411      if (command === 'yank') return apply(current?.y)
412      return done()
413    }
414    case 'diff': {
415      if (!pane.diff) return done()
416      const most = Math.max(0, nav.total - nav.area)
417      const half = Math.max(1, Math.floor(nav.area / 2))
418      const moved: Partial<Record<Command, number>> = { down: 1, up: -1, halfDown: half, halfUp: -half, g: -Infinity, e: Infinity }
419      const by = moved[command]
420      if (by !== undefined) return done({ diff: { ...pane.diff, top: clamp(pane.diff.top + by, 0, most) } })
421      if (command === 'yank') return done({}, [{ kind: 'copy', text: nav.path }])
422      if (command === 'open') return apply(nav.wake)
423      return done()
424    }
425    case 'previews': {
426      const current = nav.items.find(item => item.key === nav.cursor) ?? nav.items[0]
427      if (isMotion(command)) {
428        const key = moveOver(nav.items, current?.key ?? null, command, nav.area)
429        return done({ portCursor: key ?? null }, key ? [{ kind: 'focus', key }] : [])
430      }
431      if (command === 'open') return apply(current?.enter)
432      if (command === 'unpublish') return apply(current?.x)
433      if (command === 'yank') return apply(current?.y)
434      return done()
435    }
436    case 'pylon': {
437      const current = nav.items.find(item => item.key === nav.cursor) ?? nav.items[0]
438      if (isMotion(command)) {
439        // The ring follows the cursor onto a stop that is a button, else back to the tab, whose ⏎ acts as o does.
440        const item = nav.items.find(one => one.key === moveOver(nav.items, current?.key ?? null, command, nav.area))
441        return done({ pylonCursor: item?.key ?? null }, item ? [{ kind: 'focus', key: item.enter ? item.key : 'tab-pylon' }] : [])
442      }
443      if (command === 'open') return apply(current?.enter)
444      if (command === 'yank') return apply(current?.y)
445      if (command === 'unpublish') return current?.x ? apply(current.x) : done({}, [{ kind: 'say', text: 'No session to close. n opens one.' }])
446      return done()
447    }
448    default:
449      return done()
450  }
451}
452
453/** The pane's state after a person moved the ring onto `element` with the arrows, Tab or a click. */
454export function ringMoved(pane: NexusPane, element: string): NexusPane {
455  const field = fieldOf(element)
456  if (field === COMPOSE) return { ...pane, mode: 'insert', overlay: null, tab: 'conversation' }
457  if (field === FILTER) return { ...pane, mode: 'filter' }
458  // Leaving a field by the ring is leaving its mode.
459  const base: NexusPane = pane.mode === 'normal' ? pane : { ...pane, mode: 'normal' }
460  if (element.startsWith('menu-') && pane.overlay?.kind === 'menu') return { ...base, overlay: { kind: 'menu', cursor: element } }
461  if (element.startsWith('scope-') && pane.overlay?.kind === 'projects') return { ...base, overlay: { kind: 'projects', cursor: element } }
462  if (element.startsWith('preview-') || element.startsWith('port-')) return { ...base, portCursor: element }
463  if (element.startsWith('session-') || element === 'attach-mac') return { ...base, pylonCursor: element }
464  if (element.startsWith('dir-') || element.startsWith('file-') || element === 'changes-wake') return { ...base, changesCursor: element }
465  if (pane.view === 'list') return { ...base, listCursor: element }
466  if (pane.view === 'start' && pane.start) return { ...base, start: { ...pane.start, focused: field } }
467  return base
468}
469
hooks/strip.tsx 80 lines
1// The strip: one row above the prompt with a chip per live thread that wants a
2// look, most urgent first, so Nexus never needs opening to know what it is doing.
3// A chip opens its thread in the pane. It draws on the prompt's own background.
4import type { BoxProps, ButtonProps, ElementConstructor, TextProps } from 'claude-code'
5
6import type { NexusList } from '../types'
7import { type Chip, cells, clip, scopeLabel } from './model'
8
9type Draw = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps>; Button: ElementConstructor<ButtonProps> }
10
11const LOOK: Record<Chip['kind'], { glyph: string; color: string }> = {
12  failed: { glyph: '✕', color: 'error' },
13  decision: { glyph: '!', color: 'warning' },
14  parked: { glyph: '✕', color: 'error' },
15  reply: { glyph: '●', color: 'success' },
16  working: { glyph: '◉', color: 'planMode' },
17  waiting: { glyph: '◇', color: 'warning' },
18}
19
20const HEAD = '◆ Nexus'
21/** The most a chip's title takes, so three chips fit beside one another in a narrow column. */
22const TITLE_CELLS = 24
23
24/** The least a chip's title takes: the first chip shortens its title to this to fit beside a project's name. */
25const LEAST_TITLE_CELLS = 8
26
27const chipText = (chip: Chip, title: number) => ` ${LOOK[chip.kind].glyph} ${clip(chip.thread.title, title)}  ${chip.note} `
28
29/**
30 * The chips that fit in `width` on one row, the rest counted as `+N`, or why the list could not be read. One project
31 * shown is named after the head.
32 */
33export function StripView(draw: Draw, list: NexusList, chips: readonly Chip[], width: number, cursor: string | null, scope: string | null, open: (id: string) => void) {
34  const { Box, Text, Button } = draw
35  const head = scope === null ? HEAD : `${HEAD} · ${scopeLabel(scope)}`
36  if (list.failure && !list.threads) {
37    return (
38      <Box flexDirection="row" width={width}>
39        <Text wrap="truncate-end">
40          <Text bold color="claude">
41            {head}
42          </Text>
43          <Text color="error">{`  ${list.failure.kind}: ${list.failure.message}`}</Text>
44        </Text>
45      </Box>
46    )
47  }
48  const room = width - cells(head) - 6
49  const shown: { chip: Chip; title: number }[] = []
50  let used = 0
51  for (const chip of chips) {
52    const over = used + cells(chipText(chip, TITLE_CELLS)) + 1 - room
53    const title = cells(clip(chip.thread.title, TITLE_CELLS)) - Math.max(0, over)
54    if (over > 0 && (shown.length > 0 || title < LEAST_TITLE_CELLS)) break
55    shown.push({ chip, title })
56    used += cells(chipText(chip, title)) + 1
57  }
58  const more = chips.length - shown.length
59  return (
60    <Box flexDirection="row" columnGap={1} width={width}>
61      <Text bold color="claude">
62        {head}
63      </Text>
64      {shown.map(({ chip, title }) => {
65        const isCursor = cursor === chip.thread.id
66        return (
67          <Button key={chip.thread.id} plain onPress={() => open(chip.thread.id)}>
68            <Text backgroundColor={isCursor ? 'suggestion' : undefined} color={isCursor ? 'inverseText' : undefined}>
69              <Text color={isCursor ? 'inverseText' : LOOK[chip.kind].color}>{` ${LOOK[chip.kind].glyph} `}</Text>
70              {clip(chip.thread.title, title)}
71              <Text color={isCursor ? 'inverseText' : chip.kind === 'working' ? 'subtle' : LOOK[chip.kind].color}>{`  ${chip.note} `}</Text>
72            </Text>
73          </Button>
74        )
75      })}
76      {more > 0 && <Text color="subtle">{`+${more}`}</Text>}
77    </Box>
78  )
79}
80
hooks/tools.ts 146 lines
1// The tools the session's model calls, listed as mcp__nexus__<name>. Their
2// text rides on every request that loads them: keep it short and fixed.
3// Deleting a thread and retrying a pylon are left to the person's /nexus.
4import type { ToolSpec } from 'claude-code'
5import type { NexusPort, NexusRunState } from '../types'
6
7const thread = { type: 'string', description: 'Thread ID, or a unique prefix of one' }
8const threadPort = {
9  type: 'object',
10  additionalProperties: false,
11  properties: { thread, port: { type: 'integer', minimum: 1, maximum: 65535 } },
12  required: ['thread', 'port'],
13} as const
14
15export const TOOLS = {
16  projects: {
17    name: 'projects',
18    description: 'List Nexus projects.',
19    inputSchema: { type: 'object', additionalProperties: false, properties: {} },
20  },
21  project_add: {
22    name: 'project_add',
23    description: 'Add a Nexus project from repo_url or scratch: true. Defaults to the first online node and branch main. github grants GH_TOKEN only to api.github.com and github.com. Needs Nexus CLI 0.7.0 or later.',
24    inputSchema: {
25      type: 'object',
26      additionalProperties: false,
27      properties: {
28        id: { type: 'string' },
29        repo_url: { type: 'string' },
30        scratch: { type: 'boolean' },
31        node: { type: 'string' },
32        branch: { type: 'string' },
33        cpus: { type: 'number' },
34        memory_gib: { type: 'number' },
35        disk_gib: { type: 'number' },
36        allow_hosts: { type: 'array', items: { type: 'string' } },
37        logins: { type: 'array', items: { type: 'string', enum: ['claude', 'chatgpt'] } },
38        github: { type: 'boolean' },
39      },
40      required: ['id'],
41    },
42  },
43  threads: {
44    name: 'threads',
45    description:
46      'List Nexus threads: coding agents, each in its own VM on the home lab, grouped by project. Gives id, project, title, status and whether the agent is working.',
47    inputSchema: { type: 'object', additionalProperties: false, properties: { project: { type: 'string' } } },
48  },
49  start: {
50    name: 'start',
51    description:
52      'Start a Nexus thread on a project with a first message for its agent, or with claude: true a Claude Code worker, and return its id. The agent works on its own; use wait for its reply.',
53    inputSchema: {
54      type: 'object',
55      additionalProperties: false,
56      properties: {
57        project: { type: 'string' },
58        message: { type: 'string' },
59        title: { type: 'string', description: 'Defaults to the first line of message' },
60        claude: { type: 'boolean', description: 'Start a Claude Code worker with message as its first prompt instead of the Nexus agent' },
61        model: { type: 'string', description: "The Claude Code worker's model, with claude: true. Defaults to the CLI's" },
62        node: { type: 'string', description: "Defaults to the project's node" },
63      },
64      required: ['project', 'message'],
65    },
66  },
67  send: {
68    name: 'send',
69    description: "Send a message to a Nexus thread's agent. If it is working, the message waits for its current turn to end.",
70    inputSchema: { type: 'object', additionalProperties: false, properties: { thread, text: { type: 'string' } }, required: ['thread', 'text'] },
71  },
72  read: {
73    name: 'read',
74    description: "A Nexus thread's state (status, run, pylon, listening ports with any preview URL; ports null: unknown) and the latest part of its conversation.",
75    inputSchema: { type: 'object', additionalProperties: false, properties: { thread }, required: ['thread'] },
76  },
77  wait: {
78    name: 'wait',
79    description: "Wait until a Nexus thread's agent has answered and gone idle, or the timeout passes, then return its state and latest reply.",
80    inputSchema: {
81      type: 'object',
82      additionalProperties: false,
83      properties: { thread, timeout_seconds: { type: 'integer', minimum: 1, maximum: 600, description: 'Default 300' } },
84      required: ['thread'],
85    },
86  },
87  preview: {
88    name: 'preview',
89    description:
90      "Publish a port the thread's pylon listens on as a preview, open to the home tailnet until unpublished, and return its URL once it is served (up to 2 min).",
91    inputSchema: threadPort,
92  },
93  unpublish: {
94    name: 'unpublish',
95    description:
96      "Close the preview a port of a Nexus thread was published as. A declared service's preview cannot be closed. Publishing the port again may give it a new URL. Wakes a paused pylon first (up to 2 min).",
97    inputSchema: threadPort,
98  },
99  archive: {
100    name: 'archive',
101    description: 'Archive a Nexus thread, which pauses its VM. The person can unarchive it.',
102    inputSchema: { type: 'object', additionalProperties: false, properties: { thread }, required: ['thread'] },
103  },
104} as const satisfies Record<string, ToolSpec>
105
106/** A listening port as the model reads it: its service and its preview's URL, when it has them, and why that failed. pi-nexus gives the same keys in the same order. */
107export const portFacts = (port: NexusPort) => ({
108  port: port.port,
109  ...(port.service ? { service: port.service } : {}),
110  ...(port.preview ? { url: port.preview.url, ready: port.preview.ready } : {}),
111  ...(port.preview?.failed ? { failed: port.preview.failed } : {}),
112})
113
114/** What only a person can do for a thread, so the model says so rather than waiting again. pi-nexus words it the same. */
115export function needsHuman(state: NexusRunState | null): string | undefined {
116  if (state?.run === 'parked') return 'Tasks hit the restart limit and are parked. A human must retry them with /nexus retry.'
117  if (state?.pylon === 'failed' || state?.failed) return 'The pylon failed. A human must retry it with /nexus retry-pylon.'
118  return undefined
119}
120
121export function projectArgs(input: Record<string, unknown>): string[] {
122  const properties = TOOLS.project_add.inputSchema.properties
123  for (const [key, value] of Object.entries(input)) {
124    const spec = properties[key as keyof typeof properties]
125    if (!spec) throw new Error(`Unknown project_add parameter: ${key}`)
126    if (spec.type === 'array') {
127      if (!Array.isArray(value) || value.some(one => typeof one !== 'string' || (key === 'logins' && one !== 'claude' && one !== 'chatgpt'))) throw new Error(`Invalid ${key}`)
128    } else if (typeof value !== spec.type || (typeof value === 'number' && !Number.isFinite(value))) throw new Error(`Invalid ${key}`)
129  }
130  if (typeof input.id !== 'string' || !input.id.trim()) throw new Error('id is required')
131  if ((input.repo_url !== undefined) === (input.scratch === true)) throw new Error('provide exactly one of repo_url or scratch: true')
132  if (typeof input.repo_url === 'string' && !input.repo_url.trim()) throw new Error('repo_url is required')
133  const args = ['project', 'add', `--branch=${input.branch ?? 'main'}`]
134  if (input.node !== undefined) args.push(`--node=${input.node}`)
135  for (const [key, flag] of [['cpus', 'cpus'], ['memory_gib', 'memory'], ['disk_gib', 'disk']] as const) {
136    if (input[key] !== undefined) args.push(`--${flag}=${input[key]}`)
137  }
138  for (const host of (input.allow_hosts ?? []) as string[]) args.push(`--allow-host=${host}`)
139  for (const login of (input.logins ?? []) as string[]) args.push(`--login=${login}`)
140  if (input.github === true) args.push('--secret=GH_TOKEN:api.github.com,github.com')
141  if (input.scratch === true) args.push('--scratch')
142  args.push('--', input.id.trim())
143  if (input.repo_url !== undefined) args.push((input.repo_url as string).trim())
144  return args
145}
146
hooks/view.tsx 2278 lines
1// How the pane looks: a dock with a title bar, the thread list grouped by project,
2// one thread under four tabs (its conversation drawn as a Claude Code transcript
3// draws its own, its changes, its previews, its pylon and sessions), the Actions menu,
4// the key help and the forms. Each function takes the surface's elements, what the atoms hold and
5// the handlers the hooks module made, and holds no state of its own. Every colour
6// is a theme key, so the pane follows the person's theme.
7import type {
8  BoxProps,
9  ButtonProps,
10  CodeProps,
11  ElementConstructor,
12  InputProps,
13  LinkProps,
14  MarkdownProps,
15  RenderChildren,
16  RenderSurface,
17  SelectProps,
18  TextProps,
19} from 'claude-code'
20
21import type {
22  NexusActual,
23  NexusBase,
24  NexusCatalog,
25  NexusChangedFile,
26  NexusCheckout,
27  NexusDiffShown,
28  NexusDesired,
29  NexusDetail,
30  NexusList,
31  NexusLive,
32  NexusPane,
33  NexusPort,
34  NexusPortChange,
35  NexusPreview,
36  NexusProjectDraft,
37  NexusSessionDraft,
38  NexusShown,
39  NexusStartDraft,
40  NexusTab,
41  NexusThread,
42  NexusThreadRow,
43  NexusWatch,
44} from '../types'
45import { COMPOSE, type Command, type Effect, FILTER, HELP, KEYMAP, type MenuAction, type Nav, type NavItem, blockAt, fieldKey, keepInView, scrolledRows } from './keys'
46import {
47  ACTUAL_BADGE,
48  ATTACH_HINT,
49  BASES,
50  type Block,
51  type CheckoutFacts,
52  DESIRED_TONE,
53  STATUS_LETTER,
54  type TreeRow,
55  canReadCheckout,
56  changeTree,
57  changesSummary,
58  checkoutFacts,
59  FOLD_AT,
60  HARNESS_LABEL,
61  HARNESSES,
62  NO_SESSION_CHANGES,
63  SESSION_TONE,
64  pausedClose,
65  resumesOn,
66  sessionPlace,
67  type ShownSession,
68  attachCommand,
69  type Tone,
70  TONE_COLOR,
71  type ToolRow,
72  type ToolStatus,
73  blocks,
74  cells,
75  clip,
76  clock,
77  closedKey,
78  closedLabel,
79  diffOf,
80  exitCode,
81  foldSummary,
82  groupThreads,
83  inScope,
84  listOrder,
85  linkedPort,
86  NO_CHANGE,
87  portChange,
88  previewLook,
89  runOf,
90  scopeLabel,
91  sentence,
92  sessionName,
93  shownSessions,
94  since,
95  rowState,
96  span,
97  spinner,
98  subjectOf,
99  threadState,
100  toolName,
101  toolStatus,
102} from './model'
103
104export type Draw = {
105  Box: ElementConstructor<BoxProps>
106  Text: ElementConstructor<TextProps>
107  Button: ElementConstructor<ButtonProps>
108  Input: ElementConstructor<InputProps>
109  Select: ElementConstructor<SelectProps>
110  Link: ElementConstructor<LinkProps>
111  Code: ElementConstructor<CodeProps>
112  Markdown: ElementConstructor<MarkdownProps>
113}
114
115/** Everything a drawing reads: the atoms, the clock and the body's size. */
116export type Scene = {
117  list: NexusList
118  watch: NexusWatch | null
119  /** The watch's latest live event for the thread shown. */
120  live: NexusLive | null
121  shown: NexusShown | null
122  catalog: NexusCatalog
123  pane: NexusPane
124  /** The project whose threads the list shows, null for every project. */
125  scope: string | null
126  now: number
127  width: number
128  /** The rows the pane's body has; the views fill them, so the title bar and the composer stay put. */
129  rows: number
130  /** Docked beside the transcript, or above the prompt, where the engine paints no background of its own. */
131  placement: 'dock' | 'inline'
132  isFocused: boolean
133  surface: RenderSurface
134  /** Claude Code runs inside Herdr, where Enter's attach opens a tab rather than copying the command. */
135  inHerdr: boolean
136  /** The CLI the `cli` option names, which the attach command shown runs. */
137  cli: string
138  /** The Changes tab's reads of the thread shown, and the diff it shows. */
139  checkout: NexusCheckout | null
140  diff: NexusDiffShown | null
141  /** What Changes compares with. */
142  changesBase: NexusBase['kind']
143}
144
145export type PaneActions = {
146  /** A key pressed, with what this drawing laid out for it to move over. */
147  key: (command: Command, nav: Nav) => void
148  /** What a click or Enter on one item asks, as a key would. */
149  effect: (effect: Effect) => void
150  /** Opens or closes a fold, a tool row or a group's closed threads, as drawn now. */
151  toggle: (key: string, isOpen: boolean) => void
152  tab: (tab: NexusTab) => void
153  /** A change of the filter, typed into the field drawn as `at`. */
154  filter: (text: string, at: string) => void
155  /** Enter in the filter: back to normal mode, the cursor on the first thread it keeps, or where it was when it is empty. */
156  filterDone: (text: string) => void
157  draft: (text: string) => void
158  submit: (text: string) => void
159  back: () => void
160  /** A change of the start form; `at` is the field it was typed into, as drawn. */
161  startDraft: (change: Partial<NexusStartDraft>, at?: string) => void
162  /** Picks the form's project, which clears the node and branch chosen for the one before. */
163  startProject: (project: string) => void
164  /** Starts the thread, the field Enter was pressed in carrying its text: it may not have reported it yet. */
165  startSubmit: (change?: Partial<Pick<NexusStartDraft, 'title' | 'branch' | 'message'>>) => void
166  /** A change of the new project form; `at` is the field it was typed into, as drawn. */
167  projectDraft: (change: Partial<NexusProjectDraft>, at?: string) => void
168  /** Adds the project, the field Enter was pressed in carrying its text, as for the start form. */
169  projectSubmit: (change?: Partial<Pick<NexusProjectDraft, 'id' | 'repoUrl' | 'branch'>>) => void
170  /** A change of the open a session form; `at` is the field it was typed into, as drawn. */
171  sessionDraft: (change: Partial<NexusSessionDraft>, at?: string) => void
172  /** Opens the session, the title's Enter carrying its text, as for the start form. */
173  sessionSubmit: (change?: Partial<Pick<NexusSessionDraft, 'title'>>) => void
174}
175
176
177/**
178 * The dock paints the theme's composerSidebarBackground under everything a pane draws, and no prop clears it. A Box
179 * painted over the whole body hides it: under dark-ansi `bashMessageBackgroundColor` is ANSI black, which most dark
180 * palettes draw as (or next to) the terminal's own background. The title bar takes the user message's band.
181 */
182const BODY = 'bashMessageBackgroundColor'
183const BAR = 'userMessageBackground'
184
185/** The list's and the pills' colours: an agent at work is cyan, apart from the reds of a failure. */
186const LOOK: Record<Tone, string> = { ...TONE_COLOR, work: 'planMode' }
187const GLYPH: Record<Tone, string> = { ok: '●', work: '◉', busy: '◐', idle: '○', bad: '✕', muted: '◌' }
188
189/** Covers the gutter and the rows the body can't reach; the dock still clips its close row, bottom row and divider. */
190function Underlay({ Box }: Draw, scene: Scene) {
191  return <Box key="underlay" position="absolute" top={-2} left={0} width={scene.width + 3} height={scene.rows + 4} backgroundColor={BODY} />
192}
193
194/** The pane's frame: the body's whole size, docked on the terminal's own background, so the title bar and footer stay put. */
195function Frame(draw: Draw, scene: Scene, children: readonly RenderChildren[]) {
196  const { Box } = draw
197  const isDocked = scene.placement === 'dock'
198  return (
199    <Box flexDirection="column" width={scene.width} height={scene.rows} backgroundColor={isDocked ? BODY : undefined}>
200      {isDocked && Underlay(draw, scene)}
201      {children}
202    </Box>
203  )
204}
205
206/** Rows above or below the body that keep their height: the body alone gives way to the frame's. */
207function Fixed({ Box }: Draw, key: string, children: RenderChildren) {
208  return (
209    <Box key={key} flexDirection="column" flexShrink={0}>
210      {children}
211    </Box>
212  )
213}
214
215/** A status as a filled chip, the dashboard's badge: the tone's colour behind the label. */
216function Pill({ Text }: Draw, label: string, tone: Tone) {
217  return (
218    <Text backgroundColor={LOOK[tone]} color="inverseText" bold>
219      {` ${label} `}
220    </Text>
221  )
222}
223
224/**
225 * The pane's title bar: a band across the dock with where the person is and what is going on. In a narrow pane the
226 * side that `yields` is cut, on one row: the left's detail by default, a form's hint on the right.
227 */
228function TitleBar(draw: Draw, scene: Scene, left: RenderChildren, right: string, yields: 'left' | 'right' = 'left') {
229  const { Box, Text } = draw
230  return (
231    <Box key="title" flexDirection="row" width={scene.width} backgroundColor={BAR} paddingX={1} justifyContent="space-between">
232      <Box flexShrink={yields === 'left' ? 1 : 0}>
233        <Text wrap="truncate-end">{left}</Text>
234      </Box>
235      <Box flexShrink={yields === 'right' ? 1 : 0} paddingLeft={2}>
236        <Text color="subtle" wrap="truncate-end">
237          {right}
238        </Text>
239      </Box>
240    </Box>
241  )
242}
243
244function Rule({ Text }: Draw, scene: Scene, color = 'subtle') {
245  return <Text color={color}>{'─'.repeat(scene.width)}</Text>
246}
247
248function SectionRule({ Box, Text }: Draw, scene: Scene, title: string, count: string, key: string) {
249  return (
250    <Box key={key} marginTop={1} flexDirection="row">
251      <Text bold color="claude">
252        {title.toUpperCase()}
253      </Text>
254      <Text color="subtle">{`${count ? ` ${count}` : ''} ${'─'.repeat(Math.max(0, scene.width - title.length - (count ? count.length + 1 : 0) - 1))}`}</Text>
255    </Box>
256  )
257}
258
259/**
260 * Every letter the pane answers, as Buttons in a Box drawn as nothing: a hidden Button's hotkey still presses it, so
261 * the keys need no room, and the mode line says which matter here. A focused field takes the letters before them.
262 */
263function KeyMap({ Box, Button }: Draw, actions: PaneActions, nav: Nav) {
264  return (
265    <Box key="keymap" display="none">
266      {KEYMAP.map(key => (
267        <Button key={`key-${key.hotkey}`} plain hotkey={key.hotkey} label={key.command} onPress={() => actions.key(key.command, nav)} />
268      ))}
269    </Box>
270  )
271}
272
273const MODE_LOOK = { normal: ['NORMAL', 'idle'], insert: ['INSERT', 'ok'], filter: ['FILTER', 'busy'] } as const satisfies Record<NexusPane['mode'], [string, Tone]>
274
275/**
276 * The bottom line: the mode, so the person knows where letters go, and the keys that matter here. Without the
277 * keyboard it says how to hand the pane the keys instead.
278 */
279function ModeLine(draw: Draw, scene: Scene, hints: readonly (readonly [string, string])[], mode: NexusPane['mode'] = scene.pane.mode) {
280  const { Box, Text } = draw
281  if (!scene.isFocused) {
282    return (
283      <Box key="mode" marginTop={1}>
284        <Text wrap="truncate-end">
285          <Text backgroundColor="warning" color="inverseText" bold>
286            {' ctrl+x tab '}
287          </Text>
288          <Text color="warning">{' the keys are at the prompt: ctrl+x then tab, or a click, hands them to the pane'}</Text>
289        </Text>
290      </Box>
291    )
292  }
293  const [label, tone] = MODE_LOOK[mode]
294  return (
295    <Box key="mode" marginTop={1}>
296      <Text wrap="truncate-end">
297        {Pill(draw, label, tone)}
298        {scene.pane.pending ? <Text color="suggestion">{` ${scene.pane.pending}…`}</Text> : ''}
299        {hints.map(([key, what]) => (
300          <Text key={`hint-${key}`}>
301            <Text color="suggestion">{`  ${key}`}</Text>
302            <Text color="subtle">{` ${what}`}</Text>
303          </Text>
304        ))}
305      </Text>
306    </Box>
307  )
308}
309
310function Notice({ Text }: Draw, pane: NexusPane) {
311  if (!pane.notice) return null
312  return (
313    <Text key="notice" color={pane.notice.tone === 'error' ? 'error' : 'subtle'}>
314      {pane.notice.text}
315    </Text>
316  )
317}
318
319const Dot = ({ Text }: Draw, tone: Tone) => <Text color={LOOK[tone]}>{GLYPH[tone]}</Text>
320
321/** The cursor's mark at the head of a row the keys move over. */
322const Pointer = ({ Text }: Draw, isCursor: boolean) => <Text color="suggestion">{isCursor ? '❯ ' : '  '}</Text>
323
324/** Where a pylon's phase stands against what is wanted of it, for the list's "wants paused". */
325const PHASE: Partial<Record<NexusActual, NexusDesired>> = { running: 'running', spawning: 'running', paused: 'paused', pausing: 'paused', deleting: 'deleted', absent: 'deleted' }
326
327// The layout's rows, estimated from the built tree: the views fill a fixed height, so they say where things land.
328
329type Element = { type: string; props?: Readonly<Record<string, unknown>>; children?: unknown }
330const isElement = (node: unknown): node is Element => typeof node === 'object' && node !== null && !Array.isArray(node) && typeof (node as Element).type === 'string'
331const num = (value: unknown) => (typeof value === 'number' ? value : 0)
332
333/** The text an inline element draws: its strings, its nested Texts and its label. */
334function inlineText(node: unknown): string {
335  if (typeof node === 'string' || typeof node === 'number') return String(node)
336  if (Array.isArray(node)) return node.map(inlineText).join('')
337  if (!isElement(node)) return ''
338  // The engine gives a Button with children a `label` of their text too, so the label counts only without them.
339  const own = inlineText(node.children)
340  const label = typeof node.props?.label === 'string' ? node.props.label : ''
341  const hotkey = typeof node.props?.hotkey === 'string' && node.props.plain ? `${node.props.hotkey}: ` : ''
342  return `${hotkey}${own || label}`
343}
344
345/** Rows `text` takes at `width` cells, wrapped, each line at least one. */
346function wrapped(text: string, width: number): number {
347  const room = Math.max(1, width)
348  return text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(cells(line) / room)), 0)
349}
350
351/** The most rows a field grows to; a short pane gives it a quarter of its rows, which the body above needs more. */
352const FIELD_ROWS = 6
353
354/**
355 * The rows a focused field's editor wraps `text` to in `columns`: as `Bun.wrapAnsi(text, columns - 1, { hard: true,
356 * trim: false })` does in 2.1.310, the last cell kept for the cursor. A word moves whole to the next row, keeping the
357 * space before it on the row it left, and one wider than a row breaks across rows.
358 */
359export function editorRows(text: string, columns: number): number {
360  const width = columns - 1
361  // The wrap reads a pasted `\r\n` as one line break.
362  return text.replace(/\r\n/g, '\n').split('\n').reduce((sum, line) => {
363    let rows = 1
364    let used = 0
365    for (const [index, word] of line.split(' ').entries()) {
366      if (index > 0) {
367        if (used >= width) {
368          rows += 1
369          used = 0
370        }
371        used += 1
372      }
373      const size = cells(word)
374      if (size > width) {
375        if (Math.floor((size - 1) / width) < 1 + Math.floor((size - (width - used) - 1) / width)) {
376          rows += 1
377          used = 0
378        }
379        const chars = [...word]
380        for (const [at, char] of chars.entries()) {
381          const taken = cells(char)
382          if (used + taken > width) {
383            rows += 1
384            used = 0
385          }
386          used += taken
387          if (used === width && at < chars.length - 1) {
388            rows += 1
389            used = 0
390          }
391        }
392        continue
393      }
394      if (used > 0 && size > 0 && used + size > width) {
395        rows += 1
396        used = 0
397      }
398      used += size
399    }
400    return sum + rows
401  }, 0)
402}
403
404/**
405 * The rows an Input needs to show all of `text` in a pane `width` by `rows`, two at least while it has the keys: a
406 * field of one row submits on `\⏎` instead of typing a newline. The engine sizes a focused field's editor from the
407 * site's width less `label: ` and the Enter hint, never from the Box it sits in, so a field must span the site to wrap
408 * where it is drawn. From two rows the hint also names the newline key, when that leaves the text half the width. The
409 * key is `\⏎` or `shift+⏎` as the terminal allows, so the rows are the more of the two.
410 */
411export function fieldRows(text: string, label: string, submit: string, width: number, rows: number, hasKeys: boolean): number {
412  const room = width - (label ? cells(label) + 2 : 0)
413  const enter = ` ⏎ ${submit}`
414  const rowsAt = (columns: number) => editorRows(text, Math.max(12, columns))
415  if (!hasKeys && rowsAt(room - cells(enter)) <= 1) return 1
416  const needed = Math.max(
417    ...['\\⏎', 'shift+⏎'].map(key => {
418      const newline = `${enter} · ${key} new line`
419      const hint = room - cells(newline) >= width / 2 ? newline : enter
420      return rowsAt(room - cells(hint))
421    }),
422  )
423  return Math.max(2, Math.min(FIELD_ROWS, Math.floor(rows / 4), needed))
424}
425
426/** The rows a built tree takes at `width`, as the column it sits in stacks it. An estimate of the engine's layout. */
427export function rowsOf(node: unknown, width: number): number {
428  if (node === null || node === undefined || typeof node === 'boolean') return 0
429  if (typeof node === 'string' || typeof node === 'number') return wrapped(String(node), width)
430  if (Array.isArray(node)) return node.reduce((sum: number, child) => sum + rowsOf(child, width), 0)
431  if (!isElement(node)) return 0
432  const props = node.props ?? {}
433  if (props.display === 'none' || props.position === 'absolute') return 0
434  switch (node.type) {
435    case 'Text':
436    case 'Button':
437    case 'Link': {
438      const text = node.type === 'Link' && !inlineText(node) ? String(props.href ?? '') : inlineText(node)
439      return props.wrap === 'truncate-end' ? text.split('\n').length : wrapped(text, width)
440    }
441    case 'Markdown':
442      return wrapped(String(props.text ?? ''), width)
443    case 'Code': {
444      const source = String(props.source ?? '')
445      return props.wrap === 'truncate-end' ? source.split('\n').length : wrapped(source, width - (props.format === 'diff' ? 7 : 0))
446    }
447    case 'Input':
448      return Math.max(1, num(props.rows))
449    case 'Select':
450      return 1
451    case 'Box': {
452      const border = props.borderStyle ? 1 : 0
453      const padX = num(props.paddingLeft ?? props.paddingX ?? props.padding) + num(props.paddingRight ?? props.paddingX ?? props.padding)
454      const padY = num(props.paddingTop ?? props.paddingY ?? props.padding) + num(props.paddingBottom ?? props.paddingY ?? props.padding)
455      const margin = num(props.marginTop ?? props.marginY ?? props.margin) + num(props.marginBottom ?? props.marginY ?? props.margin)
456      if (typeof props.height === 'number') return props.height + margin
457      const inner = Math.max(1, width - padX - 2 * border)
458      const children = (Array.isArray(node.children) ? node.children.flat(Infinity) : [node.children]).filter(child => child !== null && child !== undefined && child !== false)
459      let body: number
460      if (props.flexDirection === 'row') {
461        // Inline children take their text's width; the Boxes share what is left.
462        const fixed = children.filter(child => !isElement(child) || child.type !== 'Box')
463        const boxes = children.length - fixed.length
464        const used = fixed.reduce((sum: number, child) => sum + Math.min(inner, cells(inlineText(child))), 0) + num(props.columnGap) * Math.max(0, children.length - 1)
465        const share = boxes > 0 ? Math.max(1, Math.floor((inner - used) / boxes)) : inner
466        body = children.reduce((most: number, child) => Math.max(most, isElement(child) && child.type === 'Box' ? rowsOf(child, share) : rowsOf(child, inner)), 0)
467      } else {
468        body = children.reduce((sum: number, child) => sum + rowsOf(child, inner), 0) + num(props.rowGap) * Math.max(0, children.length - 1)
469      }
470      return Math.max(body + padY + 2 * border, num(props.minHeight)) + margin
471    }
472    default:
473      return 0
474  }
475}
476
477// The thread list.
478
479function TitleOfList(draw: Draw, scene: Scene) {
480  const { Text } = draw
481  const { list, catalog } = scene
482  const threads = inScope(list.threads ?? [], scene.scope)
483  const open = threads.filter(thread => thread.status === 'active' || thread.status === 'idle').length
484  const working = threads.filter(thread => thread.agentTurnRunning).length
485  const unread = threads.filter(thread => thread.attention === 'reply' && (thread.status === 'active' || thread.status === 'idle')).length
486  const online = catalog.nodes?.filter(node => node.state === 'online').length
487  return TitleBar(
488    draw,
489    scene,
490    <Text>
491      <Text bold color="claude">
492        ◆ Nexus threads
493      </Text>
494      <Text color="subtle">{` · ${scopeLabel(scene.scope)}`}</Text>
495      {list.threads ? (
496        <Text color="subtle">{`   ${unread > 0 ? `${unread} new · ` : ''}${working} working · ${open} open`}</Text>
497      ) : list.failure ? (
498        <Text color="error">{list.failure.kind === 'unreachable' ? '   unreachable' : '   not loaded'}</Text>
499      ) : (
500        <Text color="subtle">{'   connecting…'}</Text>
501      )}
502    </Text>,
503    online === undefined ? '' : `${online}/${catalog.nodes!.length} nodes online`,
504  )
505}
506
507/** A project's row in the list, as the dashboard's sidebar heads it: `▾ web  main`, the default branch dim. */
508function ProjectHeader({ Box, Text }: Draw, project: string, branch: string | undefined) {
509  return (
510    <Box key={`project-${project}`} marginTop={1}>
511      <Text wrap="truncate-end">
512        <Text color="subtle">{'▾ '}</Text>
513        <Text bold>{project}</Text>
514        {branch ? <Text color="subtle">{`  ${branch}`}</Text> : ''}
515      </Text>
516    </Box>
517  )
518}
519
520export function ListView(draw: Draw, scene: Scene, actions: PaneActions) {
521  const { Box, Text, Button, Input } = draw
522  const { list, pane } = scene
523  const threads = inScope(list.threads ?? [], scene.scope)
524  const groups = groupThreads(threads, pane.filter, pane.toggled)
525  const now = list.polledAt ?? scene.now
526  const isFiltering = pane.mode === 'filter'
527  const order = listOrder(groups)
528  const cursor = order.some(one => one.id === pane.listCursor) ? pane.listCursor : (order[0]?.id ?? null)
529  const items: NavItem[] = []
530  const drawn: RenderChildren[] = []
531  let y = 0
532  const place = (node: RenderChildren, item?: Omit<NavItem, 'top' | 'height'>) => {
533    const height = rowsOf(node, scene.width)
534    if (item) items.push({ ...item, top: y, height })
535    drawn.push(node)
536    y += height
537  }
538  // The dashboard's sidebar: a dot by state, the title, the state's word while there is room, how long ago, dim.
539  const row = (thread: NexusThreadRow) => {
540    const [label, tone] = rowState(thread)
541    const age = thread.agentTurnRunning ? 'now' : (span(now - thread.lastActivityAt) ?? 'now')
542    const word = label === 'agent working' ? 'working' : label
543    const hasWord = scene.width >= 44
544    // The pointer, the dot and a space, then the title, the word and the age, padded to "22 min", two cells apart.
545    const room = Math.max(8, scene.width - 12 - (hasWord ? cells(word) + 2 : 0))
546    const title = clip(thread.title, room)
547    const isCursor = cursor === thread.id
548    const wants = PHASE[thread.pylon.actualState] !== thread.desired ? `wants ${thread.desired} · ` : ''
549    place(
550      <Box key={`row-${thread.id}`} flexDirection="column">
551        <Button key={thread.id} plain autoFocus={isCursor && !isFiltering ? true : undefined} onPress={() => actions.effect({ kind: 'open', thread: thread.id })}>
552          {Pointer(draw, isCursor)}
553          {Dot(draw, tone)}
554          <Text bold={isCursor || label === 'new reply' || label === 'needs you'} color={isCursor ? 'suggestion' : 'text'}>{` ${title}`}</Text>
555          {' '.repeat(Math.max(1, room - cells(title)))}
556          {hasWord ? <Text color={LOOK[tone]}>{`  ${word}`}</Text> : ''}
557          <Text color="subtle">{`  ${age.padStart(6)}`}</Text>
558        </Button>
559        {isCursor && (
560          <Text color="subtle" wrap="truncate-end">{`    ${wants}${thread.pylon.node} · you ${span(now - thread.lastHumanActivityAt) ?? 'now'} · agent ${thread.agentTurnRunning ? 'working' : thread.lastAgentActivityAt === 0 ? 'never' : (span(now - thread.lastAgentActivityAt) ?? 'now')} · ${thread.id}`}</Text>
561        )}
562      </Box>,
563      { key: thread.id, enter: { kind: 'open', thread: thread.id }, y: { kind: 'copy', text: thread.id } },
564    )
565  }
566  for (const group of groups) {
567    place(ProjectHeader(draw, group.project, scene.catalog.projects?.find(project => project.id === group.project)?.defaultBranch))
568    group.open.forEach(row)
569    if (group.closed.length > 0) {
570      const key = closedKey(group.project)
571      place(
572        <Button key={key} plain dimColor autoFocus={cursor === key && !isFiltering ? true : undefined} onPress={() => actions.toggle(key, group.isUnfolded)}>
573          {Pointer(draw, cursor === key)}
574          {`  ${group.isUnfolded ? '▾' : '▸'} ${closedLabel(group.closed)}`}
575        </Button>,
576        { key, enter: { kind: 'toggle', key, isOpen: group.isUnfolded } },
577      )
578    }
579    if (group.isUnfolded) group.closed.forEach(row)
580  }
581  const header = [
582    TitleOfList(draw, scene),
583    list.failure && (
584      <Text key="failure" color="error">
585        {list.failure.kind === 'unreachable' ? 'Nexus is unreachable' : `nexus failed (${list.failure.kind})`}: {sentence(list.failure.message)}
586        {list.threads ? ' The list is from the last poll that worked.' : ''}
587      </Text>
588    ),
589    isFiltering ? (
590      <Box key="filter-box" marginTop={1} borderStyle="round" borderColor="promptBorder" paddingX={1}>
591        <Input key={fieldKey(pane.fields, FILTER)} autoFocus label="⌕" value={pane.fields[FILTER]?.text} placeholder="filter threads by title" submitLabel="done" onInput={text => actions.filter(text, fieldKey(pane.fields, FILTER))} onSubmit={actions.filterDone} />
592      </Box>
593    ) : (
594      pane.filter && <Text key="filtered" color="subtle">{`⌕ ${pane.filter}   f edits it, an empty filter shows all`}</Text>
595    ),
596    list.threads && groups.length === 0 && (
597      <Text key="empty" color="subtle">
598        {pane.filter ? 'No thread title holds that.' : scene.scope ? `No threads in ${scene.scope} yet. n starts one, p shows another project.` : 'No threads yet. n starts one.'}
599      </Text>
600    ),
601  ]
602  const isHelp = pane.overlay?.kind === 'help'
603  const isPicking = pane.overlay?.kind === 'projects'
604  const hints: [string, string][] = isFiltering
605    ? [['⏎', 'done'], ['esc', 'clear']]
606    : isHelp
607      ? [['j/k', 'scroll'], ['q', 'close the help']]
608      : isPicking
609        ? PICKER_HINTS
610        : [['j/k', 'move'], ['l', 'open'], ['f', 'filter'], ['n', 'new'], ['p', 'projects'], ['s', 'keys'], ['q', 'close']]
611  const footer = [Notice(draw, pane), ModeLine(draw, scene, hints)]
612  const area = Math.max(3, scene.rows - rowsOf(header, scene.width) - rowsOf(footer, scene.width))
613  const top = keepInView(Math.min(pane.listTop, Math.max(0, y - area)), items.find(item => item.key === cursor), area)
614  const body: Body = isHelp
615    ? HelpBody(draw, scene, actions, area)
616    : isPicking
617      ? ProjectsBody(draw, scene, actions, area)
618      : {
619        nav: { context: 'list', items, area },
620        node: (
621          <Box flexDirection="column" flexShrink={0} marginTop={-top}>
622            {drawn}
623          </Box>
624        ),
625      }
626  const nav = body.nav
627  return Frame(
628    draw,
629    scene,
630    [
631      Fixed(draw, 'head', header),
632      <Box key="list" flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
633        {body.node}
634      </Box>,
635      Fixed(draw, 'foot', footer),
636      KeyMap(draw, actions, nav),
637    ],
638  )
639}
640
641// A thread: its header and tabs, then the tab's body, the run line, the composer and the mode line.
642
643/** What the thread view knows of the thread: the `show` poll, else the list's row, else the watch. */
644function threadOf(scene: Scene): { id: string; thread: NexusThread | undefined; detail: NexusDetail | null } {
645  const id = scene.pane.selected ?? ''
646  const detail = scene.shown?.thread === id ? scene.shown.detail : null
647  return { id, thread: detail?.thread ?? scene.list.threads?.find(one => one.id === id), detail }
648}
649
650const TAB_LABEL: Record<NexusTab, string> = { conversation: 'Conversation', changes: 'Changes', previews: 'Previews', pylon: 'Pylon' }
651
652/** What the body laid out for the keys, filled in once it is drawn: Enter on the active tab acts on it as o does. */
653type Holder = { nav: Nav }
654
655/** The newest lines of a Claude worker's launch diagnostic the Pylon tab shows: a stalled launch carries the pane's last 20. */
656const WORKER_LINES = 8
657
658/** What the Changes and Previews labels count: the files of the last read, and the previews. */
659type Counts = Partial<Record<NexusTab, number>>
660
661function Header(draw: Draw, scene: Scene, actions: PaneActions, counts: Counts, holder: Holder) {
662  const { Box, Text, Button } = draw
663  const { id, thread, detail } = threadOf(scene)
664  const watch = scene.watch?.thread === id ? scene.watch : null
665  const state = watch?.state
666  const row = scene.list.threads?.find(one => one.id === id)
667  // The details, the watch's state or the list's row, whichever has come: none of them before the first poll.
668  const pylon = detail?.pylon.actualState ?? (state?.pylon as NexusActual | undefined) ?? row?.pylon.actualState
669  const facts = { status: state?.status ?? thread?.status ?? 'active', agentTurnRunning: state ? state.run === 'working' : (thread?.agentTurnRunning ?? false) }
670  const known = pylon ? threadState(facts, pylon) : null
671  const node = detail?.pylon.node ?? row?.pylon.node
672  const run = watch && state?.run === 'working' ? runOf(watch.rows) : null
673  // The dashboard's header: `shop / Validate postcodes ● running`, then the facts the pane had, the run clock first.
674  const word = known ? (known[0] === 'agent working' ? 'working' : known[0]) : watch?.ended ? 'not watching' : watch?.outage ? 'not connected' : 'connecting'
675  const tone = known?.[1] ?? 'muted'
676  const clockText = known?.[0] === 'agent working' && run?.since ? clock(scene.now - run.since) : null
677  const tab = (key: NexusTab) => {
678    const count = counts[key]
679    const label = `${TAB_LABEL[key]}${count === undefined ? '' : ` ${count}`}`
680    const isOn = scene.pane.tab === key && !scene.pane.overlay
681    return (
682      <Button
683        key={`tab-${key}`}
684        plain
685        autoFocus={isOn && scene.pane.mode === 'normal' ? true : undefined}
686        onPress={() => (scene.pane.tab === key ? actions.key('open', holder.nav) : actions.tab(key))}
687      >
688        {isOn ? (
689          <Text bold underline color="claude">
690            {label}
691          </Text>
692        ) : (
693          <Text color="subtle">{label}</Text>
694        )}
695      </Button>
696    )
697  }
698  return (
699    <Box key="header" flexDirection="column">
700      {TitleBar(
701        draw,
702        scene,
703        <Text>
704          <Text color="subtle">{'‹ '}</Text>
705          {thread && <Text color="subtle">{`${thread.project} / `}</Text>}
706          <Text bold>{thread?.title ?? id}</Text>
707          {'  '}
708          {Dot(draw, tone)}
709          <Text color={LOOK[tone]}>{` ${word}`}</Text>
710        </Text>,
711        id,
712      )}
713      <Text color="subtle" wrap="truncate-end">
714        {[clockText, node, watch?.model, thread ? `started ${since(thread.createdAt, scene.now)}` : null].filter(Boolean).join('  ·  ')}
715      </Text>
716      <Box flexDirection="row" columnGap={3}>
717        {tab('conversation')}
718        {tab('changes')}
719        {tab('previews')}
720        {tab('pylon')}
721        <Box flexGrow={1} />
722        <Text color={scene.pane.overlay?.kind === 'menu' ? 'claude' : 'subtle'}>m actions</Text>
723      </Box>
724      {Rule(draw, scene)}
725      {watch?.outage && <Text color="error">{`${watch.outage} The watch keeps trying.`}</Text>}
726      {watch?.ended && <Text color="error">{watch.ended}</Text>}
727      {scene.shown?.thread === id && scene.shown.failure && <Text color="error">{`The thread's details did not load: ${sentence(scene.shown.failure.message)}`}</Text>}
728    </Box>
729  )
730}
731
732/**
733 * The most text the thread view draws, a margin under the engine's 100000: it draws a tree's first 100000 characters
734 * in the order written and cuts the rest, which would take the run line, the composer and the keys below the
735 * conversation.
736 */
737const DRAWN_LIMIT = 90_000
738/** Diff lines an edit shows before the person opens it. */
739const DIFF_LINES = 40
740/** Output lines a settled call shows before the person opens it, and a running call's live tail. */
741const OUTPUT_LINES = 3
742
743/** The Actions menu's entries for the thread as it stands: the rare and the destructive actions, delete last. */
744function menuOf(scene: Scene): { action: MenuAction; label: string }[] {
745  const { id, thread, detail } = threadOf(scene)
746  const watch = scene.watch?.thread === id ? scene.watch : null
747  const state = watch?.state ?? null
748  const status = state?.status ?? thread?.status
749  const failed = detail?.pylon.failed ?? state?.failed ?? null
750  const refused = detail?.pylon.retryRefused ?? null
751  // The harness's abort and retry do not reach a Claude Code worker: its run is the session's.
752  const isHarness = detail?.conversation !== 'claude'
753  return [
754    ...(isHarness && state?.run === 'working' ? [{ action: 'abort' as const, label: 'Abort the run' }] : []),
755    ...(isHarness && state?.run === 'parked' && status !== 'archived' ? [{ action: 'retry' as const, label: 'Retry the parked tasks' }] : []),
756    ...(failed && !refused ? [{ action: 'retry-pylon' as const, label: 'Retry the pylon' }] : []),
757    ...(failed ? [{ action: 'start-again' as const, label: 'Start again from its project and title' }] : []),
758    ...(status === 'active' || status === 'idle' ? [{ action: 'archive' as const, label: 'Archive' }] : []),
759    ...(status === 'archived' ? [{ action: 'unarchive' as const, label: 'Unarchive' }] : []),
760    ...(detail ? [{ action: 'copy-attach' as const, label: 'Copy the attach command' }] : []),
761    ...(status === 'active' || status === 'idle' ? [{ action: 'open-session' as const, label: 'Open a session: Claude Code, pi or a shell' }, { action: 'attach' as const, label: scene.inHerdr ? 'Attach in a new Herdr tab' : 'Attach: copy nexus attach, to run in a terminal' }] : []),
762    { action: 'rewatch' as const, label: 'Watch again' },
763    ...(status !== 'deleted' ? [{ action: 'delete' as const, label: 'Delete…  (asks first, Keep is the default)' }] : []),
764  ]
765}
766
767export function ThreadView(draw: Draw, scene: Scene, actions: PaneActions) {
768  const { Box } = draw
769  const { id, thread, detail } = threadOf(scene)
770  const watch = scene.watch?.thread === id ? scene.watch : null
771  const state = watch?.state ?? null
772  const status = state?.status ?? thread?.status
773  const failed = detail?.pylon.failed ?? state?.failed ?? null
774  const refused = detail?.pylon.retryRefused ?? null
775  // Only a live thread with a conversation and a pylon not given up on takes messages, so only it draws the composer.
776  const canInsert = status !== 'deleted' && status !== 'archived' && !failed && (!detail || detail.conversation !== null)
777  const { pane } = scene
778  const ports = detail ? portsOf(scene, detail) : null
779  const holder: Holder = { nav: { context: 'none' } }
780  const files = scene.checkout?.thread === id ? scene.checkout.changes?.files.length : undefined
781  const counts: Counts = { changes: files, previews: ports?.previews.length }
782  const header = Header(draw, scene, actions, counts, holder)
783  const runLine = RunLine(draw, scene)
784  const composer = Composer(draw, scene, actions, { failed, refused }, canInsert)
785  const isInserting = pane.mode === 'insert' && canInsert
786  const scrolled = pane.tab === 'conversation' && pane.scroll !== null
787  const hints: [string, string][] = pane.overlay?.kind === 'help'
788    ? [['j/k', 'scroll'], ['q', 'close the help']]
789    : pane.overlay?.kind === 'menu'
790      ? [['j/k', 'move'], ['⏎', 'run'], ['q', 'close']]
791      : pane.overlay?.kind === 'projects'
792        ? PICKER_HINTS
793        : isInserting
794        ? [['⏎', state && state.run !== 'idle' ? 'queue' : 'send'], ['esc', 'normal mode'], ['empty ⏎', 'normal mode']]
795        : pane.tab === 'conversation'
796          ? [['j/k', 'block'], ['d/u', 'page'], ...(scrolled ? [['ge', 'follow'] as [string, string]] : []), ['o', 'open'], ['h/l', 'tabs'], ...(canInsert ? [['i', 'write'] as [string, string]] : []), ['m', 'actions'], ['s', 'keys']]
797          : pane.tab === 'changes'
798            ? pane.diff
799              ? [['j/k', 'scroll'], ['d/u', 'page'], ['h', 'files'], ['c', BASES[BASES[scene.changesBase].next].name], ['r', 'read again'], ['y', 'copy path'], ['s', 'keys']]
800              : [['j/k', 'move'], ['⏎', 'open · fold'], ['c', BASES[BASES[scene.changesBase].next].name], ['r', 'read again'], ['y', 'copy path'], ['h/l', 'tabs'], ['s', 'keys']]
801            : pane.tab === 'previews'
802              ? [['j/k', 'move'], ['⏎', 'publish · copy'], ['x', 'unpublish'], ['y', 'copy URL'], ['h/l', 'tabs'], ['s', 'keys']]
803              : [['j/k', 'move'], ...(scene.inHerdr ? ([['⏎', 'attach'], ['y', 'copy attach']] as [string, string][]) : [['⏎/y', 'copy attach'] as [string, string]]), ['x', 'close'], ['n', 'open'], ['h/l', 'tabs'], ['s', 'keys']]
804  // A composer the thread's change took away leaves the letters to the keys.
805  const footer = [runLine, composer, Notice(draw, pane), ModeLine(draw, scene, hints, isInserting ? 'insert' : 'normal')]
806  const area = Math.max(3, scene.rows - rowsOf(header, scene.width) - rowsOf(footer, scene.width))
807  const body =
808    pane.overlay?.kind === 'help'
809      ? HelpBody(draw, scene, actions, area)
810      : pane.overlay?.kind === 'menu'
811        ? MenuBody(draw, scene, actions, area)
812        : pane.overlay?.kind === 'projects'
813          ? ProjectsBody(draw, scene, actions, area)
814          : pane.tab === 'conversation'
815          ? Conversation(draw, scene, actions, area, DRAWN_LIMIT - drawnChars([header, footer]), canInsert)
816          : pane.tab === 'changes'
817            ? pane.diff
818              ? DiffBody(draw, scene, area, canInsert, pane.diff)
819              : ChangesBody(draw, scene, actions, area, canInsert)
820            : pane.tab === 'previews'
821              ? PortsBody(draw, scene, actions, area, canInsert)
822              : PylonBody(draw, scene, actions, area, canInsert)
823  holder.nav = body.nav
824  return Frame(
825    draw,
826    scene,
827    [
828      Fixed(draw, 'head', header),
829      <Box
830        key="body"
831        flexDirection="column"
832        flexGrow={1}
833        flexShrink={1}
834        overflow="hidden"
835        justifyContent={pane.tab === 'conversation' && !pane.overlay ? 'flex-end' : 'flex-start'}
836      >
837        {body.node}
838      </Box>,
839      Fixed(draw, 'foot', footer),
840      KeyMap(draw, actions, body.nav),
841    ],
842  )
843}
844
845type Body = { node: RenderChildren; nav: Nav }
846
847function HelpBody(draw: Draw, scene: Scene, actions: PaneActions, area: number): Body {
848  const { Box, Text, Button } = draw
849  const top = scene.pane.overlay?.kind === 'help' ? scene.pane.overlay.top : 0
850  const node = (
851    <Box key="help" flexDirection="column" flexShrink={0} marginTop={-top}>
852      {HELP.map(section => (
853        <Box key={`help-${section.title}`} flexDirection="column">
854          <Text bold color="claude">
855            {section.title}
856          </Text>
857          {section.keys.map(([keys, what]) => (
858            <Text key={`help-${section.title}-${keys}`} wrap="truncate-end">
859              <Text color="suggestion">{`  ${keys.padEnd(10)}`}</Text>
860              <Text color="subtle">{what}</Text>
861            </Text>
862          ))}
863        </Box>
864      ))}
865      <Box marginTop={1}>
866        <Text color="subtle">Claude Code gives a pane only plain letters, so vim's G, ctrl+d, ctrl+u, / and ? are g e, d, u, f and s here.</Text>
867      </Box>
868      {/* The ring's home while the help covers the view, whose rows it hid: without one the keys would have nowhere to go. */}
869      <Button key="help-close" plain autoFocus onPress={() => actions.key('close', nav)}>
870        <Text color="suggestion">q</Text>
871        <Text color="subtle"> or ⏎ closes the help</Text>
872      </Button>
873    </Box>
874  )
875  const nav: Nav = { context: 'help', total: rowsOf(node, scene.width) + top, area }
876  return { nav, node }
877}
878
879function MenuBody(draw: Draw, scene: Scene, actions: PaneActions, area: number): Body {
880  const { Box, Text, Button } = draw
881  const entries = menuOf(scene)
882  const items: NavItem[] = entries.map((entry, index) => ({ key: `menu-${entry.action}`, top: index + 1, height: 1, enter: { kind: 'run', action: entry.action } }))
883  const chosen = scene.pane.overlay?.kind === 'menu' ? scene.pane.overlay.cursor : null
884  const cursor = Math.max(0, items.findIndex(item => item.key === chosen))
885  return {
886    nav: { context: 'menu', items, area },
887    node: (
888      <Box key="menu" flexDirection="column" flexShrink={0}>
889        <Text bold color="claude">
890          Actions
891        </Text>
892        {entries.map((entry, index) => (
893          <Button key={`menu-${entry.action}`} plain autoFocus={index === cursor ? true : undefined} onPress={() => actions.effect({ kind: 'run', action: entry.action })}>
894            {Pointer(draw, index === cursor)}
895            <Text color={entry.action === 'delete' || entry.action === 'abort' ? 'error' : index === cursor ? 'suggestion' : 'text'}>{entry.label}</Text>
896          </Button>
897        ))}
898      </Box>
899    ),
900  }
901}
902
903const PICKER_HINTS: [string, string][] = [['j/k', 'move'], ['⏎', 'choose'], ['q', 'cancel']]
904
905/**
906 * The project picker: every project, each project with its repo, and a new one. The project shown is marked current,
907 * and the cursor starts on it.
908 */
909function ProjectsBody(draw: Draw, scene: Scene, actions: PaneActions, area: number): Body {
910  const { Box, Text, Button } = draw
911  const { projects, projectsFailure } = scene.catalog
912  const rows: { key: string; name: string; repo: string; isCurrent: boolean; enter: Effect }[] = [
913    { key: 'scope-all', name: 'All projects', repo: '', isCurrent: scene.scope === null, enter: { kind: 'scope', project: null } },
914    ...(projects ?? []).map(project => ({ key: `scope-project-${project.id}`, name: project.id, repo: project.repoUrl, isCurrent: scene.scope === project.id, enter: { kind: 'scope', project: project.id } as Effect })),
915    { key: 'scope-new', name: '+ New project…', repo: '', isCurrent: false, enter: { kind: 'new-project' } },
916  ]
917  const note = projectsFailure ? `The projects did not load: ${sentence(projectsFailure.message)}` : projects ? null : 'Reading the projects…'
918  const noteNode = note && (
919    <Box key="projects-note" paddingLeft={2}>
920      <Text color={projectsFailure ? 'error' : 'subtle'}>{note}</Text>
921    </Box>
922  )
923  // The title, and the note under All projects while there is one, as many rows as it wraps to.
924  const noteRows = noteNode ? rowsOf(noteNode, scene.width) : 0
925  const above = (at: number) => 1 + (at > 0 ? noteRows : 0)
926  const items: NavItem[] = rows.map((row, at) => ({ key: row.key, top: at + above(at), height: 1, enter: row.enter }))
927  const chosen = scene.pane.overlay?.kind === 'projects' ? scene.pane.overlay.cursor : null
928  const cursor = Math.max(0, rows.findIndex(row => row.key === chosen), chosen ? -1 : rows.findIndex(row => row.isCurrent))
929  const top = Math.max(0, items[cursor]!.top + 1 - area)
930  // Past the pointer, the IDs take at most half the row; the rest is the repo, which gives way to the current mark.
931  const line = scene.width - 2
932  const column = Math.min(Math.max(...rows.map(row => cells(row.name))), Math.floor(line / 2)) + 2
933  const total = rows.length
934  return {
935    nav: { context: 'projects', items, area, drawn: rows[cursor]!.key },
936    node: (
937      <Box key="projects" flexDirection="column" flexShrink={0} marginTop={-top}>
938        <Text bold color="claude">
939          Projects{top > 0 ? <Text color="subtle">{`  (${cursor + 1}/${total})`}</Text> : ''}
940        </Text>
941        {rows.map((row, at) => {
942          const isCursor = at === cursor
943          const mark = row.isCurrent ? 'current' : ''
944          const room = line - column
945          const repoRoom = room - (mark ? cells(mark) + 2 : 0)
946          // A repo cut shorter than a host's name says nothing, so it goes.
947          const detail = clip([repoRoom >= 8 ? clip(row.repo, repoRoom) : '', mark].filter(Boolean).join('  '), Math.max(0, room))
948          const name = clip(row.name, detail ? column - 2 : line)
949          return [
950            <Button key={row.key} plain autoFocus={isCursor ? true : undefined} onPress={() => actions.effect(row.enter)}>
951              {Pointer(draw, isCursor)}
952              <Text color={isCursor ? 'suggestion' : row.key === 'scope-new' ? 'subtle' : 'text'} bold={isCursor}>
953                {name}
954              </Text>
955              {detail ? <Text color="subtle">{`${' '.repeat(column - cells(name))}${detail}`}</Text> : ''}
956            </Button>,
957            at === 0 ? noteNode : null,
958          ]
959        })}
960      </Box>
961    ),
962  }
963}
964
965/** One stop of the conversation the keys move over: a message, an answer, a notice, a call or a fold's line. */
966type Stop = { key: string; node: RenderChildren; gap: 0 | 1; indent: number; enter?: NavItem['enter']; y?: Effect }
967
968/** The conversation's stops, oldest first: each block, a fold's line and, while it is open, each of its steps. */
969function stopsOf(draw: Draw, scene: Scene, actions: PaneActions, all: readonly Block[], isWorking: boolean, running: Map<string, string>): Stop[] {
970  return all.flatMap((block): Stop[] => {
971    if (block.kind !== 'tools') {
972      return [{ key: block.key, node: BlockView(draw, block, threadOf(scene).detail?.conversation === 'claude'), gap: block.kind === 'notice' && block.tone === 'dim' ? 0 : 1, indent: 0, y: { kind: 'copy', text: block.text } }]
973    }
974    const tool = (one: ToolRow, indent: number): Stop => {
975      const key = `tool-${one.id}`
976      const isOpen = scene.pane.toggled[key] ?? false
977      const subject = subjectOf(one)
978      return {
979        key,
980        node: ToolView(draw, scene, actions, one, toolStatus(one, isWorking || running.has(one.id)), running.get(one.id), indent),
981        gap: 1,
982        indent,
983        enter: { kind: 'toggle', key, isOpen },
984        ...(subject ? { y: { kind: 'copy', text: subject.value } as Effect } : {}),
985      }
986    }
987    if (block.tools.length < FOLD_AT) return block.tools.map(one => tool(one, 0))
988    const fold = foldOf(scene, block, isWorking, running)
989    return [
990      { key: fold.key, node: FoldLine(draw, scene, actions, block, fold), gap: 1, indent: 0, enter: { kind: 'toggle', key: fold.key, isOpen: fold.isOpen } },
991      ...(fold.isOpen ? block.tools.map(one => tool(one, 2)) : []),
992    ]
993  })
994}
995
996/**
997 * The conversation, filling upward from the composer. It keeps the newest stops whose text fits in `room`, so a long
998 * thread costs its tail, and draws those around the bottom edge, which j, k, d and u move; the edge holds still as
999 * blocks arrive below it.
1000 */
1001function Conversation(draw: Draw, scene: Scene, actions: PaneActions, area: number, room: number, canInsert: boolean): Body {
1002  const { Box, Text } = draw
1003  const { id, thread, detail } = threadOf(scene)
1004  const watch = scene.watch?.thread === id ? scene.watch : null
1005  const empty = (text: string | null): Body => ({ nav: { context: 'conversation', items: [], total: 0, area, canInsert }, node: text && <Text color="subtle">{text}</Text> })
1006  if (thread?.status === 'deleted' || watch?.state?.status === 'deleted') return empty('The thread is deleted, so its conversation is gone.')
1007  if (detail?.conversation === null) return empty('This thread has no agent conversation.')
1008  if (!watch?.state) return empty(null)
1009  const all = blocks(watch.rows, watch.dropped, scene.live)
1010  if (all.length === 0) return empty('No messages yet.')
1011  const isWorking = watch.state.run === 'working'
1012  const running = new Map((scene.live?.tools ?? []).map(tool => [tool.callId, tool.output]))
1013  const stops = stopsOf(draw, scene, actions, all, isWorking, running)
1014  const width = scene.width - 1
1015  // Newest first while the drawn text fits; the newest stop bigger than all of it is cut with a note.
1016  const kept: Stop[] = []
1017  let left = room
1018  for (let at = stops.length - 1; at >= 0; at -= 1) {
1019    const stop = stops[at]!
1020    const size = drawnChars(stop.node)
1021    if (size <= left) {
1022      kept.unshift(stop)
1023      left -= size
1024      continue
1025    }
1026    if (kept.length === 0) kept.unshift({ ...stop, node: Cut(draw, stop.node, left) })
1027    break
1028  }
1029  const omitted = stops.length - kept.length
1030  if (omitted > 0) kept.unshift({ key: 'left-out', node: <Text color="subtle">{`… ${plural(omitted, 'earlier block', 'earlier blocks')} left out of the pane`}</Text>, gap: 0, indent: 0 })
1031  let y = 0
1032  const items: NavItem[] = kept.map(stop => {
1033    const height = stop.gap + rowsOf(stop.node, width - stop.indent)
1034    const item: NavItem = { key: stop.key, top: y, height, ...(stop.enter ? { enter: stop.enter } : {}), ...(stop.y ? { y: stop.y } : {}) }
1035    y += height
1036    return item
1037  })
1038  const total = y
1039  const rows = scrolledRows(scene.pane.scroll, items, total, area)
1040  const current = blockAt(items, scene.pane.scroll)
1041  // Only what the window can reach is drawn: the stops wholly below its bottom edge are left out and the edge moves
1042  // by what is left, those far above it are left out as the window's top clips them anyway.
1043  const edge = total - rows
1044  const shown = kept.flatMap((stop, index) => {
1045    const item = items[index]!
1046    if (item.top >= edge || item.top + item.height <= edge - area) return []
1047    return [
1048      <Box key={`stop-${stop.key}`} flexDirection="row" marginTop={stop.gap} paddingLeft={stop.indent}>
1049        <Text color="suggestion">{index === current ? '▎' : ' '}</Text>
1050        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
1051          {stop.node}
1052        </Box>
1053      </Box>,
1054    ]
1055  })
1056  const below = items.filter(item => item.top >= edge).reduce((sum, item) => sum + item.height, 0)
1057  return {
1058    nav: { context: 'conversation', items, total, area, canInsert },
1059    node: (
1060      <Box key="conversation" flexDirection="column" flexShrink={0} marginBottom={-(rows - below)}>
1061        {shown}
1062      </Box>
1063    ),
1064  }
1065}
1066
1067/** The props whose text an element draws; a field's `value` has a room of its own. */
1068const TEXT_PROPS = ['source', 'text', 'label', 'alt', 'placeholder'] as const
1069/** The elements whose one text prop may be cut; any other is drawn whole or left out, as the engine does a label. */
1070const CUT_PROP: Partial<Record<string, 'source' | 'text'>> = { Code: 'source', Markdown: 'text' }
1071
1072function ownChars(node: Element): number {
1073  const props = node.props ?? {}
1074  const options = Array.isArray(props.options) ? props.options.reduce((sum: number, option) => sum + String((option as { label?: unknown }).label ?? '').length, 0) : 0
1075  return TEXT_PROPS.reduce((sum, prop) => sum + (typeof props[prop] === 'string' ? (props[prop] as string).length : 0), options)
1076}
1077
1078/** The text a tree carries, as the engine counts it toward the 100000 it draws: children, sources, texts, labels. */
1079function drawnChars(node: unknown): number {
1080  if (typeof node === 'string') return node.length
1081  if (Array.isArray(node)) return node.reduce((sum: number, child) => sum + drawnChars(child), 0)
1082  return isElement(node) ? ownChars(node) + drawnChars(node.children) : 0
1083}
1084
1085/** `node` with its text cut after `room` characters in the order written, and how many it kept. */
1086/**
1087 * The keys a drawing holds, and where the ring belongs in it: the last element drawn with `autoFocus`, which is the
1088 * view's most specific cursor (a row under the tab, an entry of the menu), else the tab itself.
1089 */
1090export function ringOf(tree: unknown): { keys: ReadonlySet<string>; home: string | null } {
1091  const keys = new Set<string>()
1092  let home: string | null = null
1093  const walk = (node: unknown) => {
1094    if (Array.isArray(node)) return node.forEach(walk)
1095    if (!isElement(node) || node.props?.display === 'none') return
1096    const key = node.props?.key
1097    if (typeof key === 'string') {
1098      keys.add(key)
1099      if (node.props?.autoFocus === true) home = key
1100    }
1101    walk(node.children)
1102  }
1103  walk(tree)
1104  return { keys, home }
1105}
1106
1107export function cutTree(node: unknown, room: number): { node: unknown; used: number } {
1108  if (typeof node === 'string') return node.length <= room ? { node, used: node.length } : { node: node.slice(0, room), used: room }
1109  if (Array.isArray(node)) {
1110    const kept: unknown[] = []
1111    let used = 0
1112    for (const child of node) {
1113      if (used >= room) break
1114      const part = cutTree(child, room - used)
1115      // An element left out leaves no hole: a copied element's children are drawn as they stand.
1116      if (part.node !== null) kept.push(part.node)
1117      used += part.used
1118    }
1119    return { node: kept, used }
1120  }
1121  if (!isElement(node)) return { node, used: 0 }
1122  const own = ownChars(node)
1123  if (own <= room) {
1124    const children = cutTree(node.children, room - own)
1125    return { node: node.children === undefined ? node : { ...node, children: children.node }, used: own + children.used }
1126  }
1127  const prop = CUT_PROP[node.type]
1128  if (!prop || typeof node.props?.[prop] !== 'string') return { node: null, used: 0 }
1129  const text = node.props[prop] as string
1130  if (node.props.format === 'diff') {
1131    // A diff keeps its whole hunks that fit: one cut mid-hunk would not parse. With none, it is left out.
1132    const hunks = wholeHunks(text, room)
1133    return hunks ? { node: { ...node, props: { ...node.props, source: hunks } }, used: hunks.length } : { node: null, used: 0 }
1134  }
1135  return { node: { ...node, props: { ...node.props, [prop]: text.slice(0, room) } }, used: room }
1136}
1137
1138/** The hunks of a diff that fit in `room` characters, whole, or null when not even the first does. */
1139function wholeHunks(source: string, room: number): string | null {
1140  let kept = ''
1141  for (const hunk of source.split(/\n(?=@@)/)) {
1142    const next = kept ? `${kept}\n${hunk}` : hunk
1143    if (next.length > room) break
1144    kept = next
1145  }
1146  return kept || null
1147}
1148
1149/** What the line under a cut step takes of its room. */
1150const NOTE_CHARS = 80
1151
1152/** `node` cut to fit in `room`, with a line that says how much of it was left out. */
1153function Cut(draw: Draw, node: RenderChildren, room: number) {
1154  const { Box, Text } = draw
1155  const kept = cutTree(node, Math.max(0, room - NOTE_CHARS))
1156  return (
1157    <Box flexDirection="column">
1158      {kept.node as RenderChildren}
1159      <Text color="subtle">{`… ${drawnChars(node) - kept.used} more characters of this step left out of the pane`}</Text>
1160    </Box>
1161  )
1162}
1163
1164type Fold = { key: string; bad: number; busy: number; isOpen: boolean }
1165
1166/** A fold's key and counts, and whether it is open: it opens itself while a step failed or runs, unless the person chose. */
1167function foldOf(scene: Scene, block: Extract<Block, { kind: 'tools' }>, isWorking: boolean, running: Map<string, string>): Fold {
1168  const statuses = block.tools.map(tool => toolStatus(tool, isWorking || running.has(tool.id)))
1169  const bad = statuses.filter(status => status === 'error').length
1170  const busy = statuses.filter(status => status === 'running').length
1171  const key = `fold-${block.tools[0]!.id}`
1172  return { key, bad, busy, isOpen: scene.pane.toggled[key] ?? (bad > 0 || busy > 0) }
1173}
1174
1175/** A call's arguments besides its subject and what its diff shows, as an opened row draws them. */
1176function otherArgs(tool: ToolRow, diff: ReturnType<typeof diffOf>): string {
1177  const subject = subjectOf(tool)
1178  const rest = Object.fromEntries(Object.entries(tool.args).filter(([arg]) => arg !== subject?.key && !(diff && (arg === 'edits' || arg === 'content'))))
1179  return Object.keys(rest).length === 0 ? '' : JSON.stringify(rest, null, 2)
1180}
1181
1182/** One text block of the conversation: the person's message, an answer or a notice. An answer streams under the key it keeps once committed. */
1183function BlockView(draw: Draw, block: Extract<Block, { kind: 'user' | 'assistant' | 'notice' }>, isClaude: boolean) {
1184  const { Box, Text, Markdown } = draw
1185  switch (block.kind) {
1186    case 'user':
1187      return (
1188        <Box key={block.key} flexDirection="row">
1189          <Text color="subtle">{'❯ '}</Text>
1190          <Box flexDirection="column" flexShrink={1}>
1191            <Text color="text">{block.text}</Text>
1192            {block.isLive && <Text color="subtle">{isClaude ? 'queued, Claude reads it at its next tool step or after this turn' : 'queued, sends after this run'}</Text>}
1193          </Box>
1194        </Box>
1195      )
1196    case 'assistant':
1197      return (
1198        <Box key={block.key} flexDirection="row">
1199          <Text color="text">{'⏺ '}</Text>
1200          <Box flexDirection="column" flexShrink={1}>
types/index.d.ts 431 lines
1/** A pylon's state as its node last reported it: `ActualState` in the Nexus repository's src/reconcile.ts. */
2export type NexusActual = 'spawning' | 'running' | 'pausing' | 'paused' | 'deleting' | 'absent' | 'failed' | 'unknown'
3
4/** What the control plane wants a pylon to be, from its thread's activity. */
5export type NexusDesired = 'running' | 'paused' | 'deleted'
6
7/** The fields of the CLI's `Thread` this mod reads. */
8export type NexusThread = {
9  id: string
10  title: string
11  project: string
12  status: 'active' | 'idle' | 'archived' | 'deleted'
13  /** The harness's turn runs, or a reported session's, as a Claude Code worker's does: the row's `agentTurnRunning` or `sessionTurnRunning`. */
14  agentTurnRunning: boolean
15  /** Unix milliseconds, as are the two after it. */
16  createdAt: number
17  lastHumanActivityAt: number
18  lastAgentActivityAt: number
19  /** The latest of the three. */
20  lastActivityAt: number
21  /** When the latest agent turn ended, a reply's identity, and when a person last marked the replies seen from any client: Unix milliseconds, null for never. */
22  lastReplyAt: number | null
23  seenAt: number | null
24}
25
26/** What a thread asks of the person, the first that holds in this order: `attention` in docs/cli.md. */
27export type NexusAttention = 'failed' | 'decision' | 'parked' | 'reply' | 'working' | 'waiting' | null
28
29/** A `threads` row: the thread with its pylon's state and what the planner wants of the pylon. */
30export type NexusThreadRow = NexusThread & {
31  pylon: { node: string; actualState: NexusActual; failed: string | null }
32  desired: NexusDesired
33  attention: NexusAttention
34}
35
36/** Why a CLI call failed: the CLI's error kind (its exit code) and message. */
37export type NexusFailure = {
38  kind: 'failed' | 'unreachable' | 'refused' | 'not-found' | 'conflict' | 'invalid'
39  message: string
40}
41
42/** A preview of one of a pylon's services: `Preview` in the Nexus repository's src/control/registry.ts. */
43export type NexusPreview = {
44  service: string
45  httpsPort: number
46  /** The node publishes it and has warmed its URL; it says nothing of the pylon answering. */
47  ready: boolean
48  /** Why it cannot answer: the pylon's node reported its service failed, or its port without a route into the pylon. Null otherwise, and from a control plane too old to say. */
49  failed: string | null
50  /** The CLI's own URL, used as it is. */
51  url: string
52}
53
54/** A TCP port a preview can reach in the pylon, and the preview of the service that owns it. */
55export type NexusPort = { port: number; service: string | null; preview: NexusPreview | null }
56
57/** A watch `state` event: the thread's status, its run, its pylon and the ports it listens on. */
58export type NexusRunState = {
59  status: NexusThread['status']
60  run: 'idle' | 'working' | 'parked'
61  parkedTasks: number
62  queued: number
63  pylon: string
64  failed: string | null
65  /** In port order; [] while the pylon is not running, null while its node could not read them. */
66  ports: NexusPort[] | null
67}
68
69/** A watch `live` event: what the current run shows before it is committed to the conversation. */
70export type NexusLive = {
71  /** The text of each message waiting behind the run. */
72  queued: string[]
73  /** The output so far of each tool call still running, by its call's id. */
74  tools: { callId: string; output: string }[]
75  /** The answer streaming now. */
76  partial: string | null
77  /** The error of a model request waiting to be retried. */
78  retry: string | null
79}
80
81/**
82 * One drawn row of a conversation. A tool call and its result are one row. `entry` is the position of the entry that
83 * made it in a Claude Code worker's transcript, counted through the entries the control plane dropped: the row's key.
84 */
85export type NexusRow = { entry?: number } & (
86  | { kind: 'user'; text: string; at: number | null }
87  | { kind: 'assistant'; text: string }
88  | {
89      kind: 'tool'
90      id: string
91      name: string
92      summary: string
93      outcome: 'running' | 'ok' | 'error'
94      /** The call's arguments, each string cut to a bound. */
95      args: Record<string, unknown>
96      /** The result's text, its tail kept, and how many lines the whole had. */
97      output: string
98      lines: number
99      /** A unified patch of what an edit changed, when its result carries one. */
100      patch: string | null
101    }
102  | { kind: 'notice'; tone: 'error' | 'dim'; text: string }
103)
104
105/** The latest `threads` poll. A failed poll keeps the threads it had and says why. */
106export type NexusList = { threads: NexusThreadRow[] | null; failure: NexusFailure | null; polledAt: number | null }
107
108/** The thread open in the pane, as its watch has reported it, but for its `live` events. */
109export type NexusWatch = {
110  thread: string
111  rows: NexusRow[]
112  /** How many rows came before `rows[0]` since the last reset: the rows kept are a tail. */
113  dropped: number
114  /** Null until the watch's first poll answers. */
115  state: NexusRunState | null
116  /** The model of the newest answer, which the next message goes to. */
117  model: string | null
118  /** The outage the watch reported last, cleared by its next state. */
119  outage: string | null
120  /** Why the watch stopped; null while it runs. */
121  ended: string | null
122}
123
124/**
125 * A session in the pylon, as its node last reported it: open in its pane (`running`), one the pylon's next wake brings
126 * back (`paused`), or one the last wake did not bring back (`failed`, `message` saying why). A control plane older than
127 * CLI 0.8.0 sends only running sessions, without `state` or `id`.
128 */
129export type NexusSession = {
130  /** What `session close` takes: the pane, or `failed-<hash>` for a failed session in no pane. */
131  id: string
132  harness: NexusHarness
133  harnessSessionId: string | null
134  /** Herdr's, as last read while the session ran. */
135  status: 'working' | 'idle' | 'blocked' | 'done' | 'unknown'
136  /** Its Herdr workspace's label; null from a probe or control plane older than CLI 0.5.0. */
137  title: string | null
138} & (
139  | { state: 'running'; herdrPane: string; message: null }
140  /** `herdrPane` is the pane it had, which a wake may change. */
141  | { state: 'paused'; herdrPane: string | null; message: null }
142  /** `herdrPane` is the pane it left behind, or null. */
143  | { state: 'failed'; herdrPane: string | null; message: string }
144)
145
146/** The agents `session open` starts in a pylon. */
147export type NexusHarness = 'claude-code' | 'pi' | 'shell'
148
149/** What `session open` prints: the session it opened, with the title the CLI gave it. */
150export type NexusOpenedSession = { herdrPane: string; harness: NexusHarness; title: string }
151
152/**
153 * What the person did to a thread's sessions from the pane: the opens and closes under way, and those confirmed that
154 * the details have not shown yet, each with when it was confirmed. The node reads its sessions again once the CLI has
155 * answered, so a `show` asked for after the confirmation can still list them as they were.
156 */
157export type NexusSessionChanges = {
158  /** Each open under way, by the number the pane gave it. */
159  opening: { id: number; harness: NexusHarness; title: string | null }[]
160  /**
161   * `before`: the IDs and harness session IDs of the sessions the details listed when the open was confirmed, or null
162   * when they were not at hand. A wake moves a session's pane, and so its ID, but keeps its harness session ID.
163   */
164  opened: { session: NexusOpenedSession; at: number; before: { ids: string[]; harnessSessionIds: string[] } | null }[]
165  /** The IDs of the sessions whose close is under way. */
166  closing: string[]
167  /** Each close confirmed, by session ID: when, and the session's own ID, which a wake that moves its pane keeps. */
168  closed: Record<string, { at: number; harnessSessionId: string | null }>
169}
170
171/** What the open a session form holds: the harness, and a title that is the harness's name when empty. */
172export type NexusSessionDraft = { harness: NexusHarness; title: string }
173
174/**
175 * The watch's latest `live` event for the thread open in the pane. It has an atom of its own: a streaming answer
176 * prints ten a second, and writing it with the rows would copy every row each time.
177 */
178export type NexusLiveShown = { thread: string; live: NexusLive }
179
180/** `show --json`: a thread with its pylon, sessions, previews, ports and outputs. */
181export type NexusDetail = {
182  thread: NexusThread
183  attention: NexusAttention
184  pylon: { id: string; node: string; actualState: NexusActual; failed: string | null; observedAt: string | null; retryRefused: string | null }
185  sessions: NexusSession[]
186  previews: NexusPreview[]
187  /** In port order; [] while the pylon is not running, null while its node could not read them. */
188  ports: NexusPort[] | null
189  outputs: { kind: 'branch' | 'diff' | 'preview' | 'file'; ref: string }[]
190  /** Null for a thread with no conversation. */
191  agent: { parkedTasks: number } | null
192  /** Whose conversation the thread has: the Nexus harness's, a Claude Code worker's transcript (CLI 0.17.0), or none. */
193  conversation: 'agent' | 'claude' | null
194  /** A Claude Code worker thread's launch: its state, such as `started` or `stalled`, the model asked for, and a failure's diagnostic. */
195  worker: { state: string; model: string | null; message: string | null } | null
196  /** Why the thread's review ended for the person (CLI 0.18.0), its attention then `decision`; empty otherwise. */
197  decisions: string[]
198  desired: NexusDesired
199  /** The command that attaches to the pylon's Herdr sessions from a shell in the pylon. */
200  attach: string
201}
202
203/**
204 * A Claude Code worker thread's conversation, as `nexus conversation` last printed it, in the rows a watch gives:
205 * `nexus watch` streams none of it. `queued` holds the messages Claude has not taken yet.
206 */
207export type NexusClaudeShown = { thread: string; rows: NexusRow[]; dropped: number; working: boolean; queued: string[]; readAt: number | null; failure: string | null }
208
209/** The `show` poll of the thread open in the pane. */
210export type NexusShown = {
211  thread: string
212  detail: NexusDetail | null
213  /** The poll's place in `sequence`, taken as it asked: a port change confirmed before it, the details show. */
214  askedSeq: number
215  failure: NexusFailure | null
216}
217
218export type NexusProject = { id: string; repoUrl: string; defaultBranch: string; defaultNode: string }
219export type NexusNode = { name: string; tailnetHost: string; state: 'online' | 'offline' }
220
221/** `projects` and `nodes`, read when the pane opens, on a refresh, and when the start form or the project picker opens. */
222export type NexusCatalog = {
223  projects: NexusProject[] | null
224  nodes: NexusNode[] | null
225  /** Why the last read of `nodes` failed: their line and the form's node list are left out meanwhile. */
226  nodesFailure: NexusFailure | null
227  projectsFailure: NexusFailure | null
228}
229
230/** What the start form holds while the person fills it in. */
231export type NexusStartDraft = {
232  title: string
233  project: string
234  /** Empty for the project's default node, as for its default branch. */
235  node: string
236  branch: string
237  message: string
238  error: string | null
239  isStarting: boolean
240  /** The submission this form waits on, 0 before its first: a start that ends checks it is still the form's. */
241  request: number
242  /** The field or element the person moved the ring to, which the message's rows follow. */
243  focused: string | null
244}
245
246/** What the new project form holds while the person fills it in: what `nexus project add` takes. */
247export type NexusProjectDraft = {
248  id: string
249  repoUrl: string
250  /** A node's name; the first online one to begin with. */
251  node: string
252  /** Empty for `main`. */
253  branch: string
254  /** Why the last add failed, the CLI's message as it printed it. */
255  error: string | null
256  isAdding: boolean
257  /** The add this form waits on, 0 before its first: an add that ends checks it is still the form's. */
258  request: number
259}
260
261export type NexusView = 'list' | 'thread' | 'start' | 'project' | 'session'
262
263/** The thread view's tabs, in the order h and l walk them: the dashboard's inspector, after the conversation. */
264export type NexusTab = 'conversation' | 'changes' | 'previews' | 'pylon'
265
266/**
267 * What a checkout read compares with: `start`, what the pylon booted at, `turn`, the snapshot taken before the latest
268 * reply's first model request (CLI 0.11.0), or `head`. Null `commit` is the empty tree; a `turn` names its snapshot.
269 */
270export type NexusBase = { kind: 'start' | 'turn' | 'head'; commit: string | null }
271
272export type NexusFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange'
273
274/** One file of `changes --json`. A binary file's counts are null. */
275export type NexusChangedFile = { path: string; oldPath: string | null; status: NexusFileStatus; additions: number | null; deletions: number | null }
276
277/** `changes --json`: the files that differ from the base, at most 3000, `truncated` when there were more. */
278export type NexusChanges = { base: NexusBase; files: NexusChangedFile[]; truncated: boolean }
279
280/**
281 * The Changes tab's reads of the thread shown. Each read reaches into the pylon and wakes it, so they happen only on
282 * the rules `checkoutTrigger` keeps, one at a time.
283 */
284export type NexusCheckout = {
285  thread: string
286  /** The last reply, kept on screen while a read runs again. */
287  changes: NexusChanges | null
288  /** Why the last read failed; `isNoBase` when the pylon booted before Nexus recorded its base. */
289  failure: { message: string; isNoBase: boolean } | null
290  isReading: boolean
291  /** The person asked to wake a paused pylon to read it: the read runs, and runs again once the pylon runs. */
292  isWanted: boolean
293}
294
295/** One drawn row of a diff: a hunk's separator with its heading, or a line with the new file's number (none when removed). */
296export type NexusDiffRow = { kind: 'hunk'; text: string } | { kind: 'add' | 'del' | 'ctx'; line: number | null; text: string }
297
298/** The diff open in the Changes tab, as `diff --json` printed it, parsed into rows. */
299export type NexusDiffShown = {
300  thread: string
301  file: NexusChangedFile
302  /** What it was read against. */
303  base: NexusBase['kind']
304  /** Null until a read answers. */
305  rows: NexusDiffRow[] | null
306  /** What git says beyond the lines: renames, modes. */
307  meta: string[]
308  isBinary: boolean
309  /** The rows stop short: the CLI's `truncated`, or `DIFF_ROWS`. */
310  isCut: boolean
311  failure: string | null
312  isReading: boolean
313}
314
315/**
316 * Where typed letters go: to the hotkeys (`normal`), the composer (`insert`) or the list's filter (`filter`). The
317 * pane's own record of it, since the engine says nothing when Esc hands the keys back from a field.
318 */
319export type NexusMode = 'normal' | 'insert' | 'filter'
320
321/**
322 * What covers the view's body: the Actions menu or the project picker with the key of the row under its cursor, the
323 * key help, or nothing.
324 */
325export type NexusOverlay = { kind: 'menu'; cursor: string | null } | { kind: 'projects'; cursor: string | null } | { kind: 'help'; top: number } | null
326
327/**
328 * How far the conversation is scrolled back: null follows its end; else the body's bottom edge stands `rows` above
329 * the bottom of the block keyed `anchor`, so what arrives below moves nothing on screen.
330 */
331export type NexusScroll = { anchor: string; rows: number } | null
332
333/**
334 * A port the person published or unpublished from the pane. The three are apart, so an operation that starts or
335 * fails never loses what the last one that succeeded did.
336 */
337export type NexusPortChange = {
338  /** What the last operation that succeeded left the port with, its preview or none, and its place in `sequence`. */
339  confirmed: { preview: NexusPreview | null; seq: number } | null
340  /** `nexus preview --wait` or `nexus unpreview` under way. */
341  pending: 'open' | 'close' | null
342  /** Why the last operation failed, until the next starts. */
343  failure: { action: 'open' | 'close'; message: string } | null
344}
345
346/**
347 * A text field's `value` and how many times the pane has put one there. The field is drawn under a key of its own for
348 * each put (`fieldKey`), so the engine shows the put text, never the typing under an older key.
349 */
350export type NexusField = { text: string; puts: number }
351
352export type NexusPane = {
353  view: NexusView
354  tab: NexusTab
355  /** The thread shown, or null for the list. */
356  selected: string | null
357  mode: NexusMode
358  overlay: NexusOverlay
359  /** The first key of a two-key motion, `g`, waiting for its second. */
360  pending: 'g' | null
361  /** The list's row under the cursor: a thread's ID or a closed group's key. Back from a thread puts it there. */
362  listCursor: string | null
363  /** The list's first row shown, as last scrolled; each drawing clamps it so the cursor shows. */
364  listTop: number
365  /**
366   * The pane took the keys back without the person asking (`recover`) and put the ring on the list's cursor. An Enter
367   * typed for the field the keys left may still be on its way, so the next Enter on a row opens nothing and says what
368   * a second would open. The person's next key or move of the ring clears it.
369   */
370  isRetaken: boolean
371  scroll: NexusScroll
372  /** The Changes tab's row under the cursor: `dir-<path>` or `file-<path>`. */
373  changesCursor: string | null
374  /** The diff open in the Changes tab, in place of the tree, and its first row shown; null for the tree. */
375  diff: { path: string; top: number } | null
376  /** The Previews tab's row under the cursor: `preview-<service>` or `port-<port>`. */
377  portCursor: string | null
378  /** The Pylon tab's stop under the cursor: `pylon-details`, `session-<id>` or `attach-mac`. */
379  pylonCursor: string | null
380  /** The folds, tool rows, closed-thread groups and Changes folders the person opened or closed, by key; the rest follow the defaults. */
381  toggled: Record<string, boolean>
382  filter: string
383  /** The composer's text as the person typed it, for the rows it takes. */
384  draft: string
385  start: NexusStartDraft | null
386  project: NexusProjectDraft | null
387  session: NexusSessionDraft | null
388  /** What the pane last put in each text field, by the field's name (`putFields`). */
389  fields: Record<string, NexusField>
390  /** The outcome of the last press, until the next one. */
391  notice: { text: string; tone: 'error' | 'dim' } | null
392  /**
393   * The ports the person published or unpublished, by thread and then port. Kept when the pane leaves a thread, since
394   * the CLI runs on; cleared when the pane's processes start afresh, as no older one is under way then.
395   */
396  portChanges: Record<string, Record<string, NexusPortChange>>
397  /** The sessions the person opened or closed, by thread, kept when the pane leaves a thread as `portChanges` are. */
398  sessionChanges: Record<string, NexusSessionChanges>
399}
400
401/** What the strip above the prompt reads besides the list: whether it shows. */
402export type NexusStrip = { isOn: boolean }
403
404declare module 'claude-code' {
405  interface PluginState {
406    nexus: {
407      list: NexusList
408      watch: NexusWatch | null
409      live: NexusLiveShown | null
410      shown: NexusShown | null
411      catalog: NexusCatalog
412      pane: NexusPane
413      strip: NexusStrip
414      /** The project whose threads the list, the strip and `/nexus` show, null for every project; kept in `$.store`. */
415      scope: string | null
416      /** The strip's chip under the focus ring, a thread's ID, drawn highlighted. */
417      stripCursor: string | null
418      /** The run clock, ticked each second while a shown run works. */
419      now: number
420      /** The transcript of the Claude Code worker thread shown, null for any other thread. */
421      claude: NexusClaudeShown | null
422      /** The Changes tab's reads of the thread shown, null before the first. */
423      checkout: NexusCheckout | null
424      /** The diff the Changes tab shows, null before one is opened. */
425      diff: NexusDiffShown | null
426      /** What Changes compares with: what the pylon booted at, the latest turn's snapshot or HEAD; kept in `$.store`. */
427      changesBase: NexusBase['kind']
428    }
429  }
430}
431