tmux-ide 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +22 -5
  2. package/bin/cli.js +3532 -1090
  3. package/bin/cli.ts +368 -71
  4. package/package.json +2 -1
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/control/client.d.ts +23 -0
  9. package/packages/daemon/dist/control/client.js +105 -0
  10. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  11. package/packages/daemon/dist/control/dispatch.js +83 -0
  12. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  13. package/packages/daemon/dist/control/fanout.js +37 -0
  14. package/packages/daemon/dist/control/frames.d.ts +23 -0
  15. package/packages/daemon/dist/control/frames.js +37 -0
  16. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  17. package/packages/daemon/dist/control/lifecycle.js +114 -0
  18. package/packages/daemon/dist/control/server.d.ts +16 -0
  19. package/packages/daemon/dist/control/server.js +214 -0
  20. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  21. package/packages/daemon/dist/control/verbs.js +91 -0
  22. package/packages/daemon/dist/doctor.d.ts +18 -0
  23. package/packages/daemon/dist/doctor.js +105 -15
  24. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  25. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  26. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  27. package/packages/daemon/dist/lib/app-config.js +104 -5
  28. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  29. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  30. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  31. package/packages/daemon/dist/lib/state-home.js +12 -0
  32. package/packages/daemon/dist/lib/update-check.js +5 -0
  33. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  34. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  35. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  36. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  39. package/packages/daemon/dist/restore.d.ts +35 -8
  40. package/packages/daemon/dist/restore.js +52 -15
  41. package/packages/daemon/dist/send.d.ts +33 -1
  42. package/packages/daemon/dist/send.js +32 -19
  43. package/packages/daemon/src/control/client.ts +128 -0
  44. package/packages/daemon/src/control/dispatch.ts +107 -0
  45. package/packages/daemon/src/control/fanout.ts +44 -0
  46. package/packages/daemon/src/control/frames.ts +40 -0
  47. package/packages/daemon/src/control/lifecycle.ts +151 -0
  48. package/packages/daemon/src/control/server.ts +237 -0
  49. package/packages/daemon/src/control/verbs.ts +118 -0
  50. package/packages/daemon/src/doctor.ts +113 -28
  51. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  52. package/packages/daemon/src/lib/app-config.ts +103 -5
  53. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  54. package/packages/daemon/src/lib/state-home.ts +13 -0
  55. package/packages/daemon/src/lib/update-check.ts +5 -0
  56. package/packages/daemon/src/restore.ts +53 -15
  57. package/packages/daemon/src/send.ts +55 -21
  58. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  59. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  60. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  61. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  62. package/packages/daemon/src/tui/chrome/notify.ts +582 -84
  63. package/packages/daemon/src/tui/chrome/updater.ts +268 -62
  64. package/packages/daemon/src/tui/detect/classify.ts +34 -0
  65. package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
  66. package/packages/daemon/src/tui/detect/manifest.ts +24 -3
  67. package/packages/daemon/src/tui/detect/manifests.ts +240 -6
  68. package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
  69. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  70. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  71. package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
  72. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  73. package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
  74. package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
  75. package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
  76. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  77. package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
  78. package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
  79. package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
  80. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  81. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  82. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  83. package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
  84. package/packages/daemon/src/tui/mirror/palette.ts +299 -9
  85. package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
  86. package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
  87. package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
  88. package/packages/daemon/src/tui/mirror/selection.ts +122 -8
  89. package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
  90. package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
  91. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  92. package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
  93. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  94. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  95. package/packages/daemon/src/tui/team/sessions.ts +85 -7
  96. package/packages/daemon/src/tui/team/wait.ts +144 -0
  97. package/scripts/build-macos-notifier.mjs +160 -0
  98. package/scripts/postinstall.js +8 -1
  99. package/scripts/prepublish-check.mjs +37 -1
  100. package/scripts/publish-tap.sh +55 -0
  101. package/skill/SKILL.md +88 -2
@@ -12,7 +12,7 @@
12
12
  * never left guessing.
13
13
  */
14
14
  import type { AppConfig, AppConfigPatch, AppKeys } from "../../lib/app-config.ts";
15
- import { parseHHMM, type NotificationPrefs } from "../chrome/notify.ts";
15
+ import { parseHHMM, type NotificationPrefs } from "../chrome/notify-prefs.ts";
16
16
  import type { DialogSelectItem } from "./dialog-model.ts";
17
17
 
18
18
  // ── Command registry (what the palette offers) ───────────────────────────────
@@ -107,24 +107,83 @@ export function themePatch(accent: string): AppConfigPatch {
107
107
 
108
108
  // ── Notifications ────────────────────────────────────────────────────────────
109
109
 
110
- export type NotificationToggleId = "enabled" | "toast" | "macos" | "onBlocked" | "onDone";
110
+ export type NotificationToggleId =
111
+ | "enabled"
112
+ | "toast"
113
+ | "macos"
114
+ | "terminal"
115
+ | "onBlocked"
116
+ | "onDone";
111
117
 
112
118
  const onOff = (v: boolean) => (v ? "on" : "off");
113
119
 
114
120
  /** PURE — the notification toggle rows (the REAL fields notify.ts reads: the
115
- * typed toast/macos plus the polish-era raw fields), and a quiet-hours row
116
- * that descends into its own flow. Enter toggles in place. */
121
+ * typed channel fields plus the polish-era raw fields), plus the rows that
122
+ * descend into their own flows (sound tri-choice, delay prompt, quiet hours).
123
+ * Enter toggles the boolean rows in place. */
117
124
  export function notificationItems(prefs: NotificationPrefs): DialogSelectItem[] {
118
125
  return [
119
126
  { id: "enabled", label: "All notifications", detail: onOff(prefs.enabled) },
120
127
  { id: "toast", label: "In-terminal toasts", detail: onOff(prefs.toast) },
121
- { id: "macos", label: "macOS banners", detail: onOff(prefs.macos) },
128
+ { id: "macos", label: "System banners", detail: onOff(prefs.macos) },
129
+ { id: "terminal", label: "Terminal banners (OSC)", detail: onOff(prefs.terminal) },
122
130
  { id: "onBlocked", label: "Alert when an agent needs you", detail: onOff(prefs.onBlocked) },
123
131
  { id: "onDone", label: "Alert when an agent finishes", detail: onOff(prefs.onDone) },
132
+ { id: "sound", label: "Sound…", detail: soundSummary(prefs) },
133
+ { id: "delaySeconds", label: "Alert delay…", detail: delaySummary(prefs) },
124
134
  { id: "quietHours", label: "Quiet hours…", detail: quietHoursSummary(prefs) },
125
135
  ];
126
136
  }
127
137
 
138
+ /** PURE — plain words for the sound tri-state. */
139
+ export function soundSummary(prefs: NotificationPrefs): string {
140
+ return prefs.sound === "blocked" ? "when blocked" : prefs.sound === "all" ? "always" : "off";
141
+ }
142
+
143
+ /** PURE — the sound chooser rows (● on the saved choice). */
144
+ export function soundItems(prefs: NotificationPrefs): DialogSelectItem[] {
145
+ return [
146
+ {
147
+ id: "blocked",
148
+ label: "When an agent needs you",
149
+ detail: "sound + terminal bell on blocked",
150
+ current: prefs.sound === "blocked",
151
+ },
152
+ {
153
+ id: "all",
154
+ label: "Every alert",
155
+ detail: "blocked and finished",
156
+ current: prefs.sound === "all",
157
+ },
158
+ { id: "none", label: "Off — no sound", current: prefs.sound === "none" },
159
+ ];
160
+ }
161
+
162
+ /** PURE — persist a picked sound mode. */
163
+ export function soundPatch(id: string): AppConfigPatch {
164
+ return { notifications: { sound: id } };
165
+ }
166
+
167
+ /** PURE — "2 s" / "immediately". */
168
+ export function delaySummary(prefs: NotificationPrefs): string {
169
+ return prefs.delaySeconds > 0 ? `${prefs.delaySeconds} s` : "immediately";
170
+ }
171
+
172
+ /** PURE — 0–60 whole seconds (0 = fire immediately; the delay exists to swallow
173
+ * flappy transitions, and a minute is already generous). */
174
+ export function validateDelaySeconds(value: string): string | null {
175
+ const n = Number(value.trim());
176
+ if (!Number.isInteger(n) || n < 0 || n > 60) {
177
+ return "Use a whole number of seconds between 0 and 60 (0 alerts immediately)";
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** PURE — persist a validated delay. */
183
+ export function delaySecondsPatch(value: string): AppConfigPatch {
184
+ return { notifications: { delaySeconds: Number(value.trim()) } };
185
+ }
186
+
128
187
  /** PURE — flip one notification toggle. */
129
188
  export function notificationTogglePatch(
130
189
  id: NotificationToggleId,
@@ -144,7 +203,7 @@ export function quietHoursItems(prefs: NotificationPrefs): DialogSelectItem[] {
144
203
  { id: "off", label: "Off — always notify", current: prefs.quietHours === null },
145
204
  {
146
205
  id: "window",
147
- label: "Silence banners during a daily window…",
206
+ label: "Silence banners & sounds during a daily window…",
148
207
  detail: prefs.quietHours ? quietHoursSummary(prefs) : undefined,
149
208
  current: prefs.quietHours !== null,
150
209
  },
@@ -258,10 +317,35 @@ export function prefixTwinFor(altKey: string): string | null {
258
317
  return letter;
259
318
  }
260
319
 
320
+ /** THE app-key enumeration — the ONE place the unified app's fixed keys are
321
+ * listed (M24.4). The keybind viewer's app rows AND the palette's right-aligned
322
+ * row shortcuts both read this; `paletteAction` is the stable palette action
323
+ * key ({@link ./palette.ts}'s paletteActionKey) a keycap attaches to. */
324
+ const APP_KEY_ROWS: ReadonlyArray<{ label: string; keycap: string; paletteAction?: string }> = [
325
+ { label: "Command palette", keycap: "F5 · ^p" },
326
+ { label: "Home tab", keycap: "F1", paletteAction: "tab:home" },
327
+ { label: "Terminal tab", keycap: "F2", paletteAction: "tab:terminal" },
328
+ { label: "Files tab", keycap: "F3", paletteAction: "tab:files" },
329
+ { label: "Diff tab", keycap: "F4", paletteAction: "tab:diff" },
330
+ { label: "Save file", keycap: "^s", paletteAction: "save" },
331
+ { label: "Back to Home", keycap: "^g" },
332
+ { label: "Toggle editor", keycap: "^e" },
333
+ { label: "Quit / detach", keycap: "^q", paletteAction: "quit" },
334
+ ];
335
+
336
+ /** PURE — palette action key → keycap, derived from {@link APP_KEY_ROWS}. The
337
+ * palette right-aligns these on rows that have one; app.tsx drops `quit` in
338
+ * HOSTED mode, where ^q detaches instead. */
339
+ export const PALETTE_KEYCAPS: Readonly<Record<string, string>> = Object.fromEntries(
340
+ APP_KEY_ROWS.filter((r) => r.paletteAction).map((r) => [r.paletteAction!, r.keycap]),
341
+ );
342
+
261
343
  /** PURE — the read-only shortcut rows: the chrome actions from the LIVE config
262
344
  * (prefix-first, the Alt fast path second — the prefix form is the one that
263
- * survives every keyboard protocol) and then the app's fixed keys. */
264
- export function keybindingItems(keys: AppKeys): DialogSelectItem[] {
345
+ * survives every keyboard protocol) and then the app's fixed keys
346
+ * ({@link APP_KEY_ROWS}). `superK` appends the kitty-protocol ⌘K fast path to
347
+ * the palette row — shown only when the renderer actually enables it. */
348
+ export function keybindingItems(keys: AppKeys, superK = false): DialogSelectItem[] {
265
349
  const chrome: Array<{ label: string; altKey: string }> = [
266
350
  { label: "Home cockpit", altKey: keys.home },
267
351
  { label: "Session switcher", altKey: keys.popup },
@@ -280,14 +364,10 @@ export function keybindingItems(keys: AppKeys): DialogSelectItem[] {
280
364
  detail: twin ? `prefix ${twin} · ${altKey}` : altKey,
281
365
  };
282
366
  });
283
- const app: Array<[string, string]> = [
284
- ["Command palette", "F5 · ^p"],
285
- ["Home / Terminal / Files / Diff tabs", "F1–F4"],
286
- ["Back to Home", "^g"],
287
- ["Toggle editor", "^e"],
288
- ["Detach from the app", "^q"],
289
- ];
290
- for (const [label, detail] of app) rows.push({ id: `app:${label}`, label, detail });
367
+ for (const { label, keycap } of APP_KEY_ROWS) {
368
+ const detail = superK && label === "Command palette" ? `${keycap} · ⌘K` : keycap;
369
+ rows.push({ id: `app:${label}`, label, detail });
370
+ }
291
371
  return rows;
292
372
  }
293
373
 
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The sidebar — the fleet nav column. Extracted from app.tsx (M26) as a PURE
3
+ * PRESENTATIONAL component: it renders, it does not fetch, resolve, or route.
4
+ *
5
+ * Everything it needs arrives as props, which is what lets TWO hosts render the
6
+ * same component:
7
+ *
8
+ * - the terminal app (app.tsx) — OpenTUI's native renderer, over tmux
9
+ * - the web (docs/tui-web) — a solid-js universal renderer over DOM
10
+ *
11
+ * Consequently: NO node imports, NO tmux, NO app state. The only pointer surface
12
+ * is `onMouse` on the root box, carrying CELL coordinates — the same contract in
13
+ * both hosts (the web host synthesizes cells from DOM events), so the router
14
+ * (`sidebarHit`) is shared too rather than reimplemented per host.
15
+ *
16
+ * Row order is the CALLER's: pass agents already sorted (app's `fleetAgents`
17
+ * memo applies `sortAgentRows`). Sorting here would double-sort and let the two
18
+ * hosts silently disagree with the palette's jump list, which reads the same memo.
19
+ *
20
+ * The y-accounting this render implies — title, rule, session rows, gap, agents
21
+ * header, agent rows — is reversed by `sidebarHit` in agent-rows.ts. The two MUST
22
+ * move together; that's why AGENTS_GAP_ROWS is shared rather than a literal here.
23
+ */
24
+ import { For, Show } from "solid-js";
25
+ import { STATUS_COLOR, STATUS_GLYPH } from "./status-grammar.ts";
26
+ import {
27
+ AGENTS_ADD_CHIP,
28
+ AGENTS_EMPTY_LINE,
29
+ AGENTS_GAP_ROWS,
30
+ agentAgeLabel,
31
+ agentDisplayKind,
32
+ agentRowLabel,
33
+ agentsHeaderLabel,
34
+ type AgentRowInput,
35
+ } from "./agent-rows.ts";
36
+ import {
37
+ ACCENT,
38
+ BUTTON_HOVER_BG,
39
+ CHIP_ATTN_BG,
40
+ DEFAULT_FG,
41
+ HOVER_BG,
42
+ MUTED,
43
+ SIDEBAR_BG,
44
+ TAB_ACTIVE_BG,
45
+ } from "./theme.ts";
46
+ import type { AgentStatus } from "../detect/classify.ts";
47
+
48
+ /** A session row: the fleet's name + its rolled-up status. */
49
+ export interface SidebarSession {
50
+ name: string;
51
+ status: AgentStatus;
52
+ }
53
+
54
+ /** The hover regions the sidebar owns. A subset of app.tsx's HoverRegion — kept
55
+ * structural so the host's wider union assigns without a cast. */
56
+ export type SidebarHoverRegion =
57
+ | "sidebar"
58
+ | "sidebaragent"
59
+ | "agentshdr"
60
+ | "agentschip"
61
+ | "sidebtn";
62
+
63
+ /** The pointer event shape (structurally OpenTUI's MouseEvent; app.tsx's
64
+ * RouteEvent assigns to it). Cell coordinates, not pixels. */
65
+ export interface SidebarMouseEvent {
66
+ type: string;
67
+ x: number;
68
+ y: number;
69
+ }
70
+
71
+ export interface SidebarProps {
72
+ /** Column width in cells. */
73
+ width: number;
74
+ sessions: SidebarSession[];
75
+ /** Pre-sorted, attention-first (see the note above). */
76
+ agents: AgentRowInput[];
77
+ /** The session whose workspace is open — renders as the active row. */
78
+ current: string;
79
+ /** Epoch SECONDS, injected so the age readout is testable and the component
80
+ * stays clock-free. */
81
+ nowSec: number;
82
+ isHovered: (region: SidebarHoverRegion, index: number) => boolean;
83
+ /** M25.1 attention flash, by pane id. */
84
+ flashed: (paneId: string) => boolean;
85
+ /** The footer hint's three runs — the middle one is the clickable chip whose
86
+ * cells the router hit-tests (SIDEBAR_HINT_SPAN). Passed in because the quit
87
+ * hint differs by host. */
88
+ hint: { pre: string; btn: string; post: string };
89
+ onMouse?: (e: SidebarMouseEvent) => void;
90
+ }
91
+
92
+ export function Sidebar(props: SidebarProps) {
93
+ return (
94
+ <box
95
+ width={props.width}
96
+ flexDirection="column"
97
+ backgroundColor={SIDEBAR_BG}
98
+ paddingLeft={1}
99
+ overflow="hidden"
100
+ onMouse={(e: SidebarMouseEvent) => props.onMouse?.(e)}
101
+ >
102
+ <text fg={ACCENT} attributes={1}>
103
+ tmux-ide
104
+ </text>
105
+ <text fg={MUTED}>{"─".repeat(props.width - 2)}</text>
106
+ <box flexDirection="column">
107
+ <For each={props.sessions}>
108
+ {(s, i) => (
109
+ <box
110
+ flexDirection="row"
111
+ gap={1}
112
+ backgroundColor={
113
+ s.name === props.current
114
+ ? TAB_ACTIVE_BG
115
+ : props.isHovered("sidebar", i())
116
+ ? HOVER_BG
117
+ : SIDEBAR_BG
118
+ }
119
+ >
120
+ <text fg={STATUS_COLOR[s.status]}>{STATUS_GLYPH[s.status]}</text>
121
+ <text fg={s.name === props.current ? DEFAULT_FG : MUTED}>
122
+ {s.name.slice(0, props.width - 5)}
123
+ </text>
124
+ </box>
125
+ )}
126
+ </For>
127
+ </box>
128
+ {/* AGENTS section (M22.2): the fleet's agents at a glance, one row per
129
+ agent, sorted attention-first, each a JUMP target (click → its
130
+ session/window/pane). REUSES the session rows' glyph + state-color
131
+ grammar. Hover reveals the state age. */}
132
+ <box flexDirection="column" marginTop={AGENTS_GAP_ROWS}>
133
+ {/* Header row (M24.1): label + a right-aligned [+ agent] chip. The row
134
+ body opens the TEAM dialog, the chip spawns; the router x-tests
135
+ `agentsChipSpans` — the flexGrow spacer lays the chip on exactly those
136
+ cells. The empty state keeps a chip twin so spawning is discoverable
137
+ before any agent runs. */}
138
+ <box
139
+ flexDirection="row"
140
+ backgroundColor={props.isHovered("agentshdr", 0) ? HOVER_BG : SIDEBAR_BG}
141
+ >
142
+ <text fg={MUTED} attributes={1}>
143
+ {agentsHeaderLabel(
144
+ props.agents.length,
145
+ Math.max(1, props.width - AGENTS_ADD_CHIP.length - 2),
146
+ )}
147
+ </text>
148
+ <box flexGrow={1} />
149
+ <text fg={MUTED} bg={props.isHovered("agentschip", 0) ? BUTTON_HOVER_BG : SIDEBAR_BG}>
150
+ {AGENTS_ADD_CHIP}
151
+ </text>
152
+ </box>
153
+ <Show
154
+ when={props.agents.length > 0}
155
+ fallback={
156
+ <box flexDirection="row">
157
+ <text fg={MUTED}>
158
+ {AGENTS_EMPTY_LINE.slice(0, Math.max(1, props.width - AGENTS_ADD_CHIP.length - 2))}
159
+ </text>
160
+ <box flexGrow={1} />
161
+ <text fg={MUTED} bg={props.isHovered("agentschip", 1) ? BUTTON_HOVER_BG : SIDEBAR_BG}>
162
+ {AGENTS_ADD_CHIP}
163
+ </text>
164
+ </box>
165
+ }
166
+ >
167
+ <For each={props.agents}>
168
+ {(a, i) => {
169
+ const hovered = () => props.isHovered("sidebaragent", i());
170
+ const ageShown = () =>
171
+ hovered() ? agentAgeLabel(a.state, a.since, props.nowSec) : null;
172
+ const labelBudget = () => {
173
+ const age = ageShown();
174
+ return props.width - 5 - (age ? age.length + 1 : 0);
175
+ };
176
+ // Blocked is the attention state: bold, matching the statusline's
177
+ // grammar (`blocked` reads bold there too).
178
+ const attn = () => (a.state === "blocked" ? 1 : 0);
179
+ const flashed = () => props.flashed(a.paneId);
180
+ return (
181
+ <box
182
+ flexDirection="row"
183
+ gap={1}
184
+ backgroundColor={flashed() ? CHIP_ATTN_BG : hovered() ? HOVER_BG : SIDEBAR_BG}
185
+ >
186
+ <text fg={STATUS_COLOR[a.state]} attributes={attn()}>
187
+ {STATUS_GLYPH[a.state]}
188
+ </text>
189
+ <text
190
+ fg={a.state === "blocked" ? STATUS_COLOR.blocked : MUTED}
191
+ attributes={attn()}
192
+ >
193
+ {agentRowLabel(agentDisplayKind(a), a.session, labelBudget())}
194
+ </text>
195
+ <Show when={ageShown()}>
196
+ <box flexGrow={1} />
197
+ <text fg={MUTED}>{ageShown()}</text>
198
+ </Show>
199
+ </box>
200
+ );
201
+ }}
202
+ </For>
203
+ </Show>
204
+ </box>
205
+ <box flexGrow={1} />
206
+ {/* Footer hint — its middle segment is a CHIP (M21.9): the router
207
+ hit-tests SIDEBAR_HINT_SPAN on the last screen row, and these three runs
208
+ render the exact same cells. */}
209
+ <box flexDirection="row">
210
+ <text fg={MUTED}>{props.hint.pre}</text>
211
+ <text fg={MUTED} bg={props.isHovered("sidebtn", 0) ? BUTTON_HOVER_BG : SIDEBAR_BG}>
212
+ {props.hint.btn}
213
+ </text>
214
+ <text fg={MUTED}>{props.hint.post}</text>
215
+ </box>
216
+ </box>
217
+ );
218
+ }
@@ -10,6 +10,18 @@
10
10
  * mismatch from the pane layout, center the grid inside the canvas, and format
11
11
  * the quiet honest hint. No tmux, no render loop — unit-tested in isolation; the
12
12
  * app reads the pane geometry and the pinned canvas size and wires the rest.
13
+ *
14
+ * KNOWN GAP, measured (M25.5, tmux 3.7b): when TWO app processes mirror the
15
+ * same session (one per terminal — the non-hosted double-open), the later
16
+ * control client's boot pin wins `window-size latest`, and the other app can
17
+ * NEVER re-win it by interaction — the keys/mouse it forwards travel as
18
+ * control-client `send-keys` commands, which do not update tmux's
19
+ * latest-client
20
+ * bookkeeping (measured: local keystroke and wheel left the window at the
21
+ * other app's pin). The hint + the palette's "Resize to fit" verb are the
22
+ * designed escape; the structural fix is the hosted cockpit (`--detachable`),
23
+ * where one app process serves every terminal and REAL clients attach to the
24
+ * host session, whose native latest handling re-adopts on attach/focus/detach.
13
25
  */
14
26
 
15
27
  export interface Size {
@@ -66,6 +78,47 @@ export function letterboxOffset(canvas: Size, content: Size): { x: number; y: nu
66
78
  };
67
79
  }
68
80
 
81
+ /** An in-flight re-pin (M23.5): we changed our pinned size and tmux has not
82
+ * confirmed the new layout yet. `prev` is the pin BEFORE this re-pin. */
83
+ export interface RepinState {
84
+ prev: Size;
85
+ at: number;
86
+ }
87
+
88
+ /** How long any mismatch is suppressed after a re-pin (server round-trip +
89
+ * layout-change latency is sub-ms; the margin covers a loaded machine). */
90
+ export const REPIN_GRACE_MS = 400;
91
+ /** How long a mismatch that still equals the PRE-repin pin is suppressed — the
92
+ * unambiguous "tmux hasn't applied our new pin yet" signature can wait longer
93
+ * before we call it another terminal's doing. */
94
+ export const REPIN_STALE_GRACE_MS = 1500;
95
+
96
+ /**
97
+ * PURE — {@link detectSizeMismatch}, gated for the re-pin transition (M23.5,
98
+ * D4b): between our `refresh-client -C` and tmux's `%layout-change` the
99
+ * effective size is legitimately stale, and surfacing it flashed a false
100
+ * "window sized by another terminal" hint plus a letterbox jump-to-center on
101
+ * EVERY grow (measured). Suppress the mismatch while a re-pin is in flight:
102
+ * any mismatch within {@link REPIN_GRACE_MS}, and — for longer, up to
103
+ * {@link REPIN_STALE_GRACE_MS} — one whose effective size still equals the
104
+ * pre-repin pin exactly (that shape can only be the old pin, not a co-attached
105
+ * terminal's choice). Past the grace the honest hint returns.
106
+ */
107
+ export function detectSizeMismatchWithRepin(
108
+ pinned: Size,
109
+ effective: Size,
110
+ repin: RepinState | null,
111
+ now: number,
112
+ ): Size | null {
113
+ const mm = detectSizeMismatch(pinned, effective);
114
+ if (!mm || !repin) return mm;
115
+ const age = now - repin.at;
116
+ if (age < REPIN_GRACE_MS) return null;
117
+ const isPrevPin = effective.cols === repin.prev.cols && effective.rows === repin.prev.rows;
118
+ if (isPrevPin && age < REPIN_STALE_GRACE_MS) return null;
119
+ return mm;
120
+ }
121
+
69
122
  /**
70
123
  * PURE — the quiet, plain-language hint for a size mismatch: the honest answer
71
124
  * ("here is the size another terminal chose"), dimensions in the terminal-native
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The unified app's color tokens — PURE data, hoisted out of app.tsx (M26) so
3
+ * the surfaces extracted from it (starting with {@link ./sidebar.tsx}) carry
4
+ * their palette with them instead of reaching back into a 21k-line module.
5
+ *
6
+ * Companion to {@link ./status-grammar.ts}: that file owns the per-AGENT-STATE
7
+ * glyph + hue (a status signal), this one owns the CHROME (surface, accent,
8
+ * hover, selection). Keep the two families distinct — focus is an accent signal,
9
+ * agent state is a status signal, and they must never collide on a hue.
10
+ *
11
+ * Node-free on purpose: the web host (docs/tui-web) imports this module
12
+ * verbatim, aliasing @opentui/core to a browser shim for RGBA.
13
+ */
14
+ import { RGBA } from "@opentui/core";
15
+
16
+ export const DEFAULT_FG = RGBA.fromInts(212, 212, 216, 255);
17
+ export const DEFAULT_BG = RGBA.fromInts(16, 16, 22, 255);
18
+
19
+ /** The sidebar's surface — one lift above DEFAULT_BG so the nav column reads as
20
+ * chrome, not as content. */
21
+ export const SIDEBAR_BG = RGBA.fromInts(22, 22, 30, 255);
22
+ export const ACCENT = RGBA.fromInts(130, 170, 255, 255);
23
+ export const MUTED = RGBA.fromInts(110, 110, 130, 255);
24
+ export const BADGE_BG = RGBA.fromInts(60, 66, 92, 255);
25
+
26
+ /** Focused-pane gutter hairline (M22.7): the ACCENT family, drawn as │/─ glyphs
27
+ * so the gutter stays visually thin (a filled bar read as extra padding — user
28
+ * feedback). Doesn't compete with the blocked chip's red — focus is an accent
29
+ * signal, agent state is a status signal, never the same hue. */
30
+ export const FOCUS_BORDER_FG = RGBA.fromInts(110, 145, 230, 255);
31
+
32
+ /** The selected row/tab. Always wins over HOVER_BG. */
33
+ export const TAB_ACTIVE_BG = RGBA.fromInts(40, 46, 66, 255);
34
+
35
+ /** A single subtle pointer-hover tint, one lift above both DEFAULT_BG (16,16,22)
36
+ * and SIDEBAR_BG (22,22,30) and below TAB_ACTIVE_BG — the active/selected state
37
+ * always wins over hover. Used on every hoverable row/segment. */
38
+ export const HOVER_BG = RGBA.fromInts(30, 34, 48, 255);
39
+
40
+ /** A chip/button under the pointer. */
41
+ export const BUTTON_HOVER_BG = RGBA.fromInts(52, 60, 86, 255);
42
+
43
+ /** The attention flash (M25.1): a just-flipped hidden agent's row pulses this
44
+ * for a beat — the status-strip note's "look here" twin. */
45
+ export const CHIP_ATTN_BG = RGBA.fromInts(92, 44, 48, 255);
@@ -24,6 +24,15 @@ const START_BONUS = 10; // matched char is the first char of the target
24
24
  const SEPARATOR_BONUS = 8; // matched char follows a separator (word start)
25
25
  const CONTIGUOUS_BONUS = 5; // matched char is adjacent to the previous match
26
26
  const BASE = 1; // every matched char is worth at least this
27
+ // Whole-alignment extra when the query is a contiguous PREFIX of the target
28
+ // (M24.4): typing a label's start must rank that label above mid-word runs
29
+ // AND above scattered matches that rack up several word-start bonuses —
30
+ // measured: "save" scored 29 on "Save file" but 30 on "swap pane view east"
31
+ // (three word-start hits). Sized to dominate realistic competitors (a
32
+ // non-prefix alignment outscores a prefix only past ~8 all-word-start chars);
33
+ // word-boundary preference WITHIN non-prefix matches is untouched ("web"
34
+ // still anchors on the "web" of `wavyr-website`, not the leading "w").
35
+ const PREFIX_EXTRA = 24;
27
36
 
28
37
  /**
29
38
  * Case-insensitive subsequence match of `query` against `target`.
@@ -106,6 +115,17 @@ export function fuzzyMatch(
106
115
  }
107
116
  if (start === -1) return null;
108
117
 
118
+ // The exact/prefix tier (M24.4): when the query IS the target's start, that
119
+ // alignment (positions 0..m-1) + PREFIX_EXTRA competes with the DP's best —
120
+ // and effectively always wins, so prefix matches rank first however many
121
+ // word-start bonuses a scattered alignment collected.
122
+ if (t.startsWith(q)) {
123
+ const prefixScore = m * BASE + START_BONUS + (m - 1) * CONTIGUOUS_BONUS + PREFIX_EXTRA;
124
+ if (prefixScore >= bestScore) {
125
+ return { score: prefixScore, positions: Array.from({ length: m }, (_, i) => i) };
126
+ }
127
+ }
128
+
109
129
  const positions: number[] = [];
110
130
  let j = start;
111
131
  for (let i = 0; i < m && j !== -1; i++) {