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

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.
/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.
/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.
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.
| Key | What 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 e | Top, bottom. In a conversation g e follows its end again |
d u | Half a page down, up |
l h | Open, 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, o | Open 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 a | Write to the agent (insert mode). Enter sends, or queues behind a working run; an empty Enter or Esc is back to normal mode |
f | Filter the list by title (filter mode). Enter keeps the filter, Esc clears it |
x | Unpublish the preview under the cursor, in Previews. Close the session under the cursor, in Pylon, after asking |
y | Copy 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> |
m | The Actions menu: abort, retry, retry the pylon, start again, archive, unarchive, copy the attach command, open a session, attach, watch again, delete |
n | Start a thread, from the list. In a thread, open a session in its pylon |
p | The projects: show one project's threads, every project's, or add a project |
r | Refresh, or watch the thread again. In Changes, read the changes again, waking a paused pylon |
c | In 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 |
s | The key help, which j/k, d/u and g g/g e scroll when the pane is too short for it |
q | Close 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.
▾ 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.‹ 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.❯, 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.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.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.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.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.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.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.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.
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.
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.
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 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.
| Tool | What it does |
|---|---|
mcp__nexus__projects | Lists projects (read-only) |
mcp__nexus__project_add | Adds a repository or scratch project and returns the CLI’s Project JSON; refreshes the pane’s catalog without changing the project shown |
mcp__nexus__threads | Lists threads, optionally for one project, whichever project the pane shows |
mcp__nexus__start | Starts 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__send | Sends 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__read | A 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
hooks/register.tsx 2610 lines1import { 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 lines1// 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 lines1// 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}
469hooks/strip.tsx 80 lines1// 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}
80hooks/tools.ts 146 lines1// 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}
146hooks/view.tsx 2278 lines1// 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 lines1/** 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