Post-attention follow-ups: All-sessions view fix, wiki-lint CI parity, terminal/attention wiki pages #199

Merged
clawdie merged 3 commits from chore/post-attention-followups into main 2026-06-25 22:58:41 +02:00
6 changed files with 370 additions and 74 deletions

View file

@ -39,6 +39,12 @@ jobs:
- uses: actions/checkout@v4
- name: Markdown format gate
run: ./scripts/check-format.sh
# Keep CI in parity with scripts/ci-checks.sh, which also runs wiki-lint.
# Pure POSIX sh (grep/awk/sed/find) — runs in the node:20 container.
# quality-gates.md claims CI encodes the same checks as local; this makes
# that true. (CI still only enforces once a Forgejo runner is registered.)
- name: Wiki lint (dangling refs, orphan pages, resurrected names)
run: ./scripts/wiki-lint --strict
port:
runs-on: ubuntu-latest

View file

@ -178,6 +178,23 @@ impl App {
}
}
/// Total items in the session cycle: the synthetic "All sessions" entry
/// plus one per real session id. Always >= 1 — "All" is always present.
fn session_count(&self) -> usize {
self.sessions.len() + 1
}
/// Map the current `session_idx` to a filter. Index 0 is the synthetic
/// "All sessions" aggregated view (filter = None); any other index scopes
/// to the session id at `index - 1`. Call after every `session_idx` change.
fn apply_session_filter(&mut self) {
self.session_filter = if self.session_idx == 0 {
None
} else {
self.sessions.get(self.session_idx - 1).cloned()
};
}
fn rebuild_session_list(&mut self) {
let snap = match &self.snapshot {
Some(s) => s,
@ -195,17 +212,15 @@ impl App {
.collect();
ids.sort();
ids.dedup();
if ids.is_empty() {
self.sessions.clear();
self.sessions = ids;
// The cycle is [All, s1, s2, ...] = sessions.len() + 1. Keep the
// previous selection when it still maps; otherwise fall back to "All".
// Default on first connect is "All sessions" (the aggregated view).
let count = self.session_count();
if self.session_idx >= count {
self.session_idx = 0;
self.session_filter = None;
} else {
self.sessions = ids;
if self.session_idx >= self.sessions.len() {
self.session_idx = self.sessions.len().saturating_sub(1);
}
self.session_filter = self.sessions.get(self.session_idx).cloned();
}
self.apply_session_filter();
}
async fn refresh(&mut self) {
@ -312,12 +327,12 @@ impl App {
Some(sid) => format!("Session: {sid}"),
None => "All sessions".to_string(),
};
let session_span = if self.sessions.len() > 1 {
let session_span = if self.session_count() > 1 {
Span::styled(
format!(
"{session_label} ({} of {})",
self.session_idx + 1,
self.sessions.len()
self.session_count()
),
Style::default().add_modifier(Modifier::BOLD),
)
@ -638,30 +653,24 @@ async fn run(socket_path: PathBuf) -> io::Result<()> {
}
}
KeyCode::Tab | KeyCode::Char('\t') if !app.sessions.is_empty() => {
app.session_idx = (app.session_idx + 1) % app.sessions.len();
app.session_filter = app.sessions.get(app.session_idx).cloned();
let count = app.session_count();
app.session_idx = (app.session_idx + 1) % count;
app.apply_session_filter();
app.table_state.select(Some(0));
app.detail_pane = None;
app.set_status(format!(
"session {}/{}",
app.session_idx + 1,
app.sessions.len()
));
app.set_status(format!("session {}/{}", app.session_idx + 1, count));
}
KeyCode::BackTab if !app.sessions.is_empty() => {
let count = app.session_count();
app.session_idx = if app.session_idx == 0 {
app.sessions.len() - 1
count - 1
} else {
app.session_idx - 1
};
app.session_filter = app.sessions.get(app.session_idx).cloned();
app.apply_session_filter();
app.table_state.select(Some(0));
app.detail_pane = None;
app.set_status(format!(
"session {}/{}",
app.session_idx + 1,
app.sessions.len()
));
app.set_status(format!("session {}/{}", app.session_idx + 1, count));
}
KeyCode::Down | KeyCode::Char('j') => {
let count = app.filtered_panes().len();
@ -846,8 +855,71 @@ mod tests {
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
app.snapshot = Some(snap);
app.rebuild_session_list();
// Real session ids are sorted + deduped (s1 appeared twice).
assert_eq!(app.sessions, vec!["s1", "s2"]);
// Default selection is "All sessions" (index 0) — the aggregated view
// stays reachable. Regression for the "All sessions unreachable" bug.
assert_eq!(app.session_idx, 0);
assert!(app.session_filter.is_none());
assert_eq!(app.session_count(), 3); // [All, s1, s2]
// Index 1 -> s1, index 2 -> s2.
app.session_idx = 1;
app.apply_session_filter();
assert_eq!(app.session_filter.as_deref(), Some("s1"));
app.session_idx = 2;
app.apply_session_filter();
assert_eq!(app.session_filter.as_deref(), Some("s2"));
}
#[test]
fn all_sessions_view_is_reachable_with_sessions_present() {
// Regression for the pre-existing bug documented in
// GLASSPANE-TUI-ENHANCEMENTS.md: once any pane had a session_id,
// rebuild_session_list() forced session_filter = Some(first), making
// the aggregated "All sessions" view unreachable. It must now default
// to All and stay selectable via Tab.
let snap = GlasspaneSnapshot::new(
"osa",
"2026-06-25T12:00:00Z",
vec![
colibri_glasspane::Pane {
id: "a".into(),
agent: "zot".into(),
state: AgentState::Working,
session_id: Some("s1".into()),
last_event_at: None,
cwd: None,
stalled: false,
},
colibri_glasspane::Pane {
id: "b".into(),
agent: "zot".into(),
state: AgentState::Working,
session_id: Some("s2".into()),
last_event_at: None,
cwd: None,
stalled: false,
},
],
);
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
app.snapshot = Some(snap);
app.rebuild_session_list();
// After connect, the operator lands on the aggregated view.
assert_eq!(app.session_idx, 0);
assert!(app.session_filter.is_none());
assert_eq!(app.filtered_panes().len(), 2);
// Tab cycles All -> s1 -> s2 -> All.
let count = app.session_count();
app.session_idx = (app.session_idx + 1) % count;
app.apply_session_filter();
assert_eq!(app.session_filter.as_deref(), Some("s1"));
app.session_idx = (app.session_idx + 1) % count;
app.apply_session_filter();
assert_eq!(app.session_filter.as_deref(), Some("s2"));
app.session_idx = (app.session_idx + 1) % count;
app.apply_session_filter();
assert!(app.session_filter.is_none(), "wrap back to All sessions");
}
// ── render tests (TestBackend) ──
@ -1341,7 +1413,7 @@ mod tests {
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
app.snapshot = Some(snap);
app.rebuild_session_list();
// Navigate to session s1 (rebuild selects first alphabetically = s1).
// rebuild now defaults to "All sessions"; force the view to session s1.
app.session_filter = Some("s1".into());
let text = render_text(&mut app, 80, 24);

View file

@ -18,17 +18,21 @@ Glasspane doesn't just relay raw agent events. It ingests JSONL lines and
transitions a **named pane** through a finite set of states:
```
Idle → Working → Done
Idle → Working → Blocked → Done
↳ Error
↳ Stalled (no events within a timeout window)
```
The `AgentState` enum (`Idle, Working, Done, Error, Stalled`) is deliberately
The `AgentState` enum (`Idle, Working, Blocked, Done, Error`) is deliberately
small. It captures what a supervisor needs to know — "is the agent working?
stuck? finished?" — without encoding agent-specific semantics. Events that don't
change the state (e.g. a usage report from zot) are recorded in the pane's
blocked? finished?" — without encoding agent-specific semantics. Events that
don't change the state (e.g. a usage report from zot) are recorded in the pane's
metadata but don't affect the state machine.
`Stalled` is **not** a sixth variant — it is a derived flag: a pane is stalled
when no event has arrived within `DEFAULT_STALL_AFTER` (4 hours). Derived
attention (Error / Blocked / Stalled) is covered by
[operator-attention](./operator-attention.md).
**Why not just tail the log**: raw event logs are agent-specific and change over
time (zot adds new event types). The state machine is a stable contract that the
daemon, TUI, and client CLI can all rely on.
@ -93,21 +97,11 @@ that's fundamentally about current state, not event delivery.
## Usability roadmap (TODO)
Glasspane has the supervision spine — a stable state machine and a snapshot API.
The open work is operator-facing: making *"does this agent need me right now?"*
the primary, impossible-to-miss object. These are captured as direction, not yet
built. The data mostly already exists; the work is surfacing and pushing it.
Ideas drawn from agent-cockpit terminal UIs; wiring is our own. Working notes,
reference shortcut vocabulary, and a draft keymap live in
[`../GLASSPANE-TUI-ENHANCEMENTS.md`](../GLASSPANE-TUI-ENHANCEMENTS.md).
### Attention as a first-class derived signal
Today a pane is `Idle/Working/Done/Error/Stalled`. Promote "needs the operator"
into an explicit **attention flag** derived from the existing states (`Error`,
`Stalled`, and a future "waiting for input") rather than a sixth state. The state
machine stays small; attention is a view over it. The TUI highlights attention
rows; the daemon and notifier read the same flag.
The **attention** half of this roadmap shipped: the derived attention
predicate, the TUI attention bar / jump keys / filter / row highlight, and
edge-triggered terminal-capture alerts. See
[operator-attention](./operator-attention.md) for the shipped system. What
remains here is the genuinely-unbuilt direction.
### Push notifications outbound, not just on-screen
@ -118,12 +112,6 @@ already provisioned). An explicit `colibri notify`-style path — or a glasspane
event type that a zot/Pi hook fires — lets an agent say "I'm blocked" rather than
relying only on inferred state. Highest real-world impact item.
### Jump-to-next-attention navigation in the TUI
A keybinding in `colibri-tui` that cycles to the next attention pane. Trivial
given the attention flag; large ergonomic win when supervising many agents.
`crates/colibri-glasspane-tui/src/main.rs`
### Richer pane rows (context at a glance)
Glasspane already stashes non-state events in pane metadata. Surface that in the
@ -149,4 +137,5 @@ control and needs its own design pass.
## See also
- [agent-harness](./agent-harness.md) — the zot/Colibri split that Glasspane observes
- [operator-attention](./operator-attention.md) — the shipped attention/alert layer over this state machine
- [naming-decisions](./naming-decisions.md) — `pi_session_id → session_id`, `pi_type → event_type`

View file

@ -43,25 +43,27 @@ warning.
## Pages
| Page | What it covers |
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [agent-harness](./agent-harness.md) | The zot (agent) + Colibri (control plane) split; autospawn + RPC driver |
| [agent-events-reference](./agent-events-reference.md) | Per-harness zot event reference, Glasspane mappings, and verified transcript fields |
| [cost-model](./cost-model.md) | Byte-stable prefixes, cache-hit metering, auto-escalation, T14 compaction |
| [glasspane](./glasspane.md) | Agent state machine, JSONL streaming, AgentRuntime taxonomy, snapshot API |
| [headroom-sidecar](./headroom-sidecar.md) | Optional tool-result compression sidecar and its Unix-socket protocol |
| [jail-confinement](./jail-confinement.md) | Persistent vs ephemeral jails, priv-mode policy, reuse of spawner confinement for MCP servers |
| [mother-hive](./mother-hive.md) | Mother MCP architecture — forced-command SSH, single-home-in-colibri, peer auth, key-on-seed |
| [naming-decisions](./naming-decisions.md) | Ledger of harness-neutral / architecture renames — shipped and in-flight |
| [layered-soul](./layered-soul.md) | How Colibri consumes the layered-soul reviewed-context repo today vs planned |
| [task-board](./task-board.md) | Capability match scoring, cron scheduling, intake drain, SQLite backing |
| [quality-gates](./quality-gates.md) | `ci-checks.sh` as the pre-merge gate; why drift reached `main` before |
| [contracts](./contracts.md) | Stable JSON schemas (run-manifest, runtime-inventory, provider-smoke), golden tests |
| [store-schema](./store-schema.md) | SQLite coordination schema and migration discipline |
| [external-mcp](./external-mcp.md) | MCP bridge for editors + external stdio MCP host; read/write/external-call gates |
| [operator-cli](./operator-cli.md) | The `colibri` CLI as a thin typed Unix-socket client over the daemon API |
| [tui](./tui.md) | Terminal dashboard client (colibri-tui) vs the colibri-glasspane state machine |
| [runtime-inventory](./runtime-inventory.md) | Host runtime inventory + watchdog status reader; additive, read-only integrations |
| [skills-catalog](./skills-catalog.md) | Read-only runtime consumer for reviewed Clawdie-AI skill artifacts |
| [vault-provision](./vault-provision.md) | Vaultwarden-driven env-file provisioning into jails after agent spawn |
| [deployment](./deployment.md) | Host installer (clawdie): ZFS layout, rc.d/systemd service, dry-run safety |
| Page | What it covers |
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| [agent-harness](./agent-harness.md) | The zot (agent) + Colibri (control plane) split; autospawn + RPC driver |
| [agent-events-reference](./agent-events-reference.md) | Per-harness zot event reference, Glasspane mappings, and verified transcript fields |
| [cost-model](./cost-model.md) | Byte-stable prefixes, cache-hit metering, auto-escalation, T14 compaction |
| [glasspane](./glasspane.md) | Agent state machine, JSONL streaming, AgentRuntime taxonomy, snapshot API |
| [operator-attention](./operator-attention.md) | The derived "needs the operator" view: attention predicate, TUI bar/jump/filter, edge-triggered terminal alerts |
| [headroom-sidecar](./headroom-sidecar.md) | Optional tool-result compression sidecar and its Unix-socket protocol |
| [jail-confinement](./jail-confinement.md) | Persistent vs ephemeral jails, priv-mode policy, reuse of spawner confinement for MCP servers |
| [mother-hive](./mother-hive.md) | Mother MCP architecture — forced-command SSH, single-home-in-colibri, peer auth, key-on-seed |
| [naming-decisions](./naming-decisions.md) | Ledger of harness-neutral / architecture renames — shipped and in-flight |
| [layered-soul](./layered-soul.md) | How Colibri consumes the layered-soul reviewed-context repo today vs planned |
| [task-board](./task-board.md) | Capability match scoring, cron scheduling, intake drain, SQLite backing |
| [quality-gates](./quality-gates.md) | `ci-checks.sh` as the pre-merge gate; why drift reached `main` before |
| [contracts](./contracts.md) | Stable JSON schemas (run-manifest, runtime-inventory, provider-smoke), golden tests |
| [store-schema](./store-schema.md) | SQLite coordination schema and migration discipline |
| [external-mcp](./external-mcp.md) | MCP bridge for editors + external stdio MCP host; read/write/external-call gates |
| [operator-cli](./operator-cli.md) | The `colibri` CLI as a thin typed Unix-socket client over the daemon API |
| [tui](./tui.md) | Terminal dashboard client (colibri-tui) vs the colibri-glasspane state machine |
| [terminal](./terminal.md) | Terminal capability decision (Kitty, extended-key reporting, tmux passthrough, SSH terminfo) |
| [runtime-inventory](./runtime-inventory.md) | Host runtime inventory + watchdog status reader; additive, read-only integrations |
| [skills-catalog](./skills-catalog.md) | Read-only runtime consumer for reviewed Clawdie-AI skill artifacts |
| [vault-provision](./vault-provision.md) | Vaultwarden-driven env-file provisioning into jails after agent spawn |
| [deployment](./deployment.md) | Host installer (clawdie): ZFS layout, rc.d/systemd service, dry-run safety |

View file

@ -0,0 +1,126 @@
# Operator attention — "does this agent need me right now?"
← [index](./index.md)
## What this is
Glasspane's supervision spine answers _"what state is this agent in?"_. Attention
is the layer on top that answers the question an operator actually asks first:
_"does any agent need me **right now**?"_ It is a **derived view** over the state
machine and the terminal, surfaced in the TUI and backed by edge-triggered
alerts — not a sixth state, not a new subsystem.
## Decisions
### Attention is a view, not a state
`AgentState` stays small (`Idle`, `Working`, `Blocked`, `Done`, `Error`).
"Needs the operator" is a free predicate over it, not another variant:
```rust
fn needs_attention(pane: &Pane) -> bool {
pane.state == AgentState::Error
|| pane.state == AgentState::Blocked
|| pane.stalled
}
```
`Blocked` is included because the state-machine doc comment says it means
"waiting on steering / approval / input" — i.e. the agent is parked on the
operator. One predicate, consumed by the attention bar, the filter, and the
jump keys, so the definition changes in one place.
`stalled` is itself derived — a pane is stalled when no event has arrived
within `DEFAULT_STALL_AFTER` (4 hours). It is rare on purpose: attention is
mostly Errors and Blocked panes; Stalled is the "something is deeply wrong"
escalation, not a frequent one.
→ [`crates/colibri-glasspane/src/lib.rs`](../../crates/colibri-glasspane/src/lib.rs)
(`AgentState`, `SupervisedPane::is_stalled_at`, `DEFAULT_STALL_AFTER`)
### The TUI makes attention impossible to miss
When any pane in the **current view** needs attention, the normal header is
replaced by a red-bordered attention bar listing the offending panes. Rows that
need attention get an inverted background; the cursor inverts again so the
operator can still see which one is selected. Two jump keys (`n` / `N`) cycle
forward/backward through attention panes with wrapping, and `a` toggles an
attention-only filter. All three operate over the already-filtered pane set.
**Filter composition is AND.** Attention filter composes with the session
filter, so the bar reflects only what the operator is looking at. A 2026-06
bug shipped where `has_attention` was computed from the _unfiltered_ snapshot:
an error in session `s2` lit the bar while viewing session `s1`, and the bar's
own `filtered_panes()` early-return then drew nothing — so the operator lost
their header to a blank red box. Fixed by computing attention from
`filtered_panes()`; covered by a cross-session render test.
→ [`crates/colibri-glasspane-tui/src/main.rs`](../../crates/colibri-glasspane-tui/src/main.rs)
(`needs_attention`, `render_attention_bar`, `attention_indices`)
### Terminal capture is the complementary signal
The state machine is _"what the agent says"_ (structured JSONL events).
Terminal capture is _"what the screen shows"_ — the actual text of a pane,
triaged for known-broken patterns. A pane can be `Working` while its screen
reads `Active: failed (Result: exit-code)`. Attention is a view over **both**.
A `TerminalRecorder` keeps a bounded frame history (`DEFAULT_HISTORY_CAPACITY`
= 256 frames). A frame's identity is the **SHA-256 of its stripped text**, so
polling a near-static pane every second collapses into a compact log of actual
state transitions instead of thousands of duplicate frames. `capture_tmux_pane`
shells out to `tmux` for the capture, but `observe()` takes raw text directly —
the dedup and triage logic is fully testable with no terminal attached.
→ [`crates/colibri-glasspane/src/terminal.rs`](../../crates/colibri-glasspane/src/terminal.rs)
(`TerminalRecorder`, `Observation`, `capture_tmux_pane`)
### Signature triage, data-driven per OS
A `SignatureSet` scans stripped terminal text and classifies the screen into
`failures` / `warnings` / `info` / `healthy` (`Severity::{Error, Warn, Info, Ok}`).
Patterns match as case-insensitive substrings; the first hit records a
signature and it is not double-reported. Every match carries a human
`next_action` and an optional `invoke` (a skill the agent can run to
remediate) — a hit is not "something happened" but "here is what it means and
what to do".
The detection engine is **data-driven**: a FreeBSD host and a Linux host load
different `Signature` sets but share the same matcher. `SignatureSet::linux_default`
ships a small starter set; callers build their own with `SignatureSet::new`.
This is the per-OS knob Colibri's capability routing leans on.
→ [`crates/colibri-glasspane/src/signatures.rs`](../../crates/colibri-glasspane/src/signatures.rs)
(`SignatureSet`, `Severity`, `Signature`, `Detection::alertable`)
### Alerts are edge-triggered, not level-triggered
A failure/warning signature is reported **only on the frame where it first
appears**, not on every subsequent frame that still shows it — returned as
`Observation::Recorded { uuid, new_alerts }` with only the newly-fired matches.
When the condition clears and later recurs, it fires again. Level-triggered
alerts on a 1s poll would re-notify every second for the lifetime of a stuck
pane; edge-triggering makes each alert mean "this just started."
→ [`crates/colibri-glasspane/src/terminal.rs`](../../crates/colibri-glasspane/src/terminal.rs)
(`TerminalRecorder::observe`, `Observation::new_alerts`)
## What is still open
- **Outbound push.** Attention is surfaced on-screen (the bar, the highlight).
The operator supervises headless hosts over Tailscale, not by watching the
TUI. Pushing attention **out** — a desktop notification on the live image
and a Telegram message — is the highest-impact unfinished piece. Token is
already provisioned; transport (`colibri notify` vs. a glasspane event a
harness hook fires) is undecided.
- **Answering a blocked agent from the dashboard.** The snapshot API is
read-heavy by design. A write path ("send input to pane N" over the daemon
socket) would let the operator respond to a `Blocked` pane from the TUI.
Changes the socket from supervision to interactive control — its own design
pass.
## See also
- [glasspane](./glasspane.md) — the state machine attention is a view over
- [tui](./tui.md) — the dashboard that surfaces attention
- [terminal](./terminal.md) — the terminal capability attention's keybindings rely on

101
docs/wiki/terminal.md Normal file
View file

@ -0,0 +1,101 @@
# Terminal — capability, not brand
← [index](./index.md)
## What this is
A decision about **which terminal capability** Colibri's operator surfaces
depend on, and why the choice fell on Kitty. The decision is about
_capabilities_ (extended-key reporting); Kitty is the instance that provides
them. A terminal without the capability is not "wrong" — it just degrades
specific keybindings.
## Decision
The dashboard client (`colibri-tui`) and the agents it supervises (pi, zot)
are keyboard-driven, and several of their bindings rely on distinguishing
**modified keys**: `Tab` vs `Shift-Tab` (cycle sessions), `n` vs `N` (next vs
previous attention pane), `Enter` (open detail). Terminals built on VTE
(xfce4-terminal, GNOME Terminal, Sakura) and Qt-based Konsole **collapse**
modifiers: `Shift-Enter`, `Ctrl-Enter`, and `Alt-Enter` all arrive as a plain
`Enter`, so two distinct bindings become indistinguishable.
The recommended terminal is **Kitty**: GPU-accelerated, keyboard-driven, and it
reports modified keys via the Kitty keyboard protocol / extended-keys. It is the
shipped default on the operator USB, with `xterm` retained as the always-works
fallback (Kitty is GPU-only; it cannot start on a headless `bhyve` surface with
no GL, so the rescue path falls back to `xterm`).
→ [`crates/colibri-glasspane-tui/src/main.rs`](../../crates/colibri-glasspane-tui/src/main.rs)
(the bindings above)
## Decisions
### tmux must forward modifiers, not strip them
Inside tmux the same collapse happens by default: tmux strips modifier
information unless told otherwise. The live USB ships a `~/.config/tmux/tmux.conf`
that enables passthrough:
```
set -g extended-keys on
set -g extended-keys-format csi-u
```
`csi-u` is the most reliable format and needs tmux 3.5+ (the live USB's port
is 3.5a). Pre-3.5 tmux (e.g. an older Linux build host) omits the second line
and falls back to the xterm `modifyOtherKeys` format, which Colibri also
parses. **Where this matters:** `colibri-tui` launched raw (no tmux) gets
modified keys natively; the tmux config only matters for the run-inside-tmux
workflow.
`crates/colibri-glasspane-tui/src/main.rs` (the event loop's key handling)
### Raw-kitty vs in-tmux is a real distinction
Two equally-valid ways to run the dashboard:
- **Raw**`kitty /usr/local/bin/colibri-tui`. Kitty reports modifiers
directly; no tmux config needed. This is what the live-USB desktop launcher
does.
- **In tmux**`kitty` then `tmux` then `colibri-tui`. Now the tmux
extended-keys config above is load-bearing; without it, `Shift-Tab` and `N`
stop reaching the app.
The desktop launcher path is raw by design, so the dashboard works without any
operator tmux setup. The in-tmux path is for operators who want tabs/splits
around the dashboard.
### The SSH terminfo gotcha
Kitty sets `TERM=xterm-kitty`. A remote host that has never seen Kitty does not
carry that terminfo entry, so `tmux a` fails with `missing or unsuitable
terminal: xterm-kitty`. The fix is Kitty's SSH kitten, which copies the terminfo
to the remote on connect — used as the `ssh` alias on operator machines. Lying
with `TERM=xterm-256color` works but discards the extended-key capability,
defeating the reason for the terminal choice.
### pi surfaces the same requirement
The pi harness — a spawnable Colibri backend — prints a startup warning when
tmux extended-keys is off, because its own bindings (`Enter` to submit,
`Shift-Enter` for newline) hit the identical collapse. The decision here is the
same one, stated for Colibri's surfaces: pick a terminal that reports
modifiers, and configure tmux to forward them.
## Requirements, stated once
- A terminal that reports modified keys (Kitty, Ghostty, WezTerm). On the
operator USB that terminal is Kitty.
- For the in-tmux workflow: tmux 3.5+ with `extended-keys on` +
`extended-keys-format csi-u` (pre-3.5: `extended-keys on` only).
- For SSH into a Kitty session: the Kitty SSH kitten, or a one-time terminfo
install, so `TERM=xterm-kitty` resolves on the remote.
## See also
- [tui](./tui.md) — the dashboard whose bindings drive this requirement
- [operator-attention](./operator-attention.md) — the jump keys (`n` / `N`)
that depend on modifier reporting
- [deployment](./deployment.md) — the operator USB that ships Kitty + the tmux
config