From 6e3d6d6297ca60190cea5d745187b067da5f3cff Mon Sep 17 00:00:00 2001 From: dot-agi Date: Fri, 23 Oct 2026 01:04:44 +0530 Subject: [PATCH] Release 1.2.0 --- .claude-plugin/plugin.json | 2 +- README.md | 25 +- docs/images/pane.svg | 125 ------ docs/images/thread.svg | 93 ----- docs/usage.md | 41 +- hooks/inbox.ts | 10 +- hooks/limits.ts | 2 + hooks/register.tsx | 79 +++- hooks/text.ts | 165 +++++++- hooks/views.tsx | 796 +++++++++++++++++++++++++++++++------ types/index.d.ts | 2 + 11 files changed, 945 insertions(+), 395 deletions(-) delete mode 100644 docs/images/pane.svg delete mode 100644 docs/images/thread.svg diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ba9f05e..99ff6b6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "high-command", - "version": "1.1.2", + "version": "1.2.0", "description": "See the messages your teammates, named subagents, and other Claude Code sessions send you, grouped by peer with unread counts: a pane, a band above the prompt, and /highcommand.", "author": { "name": "dot-agi" }, "license": "MIT", diff --git a/README.md b/README.md index 9908cd7..832042b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Claude Code 2.1.288+ mod License: MIT - Version: 1.1.2 + Version: 1.2.0

One inbox for agent-team teammates, named subagents, and other Claude Code sessions.

@@ -33,14 +33,6 @@ When your session works with other agents, their messages arrive in the transcri - **Idle notices and peer states.** A teammate's idle notice with no content only updates its state. A notice that carries the teammate's final answer or failure shows as a message. When Claude runs ListAgents, High Command notes the state of each listed peer, such as busy, idle, or offline, and shows it on that peer's row once the peer has messages. - **Read-only.** High Command never starts a model turn and never changes a message, a send, or a tool result. -![High Command pane roster with three peers: scout 2 new and tester 1 new as verified sender, helper 2 new as local sender.](docs/images/pane.svg) - -*The roster: three peers with unread counts and sender labels.* - -![High Command thread for helper, newest first: its finished notice as verified sender, then its message build finished, 3 warnings as local sender, both new.](docs/images/thread.svg) - -*The helper thread, newest first, shows its message and finished notice.* - ## How it works ```mermaid @@ -57,7 +49,7 @@ flowchart LR - **Messages in**: High Command records each message to your main conversation after Claude Code and the other mods have handled it, so it shows the text that Claude reads. In Claude Code 2.1.296, a named subagent's message does not pass `session.receive`. Claude Code submits it as a prompt from a peer, so High Command reads it in `prompt.submit`, both while the session is idle and during a turn. Task notifications, scheduled prompts, and your own Remote Control prompts are not recorded. A message that another mod consumes, or that you decline in an approval dialog, never reaches Claude, so High Command does not record it. - **Messages out**: each plain-text SendMessage that Claude makes in your main conversation, with its reported result. A message to an agent that has no name, and a message that another mod in your session sends, are not recorded. [Known limits](#known-limits) names the other sends that High Command cannot see. -- **Agent lanes**: local agents' messages go to one lane per agent. The pane opens on the main inbox. Its agent selector switches lanes, and the pane stays on the lane you pick, also when you view an agent's transcript. +- **Agent lanes**: local agents' messages go to one lane per agent. The pane opens on the main inbox. Its **Agent lanes** section switches lanes, and the pane stays on the lane you pick, also when you view an agent's transcript. - **Unread**: a message is unread until you open its peer's thread, press **Mark read** in that thread, or press **Read all**. Messages that Claude sends are never unread. ## Install @@ -134,10 +126,17 @@ claude plugin update high-command@high-command --json | `r` | Band or pane roster | Mark every message read | | `h` | Band | Hide the band until a newer message arrives | | `1`-`9` | Pane roster | Open the thread of that peer | -| `b` | Pane thread | Back to the roster | +| `p`, `n` | Pane roster | Previous or next page of peers | +| `m` | Pane roster | Show the main inbox | +| `b` | Pane thread, identity, or agent lane | Back up one level | +| `i` | Pane thread | Show the peer's full name | | `r` | Pane thread | Mark that peer read, only while **Mark read** shows | +| `p`, `n` | Pane thread | Newer or older page | +| `p`, `n` | Pane identity | Previous or next part of the name | | Esc | Band or pane | Give the keyboard back to the prompt | +In the terminal, the pane shows each letter and digit key before its button's label, as in `r: Read all`. The agent buttons, the agent pages, **Agent status**, and **Show send details** have no key: press Tab to reach them, or click them. When the pane's location line has room, it ends with the dim hint `ctrl+x tab or click for keys · esc to prompt`. + When the band's row has room, it ends with the dim hint `ctrl+x tab to choose`. Press Ctrl+X, then Tab, to give the band the keyboard. Then Tab moves between its buttons, Enter presses one, and Esc gives the keyboard back to the prompt. Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next tab. In Emacs terminals, Ctrl+X goes to Emacs: click the band, run `/highcommand open`, or rebind `abovePrompt:focus` with [`/keybindings`](https://code.claude.com/docs/en/keybindings#above-prompt-actions). @@ -188,9 +187,9 @@ The list below holds the limits you can hit in daily use. [docs/usage.md](docs/u Read the limits you can hit in daily use - **Agent lanes**: messages that local agents send and receive go to a separate lane for each agent. Each lane has its own counts and read marks. The pane shows each lane's unread count on that agent's button, and `/highcommand` lists the counts apart from the main inbox. By default, the band, the status line, and **Hide** cover the main inbox only, so an unread message in an agent lane does not show there. The `agentLanesInBand` [option](#options) adds the unread count of all agent lanes to the band and the status line. A message between your main conversation and a local agent can show twice, once in the main inbox and once in that agent's lane. The lane copy of a message from your main conversation never counts as unread. High Command does not see messages between teammates that run in their own split panes. -- **Local agent status**: the status under each agent button in the pane, and in `/highcommand`, names its source. A `roster snapshot` status comes from Claude Code's local agent list. High Command reads that list when you open the pane or run `/highcommand`, when an agent starts, and when an agent ends a turn. Between reads, the status can be old. When two reads overlap, the older one can bring back an agent that the newer one removed. Open the pane or run `/highcommand` again after agent activity settles. A `lifecycle observation` says how an agent's last turn ended, such as `turn answered` or `turn error`. When the list does not hold an agent that ended a turn, an open pane shows that observation as the agent's status until the next read of the list. It describes a finished turn, so it does not prove that the agent still runs. A teammate in its own split pane can keep its last status after it crashes. `/highcommand` lists up to 100 local agents. The pane pages through all of them, 10 at a time. +- **Local agent status**: in the pane, **Agent status** comes after the agent buttons, on their last row when it fits. It shows the status of each agent on the page, with a dim note that a roster snapshot does not confirm that an agent still runs. That status, and the one in `/highcommand`, names its source. A `roster snapshot` status comes from Claude Code's local agent list. High Command reads that list when you open the pane or run `/highcommand`, when an agent starts, and when an agent ends a turn. Between reads, the status can be old. When two reads overlap, the older one can bring back an agent that the newer one removed. Open the pane or run `/highcommand` again after agent activity settles. A `lifecycle observation` says how an agent's last turn ended, such as `turn answered` or `turn error`. When the list does not hold an agent that ended a turn, an open pane shows that observation as the agent's status until the next read of the list. It describes a finished turn, so it does not prove that the agent still runs. A teammate in its own split pane can keep its last status after it crashes. `/highcommand` lists up to 100 local agents. The pane pages through all of them, up to 10 at a time. - **Peer states**: a peer's roster row shows only states from teammates' notices and ListAgents results, never the local agent status. A state is the last one reported, with its time, so it does not prove that the peer is in that state now. When two rows of one ListAgents result have the same name, High Command cannot tell whose state is whose. When the result lists its rows as data, High Command takes no state for that name, so a state recorded earlier, such as `offline`, stays on that peer's row. When the result holds only the printed listing, that name gets the state of the last such row that has one. -- **Send results**: a send shows the recipient and text that High Command observed, and the result reported back to High Command. **Send details** shows what Claude Code itself received, which another mod may have changed, and whether Claude Code queued the message. When a mod answers the send itself, Send details cannot show this. When a mod sends the message more than once, Send details covers only one of those sends. A refused send shows the first 120 characters of the reason, on one line, in its header. **Send details** keeps up to 2,000 characters of the reason, with its line breaks, and says when it was cut. A queued message is not proof that the peer read it. +- **Send results**: a send shows the recipient and text that High Command observed, and the result reported back to High Command. **Show send details** shows what Claude Code itself received, which another mod may have changed, and whether Claude Code queued the message. When a mod answers the send itself, Send details cannot show this. When a mod sends the message more than once, Send details covers only one of those sends. A refused send shows the first 120 characters of the reason, on one line, in its reported send result. **Send details** keeps up to 2,000 characters of the reason, with its line breaks, and says when it was cut. A queued message is not proof that the peer read it. - **Structured sends**: Claude Code passes only plain-text SendMessage messages to High Command. A SendMessage whose message is a `shutdown_request`, `shutdown_response`, or `plan_approval_response` object does not reach it. Neither its attempt nor its result appears in the main inbox, an agent lane, or `/highcommand`. Check the transcript for these sends. - **No earlier history**: High Command starts empty in each new session and conversation. `/clear`, `/resume`, and `/branch` empty it again, so a resumed conversation shows none of its earlier messages. The pane and `/highcommand` say when the capture window began. After these commands, they also say that earlier history was not restored. - **Display only**: High Command changes nothing that Claude reads. Its inbox leaves out idle notices with no content, but Claude still reads them, so the inbox saves no model turns or tokens. diff --git a/docs/images/pane.svg b/docs/images/pane.svg deleted file mode 100644 index c0eb1e4..0000000 --- a/docs/images/pane.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - High Command · pane roster - - - - - - - - - - │✕ -│Capture window: 05:10 · current conversation only. Earlier history is not included. -│Order: observation time. Counts cover retained messages. -│Main inbox -│Send and receive observations are separate. Split-pane traffic is unavailable; roster snapshots do not confirm -│liveness. -│[ Main inbox ] -│[ helper (ahelper-f17b9d4833d6a0fa) ] -│  idle; roster snapshot at 05:12; lifecycle observation: turn answered at 05:10 -│[ scout (ascout-f081ccc052ae4d3d) ] -│  idle; roster snapshot at 05:12; lifecycle observation: turn answered at 05:12 -│[ tester (atester-09feb8f62788b55e) ] -│  idle; roster snapshot at 05:12; lifecycle observation: turn answered at 05:11 -│High Command· 5 unread (retained) · 3 peers[ Read all ] -│1: scout 2 new · verified sender · 05:12 · idle (05:12; peer notice; last reported) · ← finished: Sent "scout … -│2: tester 1 new · verified sender · 05:11 · idle (05:11; peer notice; last reported) · ← finished: BLOCKED: th… -│3: helper 2 new · local sender · 05:10 · idle (05:10; peer notice; last reported) · ← finished: Build finished… - - - - diff --git a/docs/images/thread.svg b/docs/images/thread.svg deleted file mode 100644 index eae26ed..0000000 --- a/docs/images/thread.svg +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - High Command · helper thread - - - - - - - - - - │[ Back ]helper[ Identity ]· idle (05:10; peer notice; last reported) · verified sender · 2 retained messages -│· 2 new -│ -│← 05:10 finished: Build finished, 3 warnings. The message reached the main conversation and is queued for its  -│next turn. · verified sender · new -│Build finished, 3 warnings. The message reached the main conversation and is queued for its next turn. -│ -│← 05:10 build finished, 3 warnings · local sender · new -│build finished, 3 warnings - - - - diff --git a/docs/usage.md b/docs/usage.md index d028dff..e7838c8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -51,13 +51,27 @@ High Command never orders, highlights, or counts a message by a priority or urge ## The pane -The roster lists peers you received messages from or tried to message: peers with unread messages first, then the peer with the most recent message. Each row shows, in one line, the unread count, the sender label and any `suspicious` mark, the time of the latest message, the peer's state, and the latest message. A narrow pane cuts the end of the line first. Press a peer, or its digit, to open its thread. The digits follow the roster order, so they move when a new message arrives. +A roster or a thread reads from the top down: -A thread shows up to 20 messages per page with that peer, newest first. `←` marks a message to you and `→` a send attempt. A long message shows its first 2,000 characters and says how many more are in the transcript. **Older** and **Newer** page through the kept messages. A notice under the thread counts the messages on other pages, and a notice at the top of the pane counts the messages dropped for good. +1. A location line, such as `High Command › Main inbox` or `High Command › Main inbox › researcher`. When the line has room, it ends with the dim hint `ctrl+x tab or click for keys · esc to prompt`. +2. In a roster, the counts, such as `3 unread (retained) · 4 peers`, and **Read all**. In a thread, the peer's last reported state, then the facts line with **Show identity** and **Mark read**. +3. A dim line that says when the capture window began, that it covers this conversation only, and that the order is observation order. After `/clear`, `/resume`, or `/branch`, it also says that earlier history was not restored. +4. A notice that counts the messages dropped for good, when there are any. +5. The peer rows, or the thread's messages, then a page row when there is more than one page. +6. In a roster, the agent lanes. +7. The dim order note, `Order: observation time. Counts cover retained messages.` -A send shows `send attempted` with the recipient and text that High Command observed, and the reported send result. **Send details** shows the refusal reason, up to 2,000 characters. When available, it also shows the core lines: the recipient, text, and queue outcome that Claude Code itself received for the latest call. A core outcome covers that one call only. It does not prove that the recipient read the message. `/highcommand` labels sends as attempts. +The roster lists peers you received messages from or tried to message: peers with unread messages first, then the peer with the most recent message. In a pane of 90 columns or more, each row is one line of fixed columns under a dim header: the peer, the unread count, the time of the latest message, the sender level, the peer's last reported state with its time, and the latest message. Any `suspicious` and `(not delivered)` marks start the latest message. A narrower pane gives each row two lines (see **Narrow panes** in [Known limits](#known-limits)). `suspicious` and `unverified` show in your theme's warning color, and `(not delivered)` in its error color. The words stay, so a terminal without color loses nothing. On a row with nothing unread, the time, the state, and the message summary are dim. The arrow, the kind of message, and the marks are not. Press a peer, or its digit, to open its thread. The digits follow the roster order, so they move when a new message arrives. Rows past the ninth have no digit. A roster page shows up to 50 peers, with **Previous peers** and **More peers** on one row. When long names and messages would pass Claude Code's drawing limit, a page shows fewer, and a dim line says how many peers moved to the next page. -Both ways to open the pane show the main inbox, even while an agent transcript is in view. Once opened, the pane keeps the lane you pick and does not follow the agent transcript you view. Press an agent's button in the agent selector to see its lane, and **Main inbox** to return to the main conversation. Agent lane counts and read actions are separate, and each agent's button shows its lane's unread count. The agent selector shows 10 agents per page: each local agent and each agent with a lane. Each agent's status shows under its button. +A thread starts with **Back**, the peer's last reported state with its source, and a facts line: the sender label, the count of kept messages, the count of new messages, and the page. **Show identity** and **Mark read** follow the facts, or move to a line of their own when the line does not fit. A thread shows up to 20 messages per page with that peer, newest first. When long messages would pass the drawing limit, a thread page shows fewer, and a dim line says how many moved to the next page. Each message starts with `new` while it is new, its time, `←` for a message to you or `→` for a send attempt, and its kind: `message`, `finished`, `failed`, or `send attempted`. The sender label and any `suspicious` mark follow. A dim day line comes before the first message of an earlier day. The summary shows only when the sender wrote one that differs from the first line of the message. A long message shows its first 2,000 characters and says how many more are in the transcript. **Newer** and **Older** page through the kept messages, on one row with the page number and the count of messages on other pages. + +A send shows `send attempted` with the recipient and text that High Command observed. Its reported send result shows on a line of its own, in the error color when the send was not delivered. A dim note above the first send of a page says that sends show observed text and that a reported result is not a receipt. **Show send details** shows the refusal reason, up to 2,000 characters. When available, it also shows the core lines: the recipient, text, and queue outcome that Claude Code itself received for the latest call. A dim note above the core lines says that this evidence covers only the latest traced call. It does not prove that the recipient read the message. **Hide send details** closes them again. `/highcommand` labels sends as attempts. + +**Show identity** opens the peer's full name as a level of its own, 1,000 characters per part. That level shows only the location line, the part number with the name's length in characters, and the part. A name of more than one part adds **Previous identity part** and **Next identity part**. **Back** returns to the thread. + +Both ways to open the pane show the main inbox, even while an agent transcript is in view. Once opened, the pane keeps the lane you pick and does not follow the agent transcript you view. The **Agent lanes** section under a roster has **Main inbox** and one button per agent, and `▸` marks the lane on screen. Press an agent's button to see its lane, and **Main inbox** or **Back** to return to the main conversation. A button shows the agent's name, or its ID when the agent has no name or a name of only spaces or invisible characters, and adds the ID when two agents on the page share a name. Agent lane counts and read actions are separate, and each agent's button shows its lane's unread count. The section shows up to 10 agents per page: each local agent and each agent with a lane. When long agent names would pass the drawing limit, a page shows fewer, and a dim line says how many agents moved to the next page. **Agent status** shows each agent's status (see [Known limits](#known-limits)). + +**Back** goes up one level: from a peer's identity to its thread, from a thread to the roster page that now shows that peer, and from an agent lane to the main inbox. Opening a thread marks the peer read, which can move its row to a later page. ## The band @@ -76,10 +90,17 @@ By default, the band counts the main inbox only. With the `agentLanesInBand` [op | `r` | Band or pane roster | Read all | | `h` | Band | Hide the band until a newer message arrives | | `1`-`9` | Pane roster | Open a thread | -| `b` | Pane thread | Back | +| `p`, `n` | Pane roster | Previous or next page of peers | +| `m` | Pane roster | Main inbox | +| `b` | Pane thread, identity, or agent lane | Back up one level | +| `i` | Pane thread | Show identity | | `r` | Pane thread | Mark read, only while **Mark read** is visible | +| `p`, `n` | Pane thread | Newer or older page | +| `p`, `n` | Pane identity | Previous or next part of the name | | Esc | Band or pane | Give the keyboard back to the prompt | +In the terminal, the pane shows each letter and digit key before its button's label, as in `r: Read all`. The agent buttons, the agent pages, **Agent status**, and **Show send details** have no key: press Tab to reach them, or click them. When the pane's location line has room, it ends with the dim hint `ctrl+x tab or click for keys · esc to prompt`. + Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next tab. In Emacs terminals, Ctrl+X goes to Emacs: click the band, run `/highcommand open`, or rebind `abovePrompt:focus` with [`/keybindings`](https://code.claude.com/docs/en/keybindings#above-prompt-actions). Letter and digit keys work only while the band or the pane has the keyboard. Focus moves through each band button before it reaches the pane, so press Ctrl+X, then Tab, until the pane border shows focus. `/highcommand open` opens the pane directly. Opening a thread marks its kept messages read, so `r` works in a thread only when **Mark read** shows again, after that peer sends a new message. @@ -101,28 +122,28 @@ The first list holds the limits you can hit in daily use. The second list holds ### Limits in daily use - **Agent lanes**: messages that local agents send and receive go to a separate lane for each agent. Each lane has its own counts and read marks. The pane shows each lane's unread count on that agent's button, and `/highcommand` lists the counts apart from the main inbox. By default, the band, the status line, and **Hide** cover the main inbox only, so an unread message in an agent lane does not show there. The `agentLanesInBand` [option](#options) adds the unread count of all agent lanes to the band and the status line. A message between your main conversation and a local agent can show twice, once in the main inbox and once in that agent's lane. The lane copy of a message from your main conversation never counts as unread. High Command does not see messages between teammates that run in their own split panes. -- **Local agent status**: the status under each agent button in the pane, and in `/highcommand`, names its source. A `roster snapshot` status comes from Claude Code's local agent list. High Command reads that list when you open the pane or run `/highcommand`, when an agent starts, and when an agent ends a turn. Between reads, the status can be old. When two reads overlap, the older one can bring back an agent that the newer one removed. Open the pane or run `/highcommand` again after agent activity settles. A `lifecycle observation` says how an agent's last turn ended, such as `turn answered` or `turn error`. When the list does not hold an agent that ended a turn, an open pane shows that observation as the agent's status until the next read of the list. It describes a finished turn, so it does not prove that the agent still runs. A teammate in its own split pane can keep its last status after it crashes. `/highcommand` lists up to 100 local agents. The pane pages through all of them, 10 at a time. +- **Local agent status**: in the pane, **Agent status** comes after the agent buttons, on their last row when it fits. It shows the status of each agent on the page, with a dim note that a roster snapshot does not confirm that an agent still runs. That status, and the one in `/highcommand`, names its source. A `roster snapshot` status comes from Claude Code's local agent list. High Command reads that list when you open the pane or run `/highcommand`, when an agent starts, and when an agent ends a turn. Between reads, the status can be old. When two reads overlap, the older one can bring back an agent that the newer one removed. Open the pane or run `/highcommand` again after agent activity settles. A `lifecycle observation` says how an agent's last turn ended, such as `turn answered` or `turn error`. When the list does not hold an agent that ended a turn, an open pane shows that observation as the agent's status until the next read of the list. It describes a finished turn, so it does not prove that the agent still runs. A teammate in its own split pane can keep its last status after it crashes. `/highcommand` lists up to 100 local agents. The pane pages through all of them, up to 10 at a time. - **Peer states**: a peer's roster row shows only states from teammates' notices and ListAgents results, never the local agent status. A state is the last one reported, with its time, so it does not prove that the peer is in that state now. When two rows of one ListAgents result have the same name, High Command cannot tell whose state is whose. When the result lists its rows as data, High Command takes no state for that name, so a state recorded earlier, such as `offline`, stays on that peer's row. When the result holds only the printed listing, that name gets the state of the last such row that has one. -- **Send results**: a send shows the recipient and text that High Command observed, and the result reported back to High Command. **Send details** shows what Claude Code itself received, which another mod may have changed, and whether Claude Code queued the message. When a mod answers the send itself, Send details cannot show this. When a mod sends the message more than once, Send details covers only one of those sends. A refused send shows the first 120 characters of the reason, on one line, in its header. **Send details** keeps up to 2,000 characters of the reason, with its line breaks, and says when it was cut. A queued message is not proof that the peer read it. +- **Send results**: a send shows the recipient and text that High Command observed, and the result reported back to High Command. **Show send details** shows what Claude Code itself received, which another mod may have changed, and whether Claude Code queued the message. When a mod answers the send itself, Send details cannot show this. When a mod sends the message more than once, Send details covers only one of those sends. A refused send shows the first 120 characters of the reason, on one line, in its reported send result. **Send details** keeps up to 2,000 characters of the reason, with its line breaks, and says when it was cut. A queued message is not proof that the peer read it. - **Structured sends**: Claude Code passes only plain-text SendMessage messages to High Command. A SendMessage whose message is a `shutdown_request`, `shutdown_response`, or `plan_approval_response` object does not reach it. Neither its attempt nor its result appears in the main inbox, an agent lane, or `/highcommand`. Check the transcript for these sends. - **No earlier history**: High Command starts empty in each new session and conversation. `/clear`, `/resume`, and `/branch` empty it again, so a resumed conversation shows none of its earlier messages. The pane and `/highcommand` say when the capture window began. After these commands, they also say that earlier history was not restored. - **Display only**: High Command changes nothing that Claude reads. Its inbox leaves out idle notices with no content, but Claude still reads them, so the inbox saves no model turns or tokens. ### Rare cases -- **Retained history**: the main inbox and each agent lane keep 200 messages in all and 50 per peer. A peer past 50 messages loses its own oldest ones first. Past 200 in all, the oldest messages drop. A notice counts the messages dropped for good, and unread counts cover kept messages only. A thread shows up to 20 messages per page, fewer when they are long. **Older** and **Newer** page through every kept message. Opening a thread marks all of that peer's kept messages read, also those on other pages. The roster shows 50 peers per page. Peer states are kept for the 100 peers reported most recently. A message keeps its first 2,000 characters, and its thread says how many more are in the transcript. An agent lane also has a cap in characters (see **Space for agent lanes**). +- **Retained history**: the main inbox and each agent lane keep 200 messages in all and 50 per peer. A peer past 50 messages loses its own oldest ones first. Past 200 in all, the oldest messages drop. A notice counts the messages dropped for good, and unread counts cover kept messages only. A thread shows up to 20 messages per page, fewer when they are long. **Older** and **Newer** page through every kept message. Opening a thread marks all of that peer's kept messages read, also those on other pages. The roster shows up to 50 peers per page, fewer when their names and messages are long. Peer states are kept for the 100 peers reported most recently. A message keeps its first 2,000 characters, and its thread says how many more are in the transcript. An agent lane also has a cap in characters (see **Space for agent lanes**). - **Sends by mod-started agents**: Claude Code reports a send by an agent that a mod started as that mod's send. High Command records no mod's sends, so these sends do not show in that agent's lane or in `/highcommand`. Messages to that agent still show in its lane. Its messages to your main conversation or to another local agent still show in the main inbox or in that agent's lane. - **Space for agent lanes**: High Command saves all agent lanes of a conversation as one value. Claude Code 2.1.296 refuses a saved value over 4,194,304 characters of JSON text, so High Command caps the lanes below that size: - Each agent lane keeps at most 500,000 characters of saved message data. 200 messages of 2,000 characters fit. Sends with long **Send details**, and text with many line breaks or quotation marks, take more space, so such a lane can keep fewer than 200 messages. Past its cap, a lane drops its own oldest messages first. - All agent lanes together keep at most 4,000,000 characters, about eight full lanes of 2,000-character messages. Past that, the oldest messages of all lanes drop. High Command compares lanes by observation time. Each lane still drops its messages in recording sequence and keeps its newest message. New agent messages keep recording, and each lane's notice counts the messages it dropped for good. Because each lane keeps its newest message and its peer states, a conversation with very many agent lanes can still pass Claude Code's limit: about 1,800 lanes whose newest message has 2,000 characters, or fewer when those messages are sends with long **Send details**. A new agent message then still reaches its recipient unchanged. High Command does not record it and writes `high-command: session.receive: could not record a delivery ...` or `high-command: session.send: could not record a message to ...`. A lane button can also fail with a `high-command: ui.press: ...` toast. Read the missed messages in that agent's transcript. The main inbox is saved apart and keeps recording. `/clear`, `/resume`, or `/branch` starts a new capture window, which empties every lane and the main inbox. -- **Peer names**: High Command tells peers apart by name. Two senders with the same name share one roster row. Names that differ only in spacing or in invisible characters count as one name. A name longer than 60 characters shows cut, with a short code that keeps cut names apart. **Identity** in the thread shows the full name. A send that Claude addresses by the ID of a listed local agent files under that agent's name, on one row with the agent's messages to you. These sends count toward that name's 50 kept messages, so many sends can push out an unread message. The send files under `name [ID]`, on a row of its own, when another listed local agent has the same name or a peer of that name has another session's address. A name that holds only spacing and invisible characters files under `[ID]`. High Command reads the local agent list at each send. Claude Code drops an agent from that list when the agent ends or some time later. A send to the ID of an agent that is no longer listed files under that ID alone, on a row of its own, apart from the agent's messages to you. +- **Peer names**: High Command tells peers apart by name. Two senders with the same name share one roster row. Names that differ only in spacing or in invisible characters count as one name. A name longer than 60 characters shows cut, with a short code that keeps cut names apart. **Show identity** in the thread shows the full name. A send that Claude addresses by the ID of a listed local agent files under that agent's name, on one row with the agent's messages to you. These sends count toward that name's 50 kept messages, so many sends can push out an unread message. The send files under `name [ID]`, on a row of its own, when another listed local agent has the same name or a peer of that name has another session's address. A name that holds only spacing and invisible characters files under `[ID]`. High Command reads the local agent list at each send. Claude Code drops an agent from that list when the agent ends or some time later. A send to the ID of an agent that is no longer listed files under that ID alone, on a row of its own, apart from the agent's messages to you. - **Sends changed by earlier mods**: a mod that runs before High Command can change a send's recipient and text. High Command then shows the changed recipient and text, and files the send under the changed recipient. It cannot show what Claude first wrote. Check the SendMessage call in the transcript. - **Subagent messages**: Claude Code does not say whether a mod started or wrote a named subagent's message to your main conversation. High Command reads the local agent list when such a message arrives. The message is `unverified sender` when a mod started every listed agent whose name or ID its frame gives. Otherwise it is `local sender`: also when a mod wrote it from an agent that the model started, when its frames name several senders, and when the list cannot be read. Agents that left the list do not count, so a message from a model-started agent that already ended is `unverified sender` while a mod-started agent of the same name is listed. High Command counts such a message only when the prompt that reaches it holds a complete `` frame and no frame of another kind. A mod that runs after High Command can remove that frame. High Command then files the changed text under the one name that the frames gave, or under `unnamed peer` when they gave none or several. A sender name in the changed text, such as a `from` field, does not change this. - **Damaged frames**: Claude Code can wrap a peer's message in a frame, such as ``, whose attributes name the sender. A malformed attribute, a frame with no closing tag, or text beside complete frames makes High Command write an `incomplete frame parsing` line. A complete frame with a malformed attribute keeps the attributes that High Command could read, such as the sender name and summary. The sender label rules still apply to that name. A frame with no closing tag, and text beside complete frames, become a message from the delivery's sender, or from `unnamed peer`, without frame attributes. Check the transcript for the original frame. A delivery that Claude Code could not place is recorded only when it holds a complete frame. - **Display order**: the pane and `/highcommand` sort messages by observation time, the time High Command first saw each one, before other mods and Claude Code handled it. That is not always the order in which messages reached Claude. Read marks, **Hide**, which messages drop first within the main inbox or one agent lane, and the label of a peer with nothing unread follow recording sequence, the order in which High Command finished recording messages. Between agent lanes, drops follow observation time (see **Space for agent lanes**). -- **Narrow panes**: a roster row is one line, and a narrow pane cuts its end. The unread count, sender label, and `suspicious` mark come first, so the state and the latest message are cut before them. +- **Narrow panes**: below 90 columns, a roster row takes two lines, so the roster is twice as tall. The first line holds the name, the unread count, the time, and the state, and a narrow pane cuts the state first. The second line starts with the marks: the sender label, any `suspicious` mark, and any `(not delivered)` mark. They wrap and are never cut. The latest message follows them, cut at its end, or takes a line of its own when the marks leave it fewer than 20 columns. From 90 columns, each row is one line of fixed columns: a name wider than 16 columns shows its start and its end, as in `integrat…r-alpha`, and a message from another day shows only its date. When two names would show the same label, each shows a shorter cut and a 4-digit code from its full name, as in `inte…nner [f2e3]` and `inte…nner [e886]`. A label comes from its own name, so a new look-alike can switch a name to its code but cannot take its label. Names that also share the code are numbered in name order, and only those numbers can move to another peer. The state column widens to the longest state on the page but leaves the message at least 27 columns, so a state is cut only when the row is full. Those columns hold the `suspicious` and `(not delivered)` marks, so a cut never reaches them. A thread shows the name up to 60 characters, the full state, and the time of day. The column header scrolls away on a long roster. The location line leaves out its key hint when the hint does not fit, and a thread moves **Show identity** and **Mark read** to a line of their own. - **Band space**: when other mods draw a lot in the area above the prompt, the band first leaves out its key hint, then shrinks to the count and **Open**. When even that would pass Claude Code's limits for one drawing, the band does not show. The status line still counts unread messages. The band also gives way while a Claude Code survey shows. - **Summary as a list**: when a peer name holds a `|` after an odd number of backslashes, `/highcommand` prints a list instead of a table. A table cell cannot show such a name as written. - **Cut-off failure lines**: when High Command cannot record a send, the `high-command: session.send: could not record a message to ...` line names the recipient before the cause. The terminal draws the first 2,000 characters of a line. Remote surfaces and the debug log keep 10,000. A very long recipient can push the cause past that point. The SendMessage result in the transcript still says whether the message was queued. diff --git a/hooks/inbox.ts b/hooks/inbox.ts index f41e632..6095abc 100644 --- a/hooks/inbox.ts +++ b/hooks/inbox.ts @@ -30,7 +30,8 @@ export type RosterRow = { } /** The open thread as the pane draws it: the newest entries first, the count of older ones, and the count of new ones. */ -export type Thread = { row: RosterRow; since: number; shown: HighCommandEntry[]; hidden: number; fresh: number; page: number; pages: number; identityPage?: number; reasonSeq?: number } +/** `moved`: the entries that moved to the next page because this one ended at its budget, up to LIMITS.threadEntries. */ +export type Thread = { row: RosterRow; since: number; shown: HighCommandEntry[]; hidden: number; fresh: number; page: number; pages: number; moved: number; identityPage?: number; reasonSeq?: number } /** The seq of the newest entry, or 0 for an empty list. The window never drops its newest entry. */ export function newestSeq(list: readonly HighCommandEntry[]): number { @@ -320,7 +321,8 @@ export function threadOf(entries: readonly HighCommandEntry[], rows: readonly Ro const groups: HighCommandEntry[][] = [[]] let size = 0 for (const entry of all) { - const cost = JSON.stringify([entry.body, entry.summary]).length + 2000 + // Each text the entry can draw, Send details open, serialized; 2,000 more for its lines, Buttons, and structure. + const cost = JSON.stringify([entry.body, entry.summary, entry.delivery?.isDelivered === false ? entry.delivery.reason : '', entry.refusal?.body, entry.core?.to, entry.core?.body, entry.core?.reason]).length + 2000 if (groups.at(-1)!.length >= LIMITS.threadEntries || size + cost > 60_000) { groups.push([]) size = 0 @@ -332,7 +334,9 @@ export function threadOf(entries: readonly HighCommandEntry[], rows: readonly Ro const page = Math.max(0, Math.min(open.page ?? 0, pages - 1)) const shown = groups[page]! const fresh = all.filter(entry => isNewer(entry, open.since)).length - return { row, since: open.since, shown, hidden: all.length - shown.length, fresh, page, pages, identityPage: open.identityPage, reasonSeq: open.reasonSeq } + const later = groups.slice(page + 1).reduce((sum, group) => sum + group.length, 0) + const moved = later > 0 ? Math.min(LIMITS.threadEntries - shown.length, later) : 0 + return { row, since: open.since, shown, hidden: all.length - shown.length, fresh, page, pages, moved, identityPage: open.identityPage, reasonSeq: open.reasonSeq } } /** diff --git a/hooks/limits.ts b/hooks/limits.ts index b6435d1..452d49f 100644 --- a/hooks/limits.ts +++ b/hooks/limits.ts @@ -23,6 +23,8 @@ export const LIMITS = { stateChars: 24, /** Peer rows on one roster page. */ rosterRows: 50, + /** Agent lane Buttons on one lane page of the pane. */ + laneButtons: 10, /** Entries drawn in one thread. */ threadEntries: 20, /** diff --git a/hooks/register.tsx b/hooks/register.tsx index 213e7d5..ad8bcc1 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -27,8 +27,8 @@ import { } from './inbox' import { parseListing, parseStructuredListing } from './listing' import { summaryText } from './summary' -import { count, sanitize, peerKey, peerLabels, unreadText } from './text' -import { bandTree, compactBandTree, fitsBounds, paneTree, type BandActions, type PaneActions } from './views' +import { count, sanitize, peerKey, peerKeys, peerLabels, unreadText } from './text' +import { bandTree, compactBandTree, fitsBounds, laneIdsOf, laneIdsShown, paneTree, rosterPageOf, type BandActions, type PaneActions } from './views' const DESCRIPTION = 'High Command: messages from teammates and other sessions (/highcommand open shows the pane)' const PANE = { id: 'high-command', title: 'High Command', focus: true } as const @@ -55,6 +55,7 @@ const lanes = atom(LANES, {}) const hiddenLanes = atom({ plugin: 'high-command', key: 'hiddenLanes' } as const, {}) const selectedLane = atom({ plugin: 'high-command', key: 'selectedLane' } as const, null) const lanePage = atom({ plugin: 'high-command', key: 'lanePage' } as const, 0) +const agentStatus = atom({ plugin: 'high-command', key: 'agentStatus' } as const, false) const rosterPage = atom({ plugin: 'high-command', key: 'rosterPage' } as const, 0) const agents = atom({ plugin: 'high-command', key: 'agents' } as const, {}) const capture = atom({ plugin: 'high-command', key: 'capture' } as const, null) @@ -316,6 +317,45 @@ function readAll($: EngineInterface, withLanes: boolean): Promise { }) } +/** + * Moves the pane's focus ring onto the element `key` after a view change, so the person keeps their place. The keys + * are the person's to give: a site that does not hold them refuses, and the view change stands. + */ +async function focusOn($: EngineInterface, key: string | undefined): Promise { + if (key === undefined) return + try { + await $.ui.focus({ requestId: PANE.id, key }) + } catch { + // Refused: the person holds the keys elsewhere, or the element is gone. Nothing to report. + } +} + +/** + * Shows the roster page that draws `peer`'s row and returns that row's key. The thread can move the row to another + * page: opening it marks the peer read, which sorts it behind the unread peers. Without the row (its messages left the + * window), the page stays and there is no key. + */ +async function showPeerRow($: EngineInterface, laneId: string | undefined, peer: string): Promise { + const { rows } = await readRoster($, laneId) + const page = rosterPageOf(rows, peer) + if (page === undefined) return undefined + if (laneId === undefined) await update($, rosterPage, () => page) + else await changeLane($, laneId, lane => ({ ...lane, rosterPage: page })) + return peerKeys(rows.map(row => row.peer)).get(peer) +} + +/** Whether the lane page shown draws the agent `id`'s lane Button. */ +async function isLaneShown($: EngineInterface, id: string): Promise { + const all = Object.values(await read($, agents)) + return laneIdsShown(laneIdsOf(Object.keys(await read($, lanes)), all), await read($, lanePage), all).includes(id) +} + +/** Where the ring goes once the lane `id` ('' for the main inbox) shows: Back of its open thread, else `key`. */ +async function laneFocus($: EngineInterface, id: string, key: string): Promise { + const open = id === '' ? await read($, openThread) : (await readLane($, id)).openThread + return open === null ? key : 'pane:back' +} + function paneActions($: EngineInterface, withLanes: boolean, laneId?: string): PaneActions { /** Any change of a lane can evict lane messages (fitLanes); with agentLanesInBand, the status line then counts again. */ const thenSync = (work: () => Promise) => async (): Promise => { @@ -329,19 +369,47 @@ function paneActions($: EngineInterface, withLanes: boolean, laneId?: string): P const from = newestSeq(lane?.entries ?? await read($, entries)) await changeThread($, laneId, () => ({ peer, since: markOf(marks, peer), from })) await markPeersRead($, peer, laneId, withLanes) + await focusOn($, 'pane:back') }), - back: () => press($, 'back', thenSync(() => changeThread($, laneId, () => null))), + // Up one level, to the roster page that draws the peer that was open, with the ring on its row. + back: () => press($, 'back', thenSync(async () => { + const closed: { peer?: string } = {} + await changeThread($, laneId, open => { + closed.peer = open?.peer + return null + }) + if (closed.peer !== undefined) await focusOn($, await showPeerRow($, laneId, closed.peer)) + })), markRead: peer => press($, 'mark read', () => markPeersRead($, peer, laneId, withLanes)), readAll: () => press($, 'read all', () => markPeersRead($, undefined, laneId, withLanes)), page: page => press($, 'page', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, page }))), - identity: identityPage => press($, 'identity', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, identityPage }))), + // Opening the Identity level puts the ring on its Back; closing it puts the ring back on Show identity. + identity: identityPage => press($, 'identity', thenSync(async () => { + const before: { wasOpen?: boolean } = {} + await changeThread($, laneId, open => { + before.wasOpen = open?.identityPage !== undefined + return open === null ? null : { ...open, identityPage } + }) + if (identityPage === undefined) await focusOn($, 'pane:identity') + else if (before.wasOpen === false) await focusOn($, 'pane:back') + })), reason: reasonSeq => press($, 'send details', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, reasonSeq: open.reasonSeq === reasonSeq ? undefined : reasonSeq }))), - lane: id => press($, 'select lane', () => update($, selectedLane, () => id)), + lane: id => press($, 'select lane', async () => { + await update($, selectedLane, () => id) + await focusOn($, await laneFocus($, id, id === '' ? 'lane:main' : `lane:${id}`)) + }), + // Up one level from an agent lane, to the main inbox, with the ring on the Button of the lane that was open. + leaveLane: () => press($, 'back', async () => { + await update($, selectedLane, () => '') + const key = laneId !== undefined && await isLaneShown($, laneId) ? `lane:${laneId}` : 'lane:main' + await focusOn($, await laneFocus($, '', key)) + }), rosterPage: page => press($, 'peer page', thenSync(async () => { if (laneId === undefined) await update($, rosterPage, () => page) else await changeLane($, laneId, lane => ({ ...lane, rosterPage: page })) })), lanePage: page => press($, 'agent page', () => update($, lanePage, () => page)), + agentStatus: () => press($, 'agent status', () => update($, agentStatus, open => !open)), } } @@ -532,6 +600,7 @@ export const register: Register = (on, options) => { laneIds: Object.keys(all), agents: Object.values(await read($, agents)), laneUnread: new Map(Object.entries(all).map(([id, lane]) => [id, unreadTotal(rosterOf(lane.entries, {}, lane.readThrough))])), capture: await read($, capture), evicted: list.at(-1)?.evictedTotal ?? 0, + columns: e.props.bodyColumns, statusOpen: await read($, agentStatus), }, paneActions($, withLanes, laneId)) } catch (error) { const { Text } = ui diff --git a/hooks/text.ts b/hooks/text.ts index b7b8b3a..f742281 100644 --- a/hooks/text.ts +++ b/hooks/text.ts @@ -36,7 +36,7 @@ function oneLine(text: string): string { } /** The first line that holds a non-space character, as one line; '' when there is none. */ -function firstLine(text: string): string { +export function firstLine(text: string): string { const line = text.split('\n').find(part => part.trim() !== '') return line === undefined ? '' : oneLine(line) } @@ -73,16 +73,28 @@ function twoDigits(value: number): string { return String(value).padStart(2, '0') } +/** `HH:MM` in local time. */ +export function timeLabel(at: number): string { + const time = new Date(at) + return `${twoDigits(time.getHours())}:${twoDigits(time.getMinutes())}` +} + +/** The local day, as in `Oct 4`. */ +export function dayLabel(at: number): string { + const time = new Date(at) + return `${MONTHS[time.getMonth()]} ${time.getDate()}` +} + +/** Whether two times fall on one local day. */ +export function isSameDay(at: number, other: number): boolean { + const one = new Date(at) + const two = new Date(other) + return one.getFullYear() === two.getFullYear() && one.getMonth() === two.getMonth() && one.getDate() === two.getDate() +} + /** `HH:MM` on the local day of `now`, else `Mon D HH:MM`. */ export function clockLabel(at: number, now: number): string { - const time = new Date(at) - const today = new Date(now) - const hhmm = `${twoDigits(time.getHours())}:${twoDigits(time.getMinutes())}` - const isToday = - time.getFullYear() === today.getFullYear() && - time.getMonth() === today.getMonth() && - time.getDate() === today.getDate() - return isToday ? hhmm : `${MONTHS[time.getMonth()]} ${time.getDate()} ${hhmm}` + return isSameDay(at, now) ? timeLabel(at) : `${dayLabel(at)} ${timeLabel(at)}` } /** A state with the clock time it was reported, as in `idle (12:04)`; undefined without a state. */ @@ -90,6 +102,19 @@ export function stateLabel(state: HighCommandState | undefined, now: number): st return state === undefined ? undefined : `${state.word} (${clockLabel(state.at, now)}${state.source ? `; ${state.source}; last reported` : ''})` } +/** + * A state as the roster shows it, as in `idle at 12:04`, whole: the roster row cuts it only when the row is full. + * Undefined without a state. The thread shows its source (threadState). + */ +export function rosterState(state: HighCommandState | undefined, now: number): string | undefined { + return state === undefined ? undefined : `${state.word} at ${clockLabel(state.at, now)}` +} + +/** A state as a thread shows it, as in `State idle at 12:04 · peer notice · last reported`; the source only when known. */ +export function threadState(state: HighCommandState, now: number): string { + return `State ${state.word} at ${clockLabel(state.at, now)}${state.source ? ` · ${state.source} · last reported` : ''}` +} + /** * Whether the text holds BASE64_RUN or more base64 characters in a row that mix upper case, lower case, * and digits, as encoded data does; a run of one letter or of hex digits is not a block. One pass. @@ -144,6 +169,9 @@ export function unreadText(main: number, lanes = 0): string { /** The dim hint at the end of the band's row: the keys that give the band the keyboard. */ export const BAND_KEY_HINT = 'ctrl+x tab to choose' +/** The dim hint at the end of the pane's location row: how to give the pane the keys, and how to give them back. */ +export const PANE_KEY_HINT = 'ctrl+x tab or click for keys · esc to prompt' + /** * Code points a terminal can draw two cells wide, in broad ranges: Hangul, CJK, full-width forms, the emoji of the * symbol blocks, and every code point past U+FFFF. @@ -174,16 +202,107 @@ export function peerKey(name: string): string { return oneLine(sanitize(name).replace(FORMAT, '')) } +/** The 32-bit FNV-1a hash of a text's UTF-16 units, as 8 hexadecimal digits. */ +function fnv1a(text: string): string { + let hash = 2166136261 + for (let index = 0; index < text.length; index++) hash = Math.imul(hash ^ text.charCodeAt(index), 16777619) + return (hash >>> 0).toString(16).padStart(8, '0') +} + /** A display label for a normalized peer identity, clipped without changing its state key. */ export function peerLabel(peer: string): string { - if (peer.length <= LIMITS.nameChars) return peer - let hash = 2166136261 - for (let index = 0; index < peer.length; index++) hash = Math.imul(hash ^ peer.charCodeAt(index), 16777619) - return `${clip(peer, LIMITS.nameChars - 11)} [${(hash >>> 0).toString(16).padStart(8, '0')}]` + return peer.length <= LIMITS.nameChars ? peer : `${clip(peer, LIMITS.nameChars - 11)} [${fnv1a(peer)}]` +} + +/** + * The text's start and end around `…`, in at most `max` cells (cellWidth): the start takes the odd cell. Look-alike + * names mostly differ at their end, so this keeps both the shared start and the part that tells them apart. + */ +function clipMiddle(text: string, max: number): string { + const chars = [...text] + let head = '' + let headCells = 0 + for (const char of chars) { + if (headCells + cellWidth(char) > Math.ceil((max - 1) / 2)) break + head += char + headCells += cellWidth(char) + } + let tail = '' + let tailCells = 0 + for (const char of chars.reverse()) { + if (tailCells + cellWidth(char) > max - 1 - headCells) break + tail = char + tail + tailCells += cellWidth(char) + } + return `${head}…${tail}` } -/** Resolves even hash collisions across the current retained roster. */ -export function peerLabels(peers: readonly string[]): Map { +/** The text whole when it takes at most `max` cells (cellWidth), else its start and end around `…` (clipMiddle). */ +function cutCells(text: string, max: number): string { + return cellWidth(text) <= max ? text : clipMiddle(text, max) +} + +/** Hexadecimal digits of the code that tells look-alike cut labels apart. */ +const CODE_DIGITS = 4 + +/** + * A peer's label in `cells` cells, at one of two steps, each from the name alone. Step 0: the name, or its start and end + * when it is wider. Step 1: a shorter cut and the first CODE_DIGITS digits of the name's FNV-1a hash, as in + * `rele…west [3f9a]`. + */ +function cellLabel(peer: string, cells: number, step: number): string { + return step === 0 ? cutCells(peer, cells) : `${cutCells(peer, cells - CODE_DIGITS - 3)} [${fnv1a(peer).slice(0, CODE_DIGITS)}]` +} + +/** + * Labels in `cells` cells, at least 12, for the fixed roster columns. A peer shows its step 0 label (cellLabel) while + * no other peer shows the same text, else its step 1 label, so a label passes to no other peer. A step 0 label that + * reads as another peer's step 1 label also moves, so a name cannot copy a coded label. Peers whose step 1 labels still + * match, which takes equal cuts and an equal code, are numbered in name order, apart from every other label. + */ +function cellLabels(peers: readonly string[], cells: number): Map { + const steps = new Map(peers.map(peer => [peer, 0])) + const labelOf = (peer: string) => cellLabel(peer, cells, steps.get(peer)!) + for (let isMoved = true; isMoved;) { + isMoved = false + const counts = new Map() + for (const peer of peers) counts.set(labelOf(peer), (counts.get(labelOf(peer)) ?? 0) + 1) + for (const peer of peers) { + if (steps.get(peer) === 0 && counts.get(labelOf(peer))! > 1) { + steps.set(peer, 1) + isMoved = true + } + } + } + const groups = new Map() + for (const peer of [...peers].sort()) groups.set(labelOf(peer), [...(groups.get(labelOf(peer)) ?? []), peer]) + const result = new Map() + const reserved = new Set(groups.keys()) + for (const [label, group] of groups) { + if (group.length === 1) { + result.set(group[0]!, label) + continue + } + let index = 0 + for (const peer of group) { + let resolved: string + do { + index += 1 + resolved = `${cutCells(peer, cells - 3 - String(index).length)} [${index}]` + } while (reserved.has(resolved)) + reserved.add(resolved) + result.set(peer, resolved) + } + } + return new Map(peers.map(peer => [peer, result.get(peer)!])) +} + +/** + * Resolves even hash collisions across the current retained roster. With `cells`, labels fit that many cells + * (cellLabels). + */ +export function peerLabels(peers: readonly string[], cells?: number): Map { + if (cells !== undefined) return cellLabels([...new Set(peers)], cells) const groups = new Map() for (const peer of new Set(peers)) { const label = peerLabel(peer) @@ -206,6 +325,22 @@ export function peerLabels(peers: readonly string[]): Map { return result } +/** + * Button keys for peer rows that stay with the peer when the roster order changes: `peer:` and the FNV-1a hash of the + * full identity, with `-2`, `-3`, ... for the later names, in name order, that share a hash. + */ +export function peerKeys(peers: readonly string[]): Map { + const keys = new Map() + const taken = new Map() + for (const peer of [...new Set(peers)].sort()) { + const hash = fnv1a(peer) + const count = (taken.get(hash) ?? 0) + 1 + taken.set(hash, count) + keys.set(peer, count === 1 ? `peer:${hash}` : `peer:${hash}-${count}`) + } + return keys +} + /** A conversation-local marker, preserved across module reloads. */ export function captureLabel(capture: { at: number; reset: boolean }, now: number): string { return `Capture window: ${clockLabel(capture.at, now)} · current conversation only. ${capture.reset ? 'Previous High Command history was not restored.' : 'Earlier history is not included.'}` diff --git a/hooks/views.tsx b/hooks/views.tsx index 77cc57c..03f0172 100644 --- a/hooks/views.tsx +++ b/hooks/views.tsx @@ -1,9 +1,28 @@ import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, TextProps } from 'claude-code' -import type { HighCommandEntry, HighCommandAgent, HighCommandCapture } from '../types' +import type { HighCommandAgent, HighCommandCapture, HighCommandEntry, HighCommandTrust } from '../types' import { isNewer, labelOf, unreadTotal, type RosterRow, type Thread } from './inbox' import { LIMITS } from './limits' -import { BAND_KEY_HINT, captureLabel, cellWidth, identityPart, clockLabel, count, peerLabel, peerLabels, stateLabel, trustLabel, unreadText } from './text' +import { + BAND_KEY_HINT, + PANE_KEY_HINT, + cellWidth, + clockLabel, + count, + dayLabel, + firstLine, + identityPart, + isSameDay, + lineOf, + peerKeys, + peerLabel, + peerLabels, + rosterState, + threadState, + timeLabel, + trustLabel, + unreadText, +} from './text' /** The elements High Command draws with; every surface's table has them. */ type HighCommandElements = { @@ -12,13 +31,17 @@ type HighCommandElements = { Button: ElementConstructor } -/** What the pane draws: the roster, or the open thread. */ +/** What the pane draws: a roster (the main inbox or an agent lane), a thread, or a peer's identity. */ type PaneModel = { rows: RosterRow[]; now: number; thread: Thread | undefined evicted?: number; capture?: HighCommandCapture | null rosterPage?: number; laneId?: string; laneIds?: string[]; lanePage?: number; agents?: HighCommandAgent[] /** Each agent lane's unread count, by agent id. */ laneUnread?: ReadonlyMap + /** The body's cells across (Pane.bodyColumns). Without it, roster rows take two lines and no key hint shows. */ + columns?: number + /** Whether the Agent status block is open. */ + statusOpen?: boolean } /** @@ -27,138 +50,677 @@ type PaneModel = { */ export type PaneActions = { openThread: (peer: string) => Promise + /** Back from a thread to the roster page that draws its peer. */ back: () => Promise markRead: (peer: string) => Promise readAll: () => Promise page: (page: number) => Promise + /** Opens the Identity view at a part, or closes it (undefined). */ identity: (page: number | undefined) => Promise reason: (seq: number) => Promise + /** Shows the main inbox ('') or an agent's lane. */ lane: (id: string) => Promise + /** Back from an agent lane's roster to the main inbox. */ + leaveLane: () => Promise lanePage: (page: number) => Promise rosterPage: (page: number) => Promise + /** Opens or closes the Agent status block. */ + agentStatus: () => Promise } export type BandActions = { open: () => Promise; readAll: () => Promise; hide: () => Promise } +const ORDER_NOTE = 'Order: observation time. Counts cover retained messages.' +const LANE_NOTE = 'Split-pane traffic is not shown. Sent and received messages are observed separately.' +const LIVENESS_NOTE = 'Roster snapshots do not confirm liveness.' +const SEND_NOTE = 'Sends show observed text; a reported result is not a receipt.' +const CORE_NOTE = 'Core evidence covers only the latest traced call, never reading or every send.' +const NOT_DELIVERED = '(not delivered)' + +/** Body cells from which the roster draws fixed columns; below it, each roster row takes two lines. */ +const COLUMNS_FROM = 90 +/** + * The fixed roster columns: gutter and peer, New, Time, Sender, and Last state at its least; Last message takes the + * rest. Last state widens to the longest state on the page while Last message keeps MESSAGE_CELLS. + */ +const CELLS = { peer: 19, fresh: 3, time: 6, sender: 10, state: 13 } as const +/** + * Cells Last message keeps at least when Last state widens: both warning marks, which start it, and the cell that a + * cut at its end draws as `…`, so no cut reaches a mark. + */ +const MESSAGE_CELLS = cellWidth(`suspicious ${NOT_DELIVERED}`) + 1 +/** Cells the latest message keeps at least beside the marks on line 2 of a two-line row; with fewer, it takes line 3. */ +const PREVIEW_CELLS = 20 +/** Cells of a peer label in the fixed columns. */ +const LABEL_CELLS = 16 +/** The cells of `1: ` that a digit hotkey draws before a plain Button's label, and the gutter of rows without one. */ +const GUTTER = 3 + /** The defined parts, joined with ` · `. */ function joined(parts: readonly (string | undefined)[]): string { return parts.filter(part => part !== undefined).join(' · ') } -/** Labels send attempts and inbound results or failures before their summary. */ -function kindPrefix(entry: HighCommandEntry): string { - if (entry.direction === 'out') return 'send attempted: ' - const { word } = labelOf(entry) - return word !== 'message' ? `${word}: ` : '' +/** The kept parts, each one or more inline nodes, with ` · ` between two. */ +function dotted(parts: readonly (readonly (RenderElement | string)[] | false | undefined)[]): (RenderElement | string)[] { + return parts.filter(part => part !== false && part !== undefined).flatMap((part, index) => (index === 0 ? [...part] : [' · ', ...part])) +} + +/** The cells a plain Button with a hotkey draws: `h: label`. */ +function plainCells(label: string): number { + return GUTTER + cellWidth(label) +} + +/** The cells a Button with chrome draws on the terminal: `[ label ]`. */ +function chromeCells(label: string): number { + return 4 + cellWidth(label) +} + +/** + * The engine refuses a drawing past 100,000 characters serialized and draws its own in place of the pane, so a roster + * view keeps to about 90,000. Its peer rows take at most ROWS_BUDGET, the agents of its lane page at most LANES_BUDGET, + * and the rest of the view less than the 8,000 left. A thread page keeps its own budget (threadOf). A page ends before + * the row that would pass its budget and says how many rows moved to the next page. + */ +const ROWS_BUDGET = 70_000 +const LANES_BUDGET = 12_000 +const MOVED_NOTE = 'moved to the next page to keep the pane within its drawing limit.' + +/** Characters of a tree serialized as the band's bounds check counts them: each handler as 256. */ +function serialized(tree: unknown): number { + return JSON.stringify(tree, (_key, value: unknown) => typeof value === 'function' ? 'handler'.padEnd(256, '_') : value).length +} + +/** A Box's or a Text's children as a drawing holds them: one flat list, without the booleans and empty values. */ +function flatChildren(node: unknown): unknown[] { + return Array.isArray(node) ? node.flatMap(flatChildren) : node === false || node === true || node === null || node === undefined ? [] : [node] +} + +/** + * Elements as a drawing serializes them, each Button with a press handle of the longest number in place of its + * handler: the budgets measure rows with these, outside any render, and so in every place that pages. + */ +const MEASURE: HighCommandElements = { + Box: ({ children, ...props }) => ({ type: 'Box', props, children: flatChildren(children) }) as unknown as RenderElement, + Text: ({ children, ...props }) => ({ type: 'Text', props, children: flatChildren(children) }) as unknown as RenderElement, + Button: ({ onPress: _onPress, ...props }) => ({ type: 'Button', props, press: { plugin: 'high-command', handle: Number.MAX_SAFE_INTEGER } }) as unknown as RenderElement, +} + +/** The first item of each page: at most `size` items, whose costs stay within `budget`; a page holds at least one. */ +function pageStarts(costs: readonly number[], size: number, budget: number): number[] { + const starts = [0] + let used = 0 + costs.forEach((cost, index) => { + const start = starts.at(-1)! + if (index > start && (index - start >= size || used + cost > budget)) { + starts.push(index) + used = 0 + } + used += cost + }) + return starts } -/** The sender label and the suspicious mark come before the long state, so a narrow pane cuts the state first. */ -function rowText(row: RosterRow, now: number): string { - const { arrow } = labelOf(row.last) - return joined([ - row.unread > 0 ? `${row.unread} new` : undefined, - trustLabel(row.trust), - row.isSuspicious ? 'suspicious' : undefined, - clockLabel(row.last.at, now), - stateLabel(row.state, now), - `${arrow} ${kindPrefix(row.last)}${row.last.summary}${row.last.delivery?.isDelivered === false ? ' (not delivered)' : ''}`, +/** + * The page shown of `length` items cut at `starts`, when `page` was asked: within the pages there are, with the + * bounds of its items and how many of them moved on, up to `size`, because the page ended at its budget. + */ +function pageIn(starts: readonly number[], length: number, size: number, page: number | undefined): { page: number; pages: number; from: number; to: number; moved: number } { + const shown = Math.max(0, Math.min(page ?? 0, starts.length - 1)) + const from = starts[shown]! + const to = starts[shown + 1] ?? length + return { page: shown, pages: starts.length, from, to, moved: to < length ? Math.min(size - (to - from), length - to) : 0 } +} + +/** The dim note under a page that ended at its budget: how many `noun`s moved to the next page. */ +function movedLine(Text: HighCommandElements['Text'], moved: number, noun: string): RenderElement | false { + return moved > 0 && {`${count(moved, noun)} ${MOVED_NOTE}`} +} + +/** + * The first row of each roster page: at most LIMITS.rosterRows rows, within ROWS_BUDGET. A row costs the most it can + * draw in either layout, at any place on the page and at any time, so the pages stay the same at every width. + */ +function rosterStarts(rows: readonly RosterRow[]): number[] { + const peers = rows.map(row => row.peer) + const [narrow, wide, keys] = [peerLabels(peers), peerLabels(peers, LABEL_CELLS), peerKeys(peers)] + const open = async () => {} + const costs = rows.map(row => { + // A time of another day draws the longest clock labels; a row past the ninth draws its gutter Box. + const args = { row, index: LIMITS.hotkeys, key: keys.get(row.peer)!, now: 0, open } + return Math.max( + serialized(twoLineRow(MEASURE, { ...args, label: narrow.get(row.peer)! }, undefined)), + serialized(twoLineRow(MEASURE, { ...args, label: narrow.get(row.peer)! }, COLUMNS_FROM - 1)), + serialized(columnsRow(MEASURE, { ...args, label: wide.get(row.peer)! }, CELLS.state)), + ) + }) + return pageStarts(costs, LIMITS.rosterRows, ROWS_BUDGET) +} + +/** The roster page shown when `page` was asked. */ +function rosterPageIn(rows: readonly RosterRow[], page: number | undefined) { + return pageIn(rosterStarts(rows), rows.length, LIMITS.rosterRows, page) +} + +/** The roster page that draws `peer`'s row; undefined when the roster has no row for it. */ +export function rosterPageOf(rows: readonly RosterRow[], peer: string): number | undefined { + const index = rows.findIndex(row => row.peer === peer) + return index < 0 ? undefined : rosterStarts(rows).findLastIndex(start => start <= index) +} + +/** Every agent the pane can show a lane for, in id order: each local agent and each agent with a lane. */ +export function laneIdsOf(laneIds: readonly string[], agents: readonly HighCommandAgent[]): string[] { + return [...new Set([...laneIds, ...agents.map(agent => agent.id)])].sort() +} + +/** + * The lane page shown when `lanePage` was asked: at most LIMITS.laneButtons agents, within LANES_BUDGET. An agent costs + * its lane Button at its longest and its status line, so the pages stay the same whatever is open or unread. + */ +function lanePageIn(ids: readonly string[], lanePage: number | undefined, agents: readonly HighCommandAgent[]) { + const known = new Map(agents.map(agent => [agent.id, agent])) + const { Button, Text } = MEASURE + const costs = ids.map(id => { + const agent = known.get(id) + const label = `▸ ${peerLabel(nameOf(agent) ?? id)} (${peerLabel(id)}) · ${Number.MAX_SAFE_INTEGER} unread` + const status = agent === undefined ? '' : statusLine(agent, id, 0) + return serialized(