diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index b969111..ba9f05e 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "high-command",
- "version": "1.1.1",
+ "version": "1.1.2",
"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 c078fb2..9908cd7 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-
+
One inbox for agent-team teammates, named subagents, and other Claude Code sessions.
@@ -128,6 +128,8 @@ claude plugin update high-command@high-command --json
| Key | Where | Action |
| --- | --- | --- |
| Ctrl+X, then Tab | Anywhere | Move the keyboard to the band or the pane. This is Claude Code's own focus chord. |
+| Tab | Band or pane | Move to the next button |
+| Enter | Band or pane | Press the button that has focus |
| `o` | Band | Open the pane |
| `r` | Band or pane roster | Mark every message read |
| `h` | Band | Hide the band until a newer message arrives |
@@ -136,6 +138,8 @@ claude plugin update high-command@high-command --json
| `r` | Pane thread | Mark that peer read, only while **Mark read** shows |
| Esc | Band or pane | Give the keyboard back to the 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).
Letter and digit keys work only while the band or the pane has the keyboard. **Open** in the band opens the pane but leaves the keyboard on the band, and focus passes each band button before it reaches the pane. Press Ctrl+X, then Tab, until the pane border shows focus, or click the pane, before you press a digit. Each letter and digit key also has a button you can click. The full guide is [docs/usage.md](docs/usage.md).
diff --git a/docs/usage.md b/docs/usage.md
index a7a5c6b..d028dff 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -61,7 +61,7 @@ Both ways to open the pane show the main inbox, even while an agent transcript i
## The band
-While unread messages wait, the band shows the total and up to three peers, the most recent first. **Open** shows the first page of the main inbox roster, even while an agent transcript is visible. The pane opens without the keyboard, because the band holds it: press Ctrl+X, then Tab, until its border is focused, or click the pane to give it the keys. **Read all** clears every unread count. **Hide** puts the band away until a newer message arrives; it marks nothing read, and the status line keeps the count. The band gives way to Claude Code's surveys.
+While unread messages wait, the band shows the total and up to three peers, the most recent first. When its row has room, the row 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. **Open** shows the first page of the main inbox roster, even while an agent transcript is visible. The pane opens without the keyboard, because the band holds it: press Ctrl+X, then Tab, until its border is focused, or click the pane to give it the keys. **Read all** clears every unread count. **Hide** puts the band away until a newer message arrives; it marks nothing read, and the status line keeps the count. The band gives way to Claude Code's surveys.
By default, the band counts the main inbox only. With the `agentLanesInBand` [option](#options) on, it also shows the unread count of all agent lanes, as in `High Command · 3 unread (retained) · 2 unread in agent lanes`, and it also shows while only agent lanes have unread messages. **Read all** then also marks every agent lane read. **Hide** then holds until a newer message arrives in the main inbox or in an agent lane, and the band comes back with both counts. **Open** still shows the main inbox: press an agent's button in the pane to read its lane.
@@ -70,6 +70,8 @@ By default, the band counts the main inbox only. With the `agentLanesInBand` [op
| Key | Where | Action |
| --- | --- | --- |
| Ctrl+X, then Tab | Anywhere | Move the keyboard to the band or the pane. This is Claude Code's own focus chord. |
+| Tab | Band or pane | Move to the next button |
+| Enter | Band or pane | Press the button that has focus |
| `o` | Band | Open the pane |
| `r` | Band or pane roster | Read all |
| `h` | Band | Hide the band until a newer message arrives |
@@ -121,7 +123,7 @@ The first list holds the limits you can hit in daily use. The second list holds
- **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.
-- **Band space**: when other mods draw a lot in the area above the prompt, the band 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.
+- **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/register.tsx b/hooks/register.tsx
index 2e5acf7..213e7d5 100644
--- a/hooks/register.tsx
+++ b/hooks/register.tsx
@@ -552,10 +552,22 @@ export const register: Register = (on, options) => {
const waiting = rows.filter(row => row.unread > 0)
const laneUnread = withLanes ? lanesUnread(all) : 0
if (waiting.length === 0 && laneUnread === 0) return below
- // Other mods draw `below`; when the band would push it past the engine's bounds, it yields.
- const band = bandTree($.ui.resolve(e), waiting, bandActions($, withLanes), below, peerLabels(rows.map(row => row.peer)), laneUnread)
- if (fitsBounds(band)) return band
- const compact = compactBandTree($.ui.resolve(e), waiting, bandActions($, withLanes), below, laneUnread)
- return fitsBounds(compact) ? compact : below
+ // Other mods draw `below`; when the band would push it past the engine's bounds, it drops the key hint, then
+ // keeps only the counts and Open, then yields.
+ const ui = $.ui.resolve(e)
+ const actions = bandActions($, withLanes)
+ const labels = peerLabels(rows.map(row => row.peer))
+ const columns = e.props.bodyColumns
+ const bands = [
+ () => bandTree(ui, waiting, actions, below, labels, laneUnread, columns),
+ () => bandTree(ui, waiting, actions, below, labels, laneUnread),
+ () => compactBandTree(ui, waiting, actions, below, laneUnread, columns),
+ () => compactBandTree(ui, waiting, actions, below, laneUnread),
+ ]
+ for (const draw of bands) {
+ const band = draw()
+ if (fitsBounds(band)) return band
+ }
+ return below
})
}
diff --git a/hooks/text.ts b/hooks/text.ts
index 71c9a35..b7b8b3a 100644
--- a/hooks/text.ts
+++ b/hooks/text.ts
@@ -141,6 +141,31 @@ export function unreadText(main: number, lanes = 0): string {
.filter(part => part !== undefined).join(' · ')
}
+/** 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'
+
+/**
+ * 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.
+ */
+const WIDE: readonly (readonly [number, number])[] = [
+ [0x1100, 0x115f], [0x231a, 0x2b55], [0x2e80, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3],
+ [0xf900, 0xfaff], [0xfe10, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x10000, 0x10ffff],
+]
+
+/**
+ * The terminal cells one line of text takes, counted high: a code point in WIDE counts two, any other one, so a
+ * row that fits by this count fits on the screen.
+ */
+export function cellWidth(text: string): number {
+ let cells = 0
+ for (const char of text) {
+ const code = char.codePointAt(0)!
+ cells += WIDE.some(([first, last]) => code >= first && code <= last) ? 2 : 1
+ }
+ return cells
+}
+
/**
* A full peer identity: one line without control or format characters
* (bidi controls, zero-width marks). Bracketed text belongs to the name.
diff --git a/hooks/views.tsx b/hooks/views.tsx
index 58755f2..77cc57c 100644
--- a/hooks/views.tsx
+++ b/hooks/views.tsx
@@ -3,7 +3,7 @@ import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, TextProp
import type { HighCommandEntry, HighCommandAgent, HighCommandCapture } from '../types'
import { isNewer, labelOf, unreadTotal, type RosterRow, type Thread } from './inbox'
import { LIMITS } from './limits'
-import { captureLabel, identityPart, clockLabel, count, peerLabel, peerLabels, stateLabel, trustLabel, unreadText } from './text'
+import { BAND_KEY_HINT, captureLabel, cellWidth, identityPart, clockLabel, count, peerLabel, peerLabels, stateLabel, trustLabel, unreadText } from './text'
/** The elements High Command draws with; every surface's table has them. */
type HighCommandElements = {
@@ -217,9 +217,23 @@ export function paneTree(ui: HighCommandElements, model: PaneModel, actions: Pan
}
+/** The terminal cells of a row of one text and Buttons drawn as `[ label ]`, with `gap` cells before each Button. */
+function rowCells(text: string, labels: readonly string[], gap: number): number {
+ return labels.reduce((cells, label) => cells + gap + cellWidth(label) + 4, cellWidth(text))
+}
+
+/**
+ * The dim key hint after the last Button of a row of `cells` cells, or false. It shows only when the row, a gap of
+ * one and the hint all fit the band's `columns`, so the row never wraps and keeps every Button; never without `columns`.
+ */
+function keyHint(Text: HighCommandElements['Text'], cells: number, columns: number | undefined): RenderElement | false {
+ return columns !== undefined && cells + 1 + cellWidth(BAND_KEY_HINT) <= columns && {BAND_KEY_HINT}
+}
+
/**
* The band row above what other mods draw there. `waiting` holds only rows with unread entries; `laneUnread` is the
- * unread count of all agent lanes, above zero only with agentLanesInBand on.
+ * unread count of all agent lanes, above zero only with agentLanesInBand on. `columns`, the band's width in cells,
+ * adds the key hint where the row has room for it.
*/
export function bandTree(
{ Box, Text, Button }: HighCommandElements,
@@ -228,29 +242,35 @@ export function bandTree(
below: RenderElement,
labels: Map,
laneUnread = 0,
+ columns?: number,
): RenderElement {
const named = waiting.slice(0, LIMITS.bandPeers).map(row => `${labels.get(row.peer)} ${row.unread}`)
const more = waiting.length > LIMITS.bandPeers ? [`+${waiting.length - LIMITS.bandPeers} more`] : []
const peers = [...named, ...more]
+ const summary = `High Command · ${unreadText(unreadTotal(waiting), laneUnread)}${peers.length === 0 ? '' : ` — ${peers.join(' · ')}`}`
return (
- {`High Command · ${unreadText(unreadTotal(waiting), laneUnread)}${peers.length === 0 ? '' : ` — ${peers.join(' · ')}`}`}
+ {summary}
+ {keyHint(Text, rowCells(summary, ['Open', 'Read all', 'Hide'], 1), columns)}
{below}
)
}
-/** Keeps only the counts and Open when the full band exceeds the shared budget. */
-export function compactBandTree({ Box, Text, Button }: HighCommandElements, waiting: readonly RosterRow[], actions: BandActions, below: RenderElement, laneUnread = 0): RenderElement {
+/** Keeps only the counts and Open when the full band exceeds the shared budget; `columns` as in bandTree. */
+export function compactBandTree({ Box, Text, Button }: HighCommandElements, waiting: readonly RosterRow[], actions: BandActions, below: RenderElement, laneUnread = 0, columns?: number): RenderElement {
+ const summary = `High Command · ${unreadText(unreadTotal(waiting), laneUnread)}`
+ const hint = keyHint(Text, rowCells(summary, ['Open'], 0), columns)
return
- {`High Command · ${unreadText(unreadTotal(waiting), laneUnread)}`}
+ {summary}
+ {hint && {hint}}
{below}