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
@@ -6,7 +6,11 @@
6
6
  * scrollback (wheel; ↑n/depth badge; any key snaps live), real SGR mouse
7
7
  * forwarding into panes whose app enabled mouse mode, 60fps-paced (8ms state tick + targetFps 60 paint)
8
8
  * rendering, ^o pane focus cycle, ^t window cycle, ^q quits (session
9
- * untouched).
9
+ * untouched) — except HOSTED (M23.2): launched by `tmux-ide app --detachable`
10
+ * inside the internal `_tmux-ide-app` session (TMUX_IDE_HOSTED=1), ^q puts the
11
+ * cockpit away and the app keeps running (switch-client -l back to where the
12
+ * client came from, else detach); the palette's "Quit" verb remains the real
13
+ * exit (ending the pane command ends the host session).
10
14
  *
11
15
  * SELECT MODE (M22.9): forwarding normally wins on app-mouse panes, so those
12
16
  * panes (exactly the agent panes users copy from) could never drag-select.
@@ -18,6 +22,34 @@
18
22
  * passes shift through to us (SGR button+4; many terminals keep shift+drag
19
23
  * for native selection — measured: see the card notes).
20
24
  *
25
+ * DRAG SELECTS ON AGENT PANES (M24.2): the implicit default now follows the
26
+ * pane. Where the fleet's agent join (agentByPane) matches, a plain left
27
+ * press is DEFERRED (`pendingPress`): motion off the press cell starts a
28
+ * normal selection (the app sees NOTHING — no stray down), a release in
29
+ * place forwards the owed SGR press/release pair (agents are click-driven).
30
+ * Other app-mouse panes (vim/htop) forward as before. SHIFT inverts a pane's
31
+ * default (so shift+drag on an agent pane forwards; on vim it selects, as in
32
+ * M22.9); the right-click pane menu carries a per-pane session override; the
33
+ * `app.dragSelect` policy ("agents"|"always"|"never", app-config) is read
34
+ * once at boot. Wheel routing is UNTOUCHED (agent panes still forward the
35
+ * wheel outside select mode); pure logic in selection.ts (paneDragDefault /
36
+ * routePanePress).
37
+ *
38
+ * SELECTION SURVIVES SCROLLING (M25.6): mirror selections anchor in ABSOLUTE
39
+ * xterm buffer lines (absLine = scrollbackDepth − scrollOffset + viewportRow),
40
+ * not viewport cells — so scrolling mid-drag keeps the highlight on its text
41
+ * and a selection can span many screens. While a drag is live the wheel over
42
+ * the pane always scrolls the LOCAL scrollback (never forwarded, never cancels
43
+ * — even on app-mouse panes), and holding the pointer at the pane's top/bottom
44
+ * content row auto-scrolls ~1 row per 8ms state tick (clamped at the
45
+ * scrollback top / the live bottom). The release copy extracts the FULL
46
+ * absolute span straight from the pane's buffer (SessionMirror.extractText,
47
+ * built capped so a runaway span never materializes unbounded — the 1 MB
48
+ * clipboard cap still refuses over-limit selections). Buffer rotation at the
49
+ * scrollback cap mid-drag is compensated via PaneMirror.lineTrim (the anchor
50
+ * follows its content); both plain drags and M22.9/M24.2 select-mode /
51
+ * shift / deferred-press entries share this machinery.
52
+ *
21
53
  * SURFACE TABS (M18.4): a persistent top row — [⌂ Home][❯ Terminal][▤ Files]
22
54
  * [± Diff] — makes the app a real IDE. F1..F4 switch (F-keys encode reliably;
23
55
  * ctrl+digit/alt do NOT — measured); the tab bar is also clickable (fixed x-span
@@ -38,6 +70,21 @@
38
70
  * openFile, diffFile } — persists to `~/.tmux-ide/app-state.json`
39
71
  * (TMUX_IDE_HOME override), debounced, restored on launch.
40
72
  *
73
+ * PALETTE V2 (M24.4): the overlay renders ROWS (palette.ts's PaletteRow) —
74
+ * an empty query groups "recent" (persisted usage in app-state's paletteUsage,
75
+ * keyed by paletteActionKey so relabels keep history) · "suggested" (surface
76
+ * verbs; BLOCKED agents' jumps first) · "commands"; a typed query is one flat
77
+ * list ranked by the label-start-weighted fuzzy score with a frequency/recency
78
+ * tie-break. Headers are inert rows: keyboard (stepPaletteRow) and the router
79
+ * both skip them. Action rows right-align their app keycap (settings-model's
80
+ * PALETTE_KEYCAPS — the keybind viewer's single source). ⌘K is a third opener
81
+ * beside F5/^p, delivered ONLY under the kitty keyboard protocol: the renderer
82
+ * requests it (useKittyKeyboard, app.kittyKeys config, default on), the stdin
83
+ * parser maps CSI-u keys to the SAME names as legacy so pane re-encoding is
84
+ * untouched, and ALL super-modified keys are consumed at the top of the key
85
+ * handler (never typed into a query/prompt/editor, never forwarded — pane
86
+ * forwarding of modifier-rich keys is card #83's scope).
87
+ *
41
88
  * SETTINGS (M22.4): no settings screen — every setting is a palette COMMAND
42
89
  * ("Settings…" is the categorized umbrella) executed via three DIALOG
43
90
  * primitives on ONE global stack (dialog-stack.ts; pure model in
@@ -64,15 +111,26 @@
64
111
  * persisted tab; `--edit <file>` opens Files; `--diff <dir>` opens Diff. On home,
65
112
  * `o` opens a path prompt, `d` opens the Diff tab for the selected session's dir.
66
113
  *
67
- * DIFF (M18.3): a two-column panel — left is the changed-file list (status letter
68
- * + path, selected row highlighted), right is the unified diff of the selected
69
- * file (add/del/hunk/context colored). Git runs via ASYNC execFile ONLY (the
70
- * landmine: no sync execs near the render loop; the one exception is reading a
71
- * single untracked file to show it as additions). `git status --porcelain` +
72
- * `git diff --no-color -- <file>` refresh on a 3s timer while mode=diff and on
73
- * manual `r`. j/k move the file selection; the wheel scrolls the diff (or the
74
- * file list when over the left column); a left-column click selects a file; `^e`
75
- * opens the selected file in the EDITOR at its repo-relative path. Pure parsing +
114
+ * DIFF (M18.3; v2 M24.5): a two-column panel — left is the changed-file list
115
+ * GROUPED into Staged / Unstaged / Untracked sections (counted headers are
116
+ * non-selectable rows; an `MM` file appears in BOTH stage groups, each side
117
+ * diffing its own half of the index), right is the unified diff of the selected
118
+ * row (add/del/hunk/context colored, add/del lines carry background fills).
119
+ * Git runs via ASYNC execFile ONLY (the landmine: no sync execs near the render
120
+ * loop; the one exception is reading a single untracked file to show it as
121
+ * additions). `git status --porcelain` + both `--numstat`s (per-file ± counts,
122
+ * header totals) refresh on a 3s timer while mode=diff and on manual `r`.
123
+ * j/k move the file selection (headers skipped — the row/selection math is the
124
+ * shared buildDiffRows pass, the AGENTS_GAP_ROWS lesson); s/u stage/unstage the
125
+ * selected file and S/U everything (reversible, so no confirms — each verb
126
+ * notes what it did and follows the file into its new group); the footer verbs
127
+ * and a selected/hovered row's [s stage]/[u unstage] chip are their span-routed
128
+ * mouse twins; `/` filters the list live (escape clears — diff surface only,
129
+ * Terminal's `/` scrollback search is untouched); ]/[ jump the diff view
130
+ * between hunks; the wheel scrolls the diff (or the file list when over the
131
+ * left column); a left-column click selects a file row; `^e` opens the selected
132
+ * file in the EDITOR at the first changed line of the top-visible hunk (pure
133
+ * hunk math from the `@@ -a,b +c,d` header). Pure parsing + grouping +
76
134
  * classification live in diff-model.ts (unit-tested).
77
135
  *
78
136
  * EDITOR (M18.2): the editing ENGINE is a native `EditBuffer` (bun:ffi —
@@ -140,17 +198,32 @@ import {
140
198
  writeSync,
141
199
  closeSync,
142
200
  } from "node:fs";
143
- import { readdir, writeFile, rename, rm, stat } from "node:fs/promises";
201
+ import { readdir, readFile, writeFile, rename, rm, stat } from "node:fs/promises";
144
202
  import { basename, dirname, join } from "node:path";
145
203
  import { homedir } from "node:os";
146
204
  import { render, useKeyboard, usePaste, useTerminalDimensions } from "@opentui/solid";
147
- import { RGBA, EditBuffer, decodePasteBytes } from "@opentui/core";
205
+ import { RGBA, EditBuffer, createCliRenderer, decodePasteBytes } from "@opentui/core";
148
206
  import { createSignal, createMemo, createEffect, onMount, onCleanup, For, Show } from "solid-js";
149
207
  import { SessionMirror, type LivePane } from "./session-mirror.ts";
150
208
  import { registerPaneSurface, type PaneSearchHighlight } from "./pane-surface.tsx";
151
209
  import { tapInputSent, tapInputTick } from "./perf-tap.ts";
210
+ import { installHostAutowrapGuard, type HostAutowrapGuard } from "./host-terminal.ts";
152
211
  import { execFile, spawn } from "node:child_process";
153
212
  import type { AgentStatus } from "../detect/classify.ts";
213
+ import { Sidebar } from "./sidebar.tsx";
214
+ import {
215
+ ACCENT,
216
+ BADGE_BG,
217
+ BUTTON_HOVER_BG,
218
+ CHIP_ATTN_BG,
219
+ DEFAULT_BG,
220
+ DEFAULT_FG,
221
+ FOCUS_BORDER_FG,
222
+ HOVER_BG,
223
+ MUTED,
224
+ SIDEBAR_BG,
225
+ TAB_ACTIVE_BG,
226
+ } from "./theme.ts";
154
227
  import { rollupChips, homeFooterHints, type FleetRollup } from "../team/home.ts";
155
228
  import {
156
229
  isBinary,
@@ -165,20 +238,39 @@ import {
165
238
  type ReadOnlyReason,
166
239
  } from "./editor-buffer.ts";
167
240
  import {
168
- parseStatusPorcelain,
169
241
  classifyDiff,
170
242
  untrackedDiffText,
171
243
  clampSel,
172
- type StatusEntry,
244
+ parseStatusGroups,
245
+ parseStatusPorcelain,
246
+ // Both diff-model and file-tree export a `filterEntries`; alias the diff one
247
+ // (merge of M24.5 + M24.6 — the two surfaces grew them independently).
248
+ filterEntries as filterDiffEntries,
249
+ buildDiffRows,
250
+ rowIndexOfFile,
251
+ parseNumstat,
252
+ untrackedLineCount,
253
+ applyCounts,
254
+ totalCounts,
255
+ nextHunkTop,
256
+ hunkEditTarget,
257
+ type DiffEntry,
258
+ type DiffGroup,
173
259
  type DiffLineKind,
260
+ type StatusEntry,
174
261
  } from "./diff-model.ts";
175
262
  import {
176
263
  loadAppState,
177
264
  saveAppState,
178
265
  addRecentFolder,
266
+ addCustomCommand,
267
+ recordPaletteUse,
179
268
  clampSidebarWidth,
180
269
  isTab,
270
+ rememberSpawn,
271
+ spawnMemoryKey,
181
272
  type AppState,
273
+ type PaletteUsageEntry,
182
274
  type Tab,
183
275
  } from "./app-state.ts";
184
276
  import {
@@ -200,19 +292,25 @@ import { registerProject, ProjectAlreadyRegisteredError } from "../../lib/projec
200
292
  import { separatorAt, resizedSize, resizeCommand, type Separator } from "./resize-model.ts";
201
293
  import {
202
294
  effectiveWindowSize,
203
- detectSizeMismatch,
295
+ detectSizeMismatchWithRepin,
204
296
  letterboxOffset,
205
297
  formatSizeHint,
298
+ type RepinState,
206
299
  type Size,
207
300
  } from "./size-truth.ts";
208
301
  import {
209
- filterPaletteActions,
302
+ paletteRows,
303
+ paletteActionKey,
304
+ paletteRowText,
305
+ firstPaletteAction,
306
+ stepPaletteRow,
210
307
  parseBufferList,
211
308
  palettePos,
212
309
  paletteRowAt,
213
310
  paletteContains,
214
311
  clampPaletteTop,
215
312
  type PaletteAction,
313
+ type PaletteRow,
216
314
  type PaletteGeom,
217
315
  type TmuxBuffer,
218
316
  } from "./palette.ts";
@@ -247,6 +345,7 @@ import {
247
345
  HINT_CHROME_RESTART,
248
346
  HINT_LIVE,
249
347
  HINT_READOPT,
348
+ delaySecondsPatch,
250
349
  keybindingItems,
251
350
  notificationItems,
252
351
  notificationTogglePatch,
@@ -255,6 +354,9 @@ import {
255
354
  quietHoursOffPatch,
256
355
  quietHoursPatch,
257
356
  resetSettingsPatch,
357
+ soundItems,
358
+ soundPatch,
359
+ validateDelaySeconds,
258
360
  restoreItems,
259
361
  restorePatch,
260
362
  settingsRootItems,
@@ -267,11 +369,26 @@ import {
267
369
  validateQuietTime,
268
370
  validateSnapshotEvery,
269
371
  validateTickMs,
372
+ PALETTE_KEYCAPS,
270
373
  type NotificationToggleId,
271
374
  type SettingsCommandId,
272
375
  } from "./settings-model.ts";
273
376
  import { loadAppConfig, loadRawAppConfig, updateAppConfig } from "../../lib/app-config.ts";
274
- import { parseNotificationPrefs } from "../chrome/notify.ts";
377
+ import {
378
+ APP_FOCUS_OPTION,
379
+ APP_JUMP_OPTION,
380
+ buildAppFocusValue,
381
+ parseNotificationPrefs,
382
+ } from "../chrome/notify.ts";
383
+ import { adoptMarkArgv, updaterProbeArgv, updaterSpawnArgv } from "../chrome/front-door.ts";
384
+ import { APP_HOST_SESSION } from "./hosted.ts";
385
+ import {
386
+ ATTENTION_FLASH_MS,
387
+ attentionNoteLine,
388
+ diffAttention,
389
+ noteworthyTransitions,
390
+ type AttentionAgent,
391
+ } from "./attention.ts";
275
392
  import {
276
393
  buildHomeItems,
277
394
  centerPad,
@@ -288,7 +405,9 @@ import {
288
405
  agentRowLabel,
289
406
  agentsHeaderLabel,
290
407
  agentAgeLabel,
408
+ agentDisplayKind,
291
409
  sidebarHit,
410
+ AGENTS_ADD_CHIP,
292
411
  AGENTS_EMPTY_LINE,
293
412
  AGENTS_GAP_ROWS,
294
413
  type AgentRowInput,
@@ -302,13 +421,59 @@ import {
302
421
  type SearchMatch,
303
422
  } from "./search-model.ts";
304
423
  import {
424
+ ALWAYS_IGNORE,
425
+ ancestorDirs,
305
426
  buildNodes,
427
+ changedFileWalk,
428
+ filterEntries,
429
+ filterView,
430
+ indexOfPath,
306
431
  insertChildrenAt,
432
+ nextChangedPath,
433
+ rebuildTree,
434
+ relPath,
307
435
  removeSubtreeAt,
436
+ statusMapFromEntries,
308
437
  type FileNode,
309
438
  type RawEntry,
310
439
  } from "./file-tree.ts";
440
+ import ignore, { type Ignore } from "ignore";
441
+ import { watchDirectory } from "../../widgets/lib/watcher.ts";
311
442
  import { spans, spanHit, spansFromRight, type Span } from "./spans.ts";
443
+ import {
444
+ AGAIN_ID,
445
+ CUSTOM_KIND_ID,
446
+ INTERRUPT_TAP_GAP_MS,
447
+ RESTART_GRACE_MS,
448
+ TEAM_ACTIONS,
449
+ TEAM_NEW_ID,
450
+ clearAuthorityArgs,
451
+ compatiblePlacement,
452
+ customRecentIndex,
453
+ defaultSpawnPlacement,
454
+ interruptArgs,
455
+ labelPaneArgs,
456
+ labelWindowArgs,
457
+ lastSpawnName,
458
+ launchCommandFor,
459
+ newAgentItems,
460
+ paneHostsShell,
461
+ placementActions,
462
+ placementLabel,
463
+ relaunchArgs,
464
+ resolvePlacement,
465
+ respawnArgs,
466
+ spawnAgentArgs,
467
+ spawnLabelFor,
468
+ spawnSessionArgs,
469
+ stampLaunchArgs,
470
+ teamAgentIndex,
471
+ teamItems,
472
+ type LastSpawn,
473
+ type SpawnPlacement,
474
+ type SpawnWhere,
475
+ } from "./agent-lifecycle.ts";
476
+ import { getManifests } from "../detect/manifest-loader.ts";
312
477
  import { agentsByPane, chipLabel } from "./agent-chip.ts";
313
478
  import { focusStrips } from "./focus-border.ts";
314
479
  import { scrollThumb, trackZone, pageTop, dragTop } from "./scrollbar-model.ts";
@@ -330,17 +495,20 @@ import {
330
495
  orderCells,
331
496
  rowSelectionRange,
332
497
  extractSelection,
498
+ trimAdjustCell,
333
499
  wordRangeAt,
334
500
  lineRangeAt,
335
501
  clickCount,
336
502
  tintRunsInverse,
337
503
  tintRunsBg,
338
504
  osc52Sequence,
339
- pressStartsSelection,
505
+ paneDragDefault,
506
+ routePanePress,
340
507
  wheelScrollsLocal,
341
508
  selectBadgeLabel,
342
509
  ATTR_INVERSE,
343
510
  type Cell,
511
+ type PaneDragDefault,
344
512
  type Selection,
345
513
  } from "./selection.ts";
346
514
 
@@ -391,20 +559,13 @@ const zzlog = (m: string) => {
391
559
  } catch {}
392
560
  };
393
561
 
394
- const SIDEBAR_BG = RGBA.fromInts(22, 22, 30, 255);
395
- const ACCENT = RGBA.fromInts(130, 170, 255, 255);
396
- const MUTED = RGBA.fromInts(110, 110, 130, 255);
397
- const BADGE_BG = RGBA.fromInts(60, 66, 92, 255);
398
562
  // Focused-pane gutter hairline (M22.7): the ACCENT family, drawn as │/─ glyphs
399
563
  // so the gutter stays visually thin (a filled bar read as extra padding — user
400
564
  // feedback). Doesn't compete with the blocked chip's red — focus is an accent
401
565
  // signal, agent state is a status signal, never the same hue.
402
- const FOCUS_BORDER_FG = RGBA.fromInts(110, 145, 230, 255);
403
- const TAB_ACTIVE_BG = RGBA.fromInts(40, 46, 66, 255);
404
566
  // A single subtle pointer-hover tint, one lift above both DEFAULT_BG (16,16,22)
405
567
  // and SIDEBAR_BG (22,22,30) and below TAB_ACTIVE_BG — the active/selected state
406
568
  // always wins over hover. Used on every hoverable row/segment (see `hover`).
407
- const HOVER_BG = RGBA.fromInts(30, 34, 48, 255);
408
569
  const KEYMAP: Record<string, string> = {
409
570
  return: "Enter",
410
571
  backspace: "BSpace",
@@ -451,11 +612,19 @@ type HoverRegion =
451
612
  | "windowtab"
452
613
  | "files"
453
614
  | "diff"
615
+ // M24.5: the diff footer's clickable [s stage]/[u unstage]/[S all]/[U all]
616
+ // verb chips (index into DIFF_VERBS).
617
+ | "diffverb"
454
618
  | "button"
455
619
  | "tabbtn"
456
620
  | "homechip"
621
+ | "homeagentchip"
457
622
  | "welcomeopen"
458
- | "sidebtn";
623
+ | "sidebtn"
624
+ // M24.1: the AGENTS header row (click → Team dialog) and its right-aligned
625
+ // [+ agent] chip (index 0 = header row, 1 = the empty-state row's twin).
626
+ | "agentshdr"
627
+ | "agentschip";
459
628
 
460
629
  /** The one pointer-event shape the central `route` reads. `button` distinguishes
461
630
  * left (0) / right (2) presses; `stopPropagation` (present on the real OpenTUI
@@ -476,8 +645,6 @@ type RouteEvent = {
476
645
  stopPropagation?: () => void;
477
646
  };
478
647
 
479
- const DEFAULT_FG = RGBA.fromInts(212, 212, 216, 255);
480
- const DEFAULT_BG = RGBA.fromInts(16, 16, 22, 255);
481
648
  // Packed 0xRRGGBB twins of the defaults, for the framebuffer-blit path (M21.3):
482
649
  // the blit writes packed channels straight into the buffer, no RGBA per cell.
483
650
  const DEFAULT_FG_PACKED = 0xd4d4d8;
@@ -489,7 +656,6 @@ const DEFAULT_BG_PACKED = 0x101016;
489
656
  const FB_PANES = process.env.TMUX_IDE_FB_PANES !== "0";
490
657
  // The blocked pane-chip's attention background (M22.3) — a red-leaning lift of
491
658
  // BADGE_BG so a blocked agent's chip pops without tinting any terminal cells.
492
- const CHIP_ATTN_BG = RGBA.fromInts(92, 44, 48, 255);
493
659
  const GUTTER_BG = RGBA.fromInts(38, 40, 52, 255);
494
660
  const GUTTER_FG = RGBA.fromInts(96, 100, 120, 255);
495
661
  const MODIFIED_FG = RGBA.fromInts(235, 200, 100, 255);
@@ -515,6 +681,27 @@ const STATUS_LETTER_FG: Record<string, RGBA> = {
515
681
  C: ACCENT,
516
682
  "?": MUTED,
517
683
  };
684
+ // Add/del BACKGROUND fills layered UNDER the DIFF_FG classes (M24.5). Values
685
+ // mirror the widget theme's diffAddedBg/diffRemovedBg (widgets/lib/theme.ts:27-34)
686
+ // — that theme is the future token source once the app's const surface colors
687
+ // move onto the theming pipeline.
688
+ const DIFF_ADD_BG = RGBA.fromInts(20, 60, 30, 255);
689
+ const DIFF_DEL_BG = RGBA.fromInts(60, 20, 20, 255);
690
+ const DIFF_LINE_BG: Partial<Record<DiffLineKind, RGBA>> = { add: DIFF_ADD_BG, del: DIFF_DEL_BG };
691
+ // The diff footer's clickable stage/unstage verbs (M24.5) — fixed labels so
692
+ // the x-span math is constant. Laid out from the main column's first content
693
+ // cell (sidebarW()+paddingLeft 1) with the render's gap={1}; the spans memo and
694
+ // the footer render walk this SAME list, so a click lands where it's drawn.
695
+ const DIFF_VERBS: { id: "stage" | "unstage" | "stage-all" | "unstage-all"; label: string }[] = [
696
+ { id: "stage", label: "[s stage]" },
697
+ { id: "unstage", label: "[u unstage]" },
698
+ { id: "stage-all", label: "[S all]" },
699
+ { id: "unstage-all", label: "[U all]" },
700
+ ];
701
+ // A diff file row's right-anchored stage/unstage chip (shown on the selected
702
+ // and hovered rows) — trailing space = a 1-cell inset from the list edge.
703
+ const DIFF_ROW_CHIP_STAGE = "[s stage] ";
704
+ const DIFF_ROW_CHIP_UNSTAGE = "[u unstage] ";
518
705
  const HEADER_ROWS = 2;
519
706
  // The persistent surface-tab row is one screen row at the very top (above the
520
707
  // sidebar + main region). Its height offsets every region's global y, so the
@@ -545,7 +732,26 @@ const PALETTE_ROWS = 10;
545
732
  const HOME_CHIP_SESSION = "[± diff] ";
546
733
  const HOME_CHIP_PROJECT = "[▸ launch] ";
547
734
  const HOME_CHIP_RECENT = "[▸ open] ";
548
- const TABBAR_PALETTE_LABEL = "F5 ⌘ palette ";
735
+ // The spawn verb's home entry (M23.1): session/project rows carry a second
736
+ // chip left of the primary one; `a` is its keyboard twin.
737
+ const HOME_CHIP_AGENT = "[+ agent] ";
738
+ // ── M24.4 kitty keyboard protocol ───────────────────────────────────────────
739
+ // One config read at boot (the dragSelect discipline): when on, the renderer
740
+ // requests kitty's disambiguated key encoding from the host terminal, which is
741
+ // what delivers ⌘-modified keys at all — ⌘K opens the palette. Hosts without
742
+ // the protocol ignore the request (legacy encoding, no behavior change);
743
+ // `app.kittyKeys: false` opts out entirely. The ⌘K hint only shows while the
744
+ // request is actually made.
745
+ const KITTY_KEYS = loadAppConfig().app.kittyKeys;
746
+ const TABBAR_PALETTE_LABEL = KITTY_KEYS ? "F5 ⌘K palette " : "F5 ⌘ palette ";
747
+ // The palette rows' right-aligned keycaps (M24.4) — the settings keybind
748
+ // viewer's enumeration, minus `quit` when HOSTED (^q detaches there; the
749
+ // palette's Quit is the real exit, so the keycap would lie).
750
+ const PALETTE_ROW_KEYCAPS: Readonly<Record<string, string>> = Object.fromEntries(
751
+ Object.entries(PALETTE_KEYCAPS).filter(
752
+ ([key]) => !(key === "quit" && process.env.TMUX_IDE_HOSTED === "1"),
753
+ ),
754
+ );
549
755
  // ── M22.5 first-run welcome ─────────────────────────────────────────────────
550
756
  // A centered greeting shown only on a truly empty fleet (no sessions, no
551
757
  // registered projects). WELCOME_ROWS rows in the content area (gy 2…); the
@@ -555,11 +761,17 @@ const WELCOME_LINE = "Welcome to tmux-ide — a cockpit for the tmux sessions yo
555
761
  const WELCOME_ACTION_LABEL = "▸ open a folder — press f";
556
762
  const WELCOME_ROWS = 6;
557
763
  const WELCOME_ACTION_ROW = 3; // 0-based within the welcome block
764
+ // HOSTED mode (M23.2): the detachable-cockpit launcher stamps this marker on
765
+ // the app's pane command inside `_tmux-ide-app`. ^q then detaches the tmux
766
+ // client instead of exiting (the cockpit survives the terminal); every "^q
767
+ // quit" hint reads "detach" so the keycap tells the truth.
768
+ const HOSTED = process.env.TMUX_IDE_HOSTED === "1";
769
+ const QUIT_HINT = HOSTED ? "^q detach" : "^q quit";
558
770
  // The sidebar footer hint, split so its "F5 palette" segment is a chip: the
559
771
  // span starts after paddingLeft (1) + the pre text.
560
772
  const SIDEBAR_HINT_PRE = "F1-4 tabs · ";
561
773
  const SIDEBAR_HINT_BTN = "F5 palette";
562
- const SIDEBAR_HINT_POST = " · ^q quit";
774
+ const SIDEBAR_HINT_POST = ` · ${QUIT_HINT}`;
563
775
  const SIDEBAR_HINT_SPAN = { start: 1 + SIDEBAR_HINT_PRE.length, width: SIDEBAR_HINT_BTN.length };
564
776
  // Scrollback-search highlight backgrounds (M20.3), packed 0xRRGGBB to sit in a
565
777
  // run's `bg` (search paints a bg, distinct from selection's inverse video, so
@@ -581,7 +793,6 @@ const SCROLL_THUMB_HOVER_BG = RGBA.fromInts(120, 130, 170, 255);
581
793
  // rows, styled like a subtle chip; the hovered one lifts to the accent.
582
794
  const BUTTON_FG = RGBA.fromInts(150, 160, 190, 255);
583
795
  const BUTTON_BG = RGBA.fromInts(34, 38, 54, 255);
584
- const BUTTON_HOVER_BG = RGBA.fromInts(52, 60, 86, 255);
585
796
  // A toggled-on chip (the zoom button while the focused pane's window is zoomed):
586
797
  // the accent, tinted down so the button still reads as a chip, not a label.
587
798
  const BUTTON_ACTIVE_BG = RGBA.fromInts(58, 78, 128, 255);
@@ -601,8 +812,30 @@ const packedToRgba = (packed: number | null, fallback: RGBA): RGBA => {
601
812
  return c;
602
813
  };
603
814
 
604
- render(
605
- () => {
815
+ // Create the renderer explicitly so host-terminal mode ownership spans its
816
+ // exact lifetime. OpenTUI's terminal setup must finish BEFORE DECAWM is turned
817
+ // off; its onDestroy runs after native terminal teardown and restores it. The
818
+ // process-exit fallback covers any future direct process.exit path and uses a
819
+ // synchronous fd write because queued stdout is not reliable during `exit`.
820
+ let hostAutowrap: HostAutowrapGuard | null = null;
821
+ const appRenderer = await createCliRenderer({
822
+ // targetFps stays EXPLICIT: @opentui 0.4.3 silently defaults it to 30
823
+ // (maxFps already defaults to 60). Re-confirmed on the 0.4.3 bump (M21.2).
824
+ targetFps: 60,
825
+ maxFps: 60,
826
+ // `{}` requests kitty's default disambiguation + alternate-key flags.
827
+ useKittyKeyboard: KITTY_KEYS ? {} : null,
828
+ // OpenTUI's error console is an in-app overlay; keep it development-only.
829
+ consoleMode: process.env.TMUX_IDE_MIRROR_DEBUG ? "console-overlay" : "disabled",
830
+ onDestroy: () => hostAutowrap?.restore(),
831
+ });
832
+ hostAutowrap = installHostAutowrapGuard((sequence) => writeSync(process.stdout.fd, sequence), {
833
+ onExit: (listener) => process.once("exit", listener),
834
+ offExit: (listener) => process.removeListener("exit", listener),
835
+ });
836
+
837
+ try {
838
+ await render(() => {
606
839
  // Register <pane_surface> before any is created (M21.3). An explicit call —
607
840
  // a bare side-effect import of the module gets DCE'd by the transpiler.
608
841
  if (FB_PANES) registerPaneSurface();
@@ -642,6 +875,18 @@ render(
642
875
  // every folder open, persisted with the rest of the app state. Home renders
643
876
  // them under a "recent" header (deduped against sessions + the registry).
644
877
  const [recentFolders, setRecentFolders] = createSignal<string[]>(persisted.recentFolders);
878
+ // The "again" spawn memory + custom-command recents (M24.1) — restored from
879
+ // app-state, updated on every spawn, persisted with the rest.
880
+ const [lastSpawns, setLastSpawns] = createSignal<Record<string, LastSpawn>>(
881
+ persisted.lastSpawns,
882
+ );
883
+ const [customCommands, setCustomCommands] = createSignal<string[]>(persisted.customCommands);
884
+ // Palette usage history (M24.4) — restored from app-state, bumped on every
885
+ // dispatched palette action, persisted with the rest. Drives the empty-query
886
+ // "recent" group and the typed-query tie-break.
887
+ const [paletteUsage, setPaletteUsage] = createSignal<Record<string, PaletteUsageEntry>>(
888
+ persisted.paletteUsage,
889
+ );
645
890
  // The first-run tip line — the user's ACTUAL keybindings, read once at launch
646
891
  // (loadAppConfig honors TMUX_IDE_CONFIG). Cheap + pure, computed once.
647
892
  const welcomeTip = firstRunTip(loadAppConfig().keys);
@@ -723,15 +968,27 @@ render(
723
968
  const [sel, setSel] = createSignal(0);
724
969
  const [status, setStatus] = createSignal(bareHome ? "home" : "attaching…");
725
970
 
726
- // ── SELECTION & CLIPBOARD (M19.4) ────────────────────────────────────────
971
+ // ── SELECTION & CLIPBOARD (M19.4; absolute-space M25.6) ──────────────────
727
972
  // The visible selection (drives inverse-tint on the mirror/editor render) and
728
973
  // the gesture state machine driving it. `selecting` marks a drag in progress
729
974
  // (null = none, discrete word/line selections leave it null); `selAnchor` is
730
- // where the drag began; `lastClick` tracks click-count for double/triple. A
731
- // transient `note` reuses the status channel for "copied/pasted N chars".
975
+ // where the drag began — for the MIRROR that's an ABSOLUTE buffer cell
976
+ // (M25.6), fixed at press; `selTrimBase` records the pane's lineTrim() at
977
+ // that moment so a buffer rotating past its scrollback cap mid-drag keeps
978
+ // the anchor on its content (trimAdjustCell subtracts the drift at each
979
+ // extend). `lastClick` tracks click-count for double/triple. A transient
980
+ // `note` reuses the status channel for "copied/pasted N chars".
732
981
  const [selection, setSelection] = createSignal<Selection | null>(null);
733
982
  let selecting: { surface: "mirror"; paneId: string } | { surface: "editor" } | null = null;
734
983
  let selAnchor: Cell = { row: 0, col: 0 };
984
+ let selTrimBase = 0;
985
+ // Edge auto-scroll (M25.6): armed by extendSelection when the drag pointer
986
+ // sits at/beyond the selecting pane's top/bottom content row; the 8ms state
987
+ // tick then scrolls 1 row per tick and re-extends at the LAST pointer —
988
+ // no new timers. Cleared on release/escape (clearSelection) and whenever
989
+ // the pointer moves back inside the pane body.
990
+ let dragAutoScroll: "up" | "down" | null = null;
991
+ let lastDragPointer = { x: 0, y: 0 };
735
992
  let lastClick: { row: number; col: number; ts: number; count: number } | null = null;
736
993
  const [note, setNote] = createSignal("");
737
994
  let noteTimer: ReturnType<typeof setTimeout> | null = null;
@@ -742,6 +999,7 @@ render(
742
999
  };
743
1000
  const clearSelection = () => {
744
1001
  selecting = null;
1002
+ dragAutoScroll = null;
745
1003
  if (selection() !== null) setSelection(null);
746
1004
  };
747
1005
 
@@ -778,6 +1036,39 @@ render(
778
1036
  if (focused && focused !== sm && mirror?.focusedPane() !== sm) exitSelectMode();
779
1037
  });
780
1038
 
1039
+ // ── IMPLICIT DRAG-SELECT DEFAULT (M24.2) ─────────────────────────────────
1040
+ // Select mode is the explicit escape hatch; the DEFAULT now follows the
1041
+ // pane. Where the fleet says an agent runs (the M22.3 agentByPane join), a
1042
+ // plain left drag SELECTS — the press is DEFERRED (`pendingPress`) so a
1043
+ // genuine click still reaches the app as one SGR press/release pair, and
1044
+ // NOTHING is forwarded once motion starts a selection. Other app-mouse
1045
+ // panes keep forwarding; shift inverts a pane's default (routePanePress);
1046
+ // the right-click toggle overrides per pane for the session (pruned when
1047
+ // the pane dies); `app.dragSelect` sets the policy, read once at boot like
1048
+ // the rest of the app config.
1049
+ const dragSelectPolicy = loadAppConfig().app.dragSelect;
1050
+ const dragOverrides = new Map<string, PaneDragDefault>();
1051
+ const paneDrag = (paneId: string): PaneDragDefault =>
1052
+ paneDragDefault(
1053
+ agentByPane().get(paneId),
1054
+ dragSelectPolicy,
1055
+ dragOverrides.get(paneId) ?? null,
1056
+ );
1057
+ /** Drop overrides for panes that no longer exist anywhere on the server
1058
+ * (pane ids are server-global and never recycled, so a miss is a death,
1059
+ * not a window switch). Piggybacks on the 3s fleet tick; one control-mode
1060
+ * round-trip, only while overrides exist at all. */
1061
+ const pruneDragOverrides = () => {
1062
+ if (dragOverrides.size === 0 || !mirror) return;
1063
+ void mirror
1064
+ .command('list-panes -a -F "#{pane_id}"')
1065
+ .then((lines) => {
1066
+ const alive = new Set(lines.map((l) => l.trim()));
1067
+ for (const id of [...dragOverrides.keys()]) if (!alive.has(id)) dragOverrides.delete(id);
1068
+ })
1069
+ .catch(() => {});
1070
+ };
1071
+
781
1072
  // ── SCROLLBACK SEARCH (M20.3) ────────────────────────────────────────────
782
1073
  // copy-mode's `/` finder, app-native. `search` is the live search SESSION: a
783
1074
  // bottom-of-canvas input line owning the keyboard while open. `editing:true`
@@ -833,6 +1124,32 @@ render(
833
1124
  };
834
1125
  let dragging: DragState | null = null;
835
1126
 
1127
+ // ── DEFERRED PRESS (M24.2) ───────────────────────────────────────────────
1128
+ // A left press on a select-default app-mouse pane is WITHHELD: if the
1129
+ // pointer leaves the press cell before release, the press becomes the
1130
+ // anchor of a normal selection (the app never sees any of it); if the
1131
+ // release lands in the same cell, the owed SGR press/release pair is
1132
+ // forwarded then — a click, which is how agents like claude are driven.
1133
+ // Coordinates are frozen at press so the forwarded pair is exactly the
1134
+ // cell the user pressed. `absCell`/`trimBase` freeze the press's ABSOLUTE
1135
+ // buffer cell (M25.6) so a selection born from the deferred press anchors
1136
+ // exactly where the button went down. Only one of {pendingPress, selecting,
1137
+ // dragging} is ever live.
1138
+ let pendingPress: {
1139
+ paneId: string;
1140
+ x: number;
1141
+ gy: number;
1142
+ cell: Cell;
1143
+ absCell: Cell;
1144
+ trimBase: number;
1145
+ } | null = null;
1146
+ // The pane whose app is OWED a release because its press was forwarded.
1147
+ // OpenTUI synthesizes SEVERAL release-type events per physical release
1148
+ // (drag-end, up, drop, up — measured live), so the release forward must be
1149
+ // debt-tracked: paid exactly ONCE, to the pane that got the down (wherever
1150
+ // the pointer is at release), and never for gestures we consumed locally.
1151
+ let forwardedDown: string | null = null;
1152
+
836
1153
  // ── RIGHT-CLICK CONTEXT MENU (M19.2) ─────────────────────────────────────
837
1154
  // A small overlay opened at the pointer on a right-button press (SGR button
838
1155
  // 2). Late-mounted inside <Show>, so — per the mouse landmine laws — it
@@ -859,6 +1176,8 @@ render(
859
1176
  diffPath?: string;
860
1177
  paneId?: string;
861
1178
  windowIndex?: number;
1179
+ /** The sidebar agent row a menu targets (M23.1 lifecycle verbs). */
1180
+ agent?: AgentRowInput;
862
1181
  }
863
1182
  const [menu, setMenu] = createSignal<MenuState | null>(null);
864
1183
  const [menuSel, setMenuSel] = createSignal(0);
@@ -893,6 +1212,95 @@ render(
893
1212
  .filter((a, i, arr) => arr.findIndex((b) => b.paneId === a.paneId) === i),
894
1213
  ),
895
1214
  );
1215
+
1216
+ // ── IN-APP ATTENTION (M25.1) ─────────────────────────────────────────────
1217
+ // The 3s fleet poll diffs the previous per-pane agent states against the
1218
+ // fresh payload (pure math in attention.ts); blocked/done flips for agents
1219
+ // NOT on the current screen (other workspace / other window / a non-
1220
+ // Terminal tab) surface as a status-strip note plus a brief flash on their
1221
+ // sidebar rows. First sight is graced — an app boot announces nothing.
1222
+ let attnPrev = new Map<string, AgentStatus>();
1223
+ const [attnFlash, setAttnFlash] = createSignal<ReadonlySet<string>>(new Set());
1224
+ let attnFlashTimer: ReturnType<typeof setTimeout> | null = null;
1225
+ const noteAttention = (projects: FleetProject[]) => {
1226
+ const agents: AttentionAgent[] = projects.flatMap((p) =>
1227
+ p.sessions.flatMap((s) =>
1228
+ (s.agents ?? []).map((a) => ({
1229
+ paneId: a.paneId,
1230
+ session: a.session,
1231
+ kind: a.kind,
1232
+ state: a.state,
1233
+ })),
1234
+ ),
1235
+ );
1236
+ const { transitions, next } = diffAttention(attnPrev, agents);
1237
+ attnPrev = next;
1238
+ const worthy = noteworthyTransitions(transitions, {
1239
+ tab: tab(),
1240
+ visiblePaneIds: tab() === "terminal" ? panes().map((p) => p.id) : [],
1241
+ });
1242
+ const line = attentionNoteLine(worthy);
1243
+ if (!line) return;
1244
+ setStatusNote(line);
1245
+ setAttnFlash(new Set(worthy.map((w) => w.paneId)));
1246
+ if (attnFlashTimer) clearTimeout(attnFlashTimer);
1247
+ attnFlashTimer = setTimeout(() => setAttnFlash(new Set()), ATTENTION_FLASH_MS);
1248
+ };
1249
+
1250
+ // ── FOCUS HANDSHAKE (M25.1) ──────────────────────────────────────────────
1251
+ // The app publishes what it is showing — attached?, the mirrored session,
1252
+ // the on-screen pane ids — as a tmux SERVER option the chrome updater's
1253
+ // notify path reads (see notify.ts AppFocus for the option-vs-file
1254
+ // rationale). Refreshed on every fleet poll and on tab switches; the
1255
+ // record's `ts` plus the reader's staleness guard cover an app that died
1256
+ // without cleanup. Hosted attachment is probed (the cockpit keeps running
1257
+ // detached); a plain app IS the user's terminal, so it's attached while
1258
+ // it runs.
1259
+ const writeFocusRecord = (attached: boolean) => {
1260
+ const value = buildAppFocusValue({
1261
+ ts: Date.now(),
1262
+ attached,
1263
+ session: curTarget(),
1264
+ panes: tab() === "terminal" ? panes().map((p) => p.id) : [],
1265
+ });
1266
+ execFile("tmux", ["set-option", "-s", APP_FOCUS_OPTION, value], () => {});
1267
+ };
1268
+ const refreshFocusRecord = () => {
1269
+ if (!HOSTED) {
1270
+ writeFocusRecord(true);
1271
+ return;
1272
+ }
1273
+ execFile("tmux", ["list-clients", "-t", `=${APP_HOST_SESSION}`, "-F", "x"], (err, stdout) =>
1274
+ writeFocusRecord(!err && stdout.trim().length > 0),
1275
+ );
1276
+ };
1277
+ onCleanup(() => {
1278
+ if (attnFlashTimer) clearTimeout(attnFlashTimer);
1279
+ // Best-effort; the staleness guard is the real cleanup for a hard death.
1280
+ execFile("tmux", ["set-option", "-s", "-u", APP_FOCUS_OPTION], () => {});
1281
+ });
1282
+
1283
+ // ── CLICK-TO-JUMP CONSUME (M25.1, hosted only) ───────────────────────────
1284
+ // A macOS banner click stamps @tmux_ide_app_jump on the host session (see
1285
+ // notify.ts notifierExecuteCommand) and switches the user's client to the
1286
+ // cockpit. The fleet poll consumes the stamp: unset it FIRST (never loop),
1287
+ // then open that session's workspace — which also serves the detached
1288
+ // case, where the switch-client had nobody to move but the next attach
1289
+ // should land on the session that needed input.
1290
+ const consumeJumpRequest = () => {
1291
+ if (!HOSTED) return;
1292
+ execFile(
1293
+ "tmux",
1294
+ ["show-option", "-t", APP_HOST_SESSION, "-qv", APP_JUMP_OPTION],
1295
+ (err, stdout) => {
1296
+ const target = err ? "" : stdout.trim();
1297
+ if (!target) return;
1298
+ execFile("tmux", ["set-option", "-t", APP_HOST_SESSION, "-u", APP_JUMP_OPTION], () => {
1299
+ openWorkspace(target, dirForSession(target));
1300
+ });
1301
+ },
1302
+ );
1303
+ };
896
1304
  const homeItems = createMemo<HomeItem[]>(() => buildHomeItems(projectsData(), recentFolders()));
897
1305
  // First-run (M22.5): a truly empty fleet gets a centered welcome. It reserves
898
1306
  // WELCOME_ROWS at the top of the content area, so the home row math shifts by
@@ -1008,7 +1416,7 @@ render(
1008
1416
  },
1009
1417
  );
1010
1418
 
1011
- const openEditor = (rawPath: string) => {
1419
+ const openEditor = (rawPath: string, line?: number) => {
1012
1420
  const path = rawPath.startsWith("~/")
1013
1421
  ? `${process.env.HOME ?? ""}${rawPath.slice(1)}`
1014
1422
  : rawPath;
@@ -1026,11 +1434,20 @@ render(
1026
1434
  editBuffer = EditBuffer.create("wcwidth");
1027
1435
  editBuffer.setText(text);
1028
1436
  editBuffer.setCursor(0, 0);
1437
+ // Jump target (M24.5: ^e from a diff hunk): clamp into the buffer, put
1438
+ // the cursor there, and scroll it into view.
1439
+ let top = 0;
1440
+ if (line !== undefined) {
1441
+ const lineCount = text.split("\n").length;
1442
+ const target = Math.max(0, Math.min(line, lineCount - 1));
1443
+ editBuffer.setCursor(target, 0);
1444
+ top = scrollToCursor(target, 0, editorRows(), lineCount);
1445
+ }
1029
1446
  if (mode() !== "editor") prevMode = mode() === "mirror" ? "mirror" : "home";
1030
1447
  setEditorPath(path);
1031
1448
  setEditorReadOnly(reason);
1032
1449
  setEditorModified(false);
1033
- setEditorTop(0);
1450
+ setEditorTop(top);
1034
1451
  setEditorMsg("");
1035
1452
  setEditorRev((r) => r + 1);
1036
1453
  setFilesFocus("editor");
@@ -1095,6 +1512,12 @@ render(
1095
1512
  } else if (!ro && name === "delete") {
1096
1513
  eb.deleteChar();
1097
1514
  setEditorModified(true);
1515
+ } else if (!ro && name === "space" && !evt.ctrl && !evt.meta) {
1516
+ // OpenTUI names the key "space", not " " — without this branch the
1517
+ // editor could not insert spaces at all (found by the M24.6 battery;
1518
+ // same trap the dialog stack hit in M24.1).
1519
+ eb.insertText(" ");
1520
+ setEditorModified(true);
1098
1521
  } else if (!ro && name.length === 1 && !evt.ctrl && !evt.meta) {
1099
1522
  eb.insertText(evt.shift ? name.toUpperCase() : name);
1100
1523
  setEditorModified(true);
@@ -1105,27 +1528,48 @@ render(
1105
1528
  setEditorRev((r) => r + 1);
1106
1529
  };
1107
1530
 
1108
- // ── DIFF (M18.3) ────────────────────────────────────────────────────────
1109
- // The working-tree diff of `diffDir`, rendered natively. Git runs via async
1110
- // execFile (`runGit`); the only sync io is reading a single untracked file to
1111
- // show it as additions. `diffText` holds the raw diff for the selected file;
1112
- // `diffLoadToken` discards a slow diff whose selection has since moved on.
1531
+ // ── DIFF (M18.3; grouped + stage-aware M24.5) ───────────────────────────
1532
+ // The working-tree diff of `diffDir`, rendered natively as a GROUPED list
1533
+ // (Staged / Unstaged / Untracked; an MM file appears in both stage groups,
1534
+ // each side diffing its own half of the index). Git runs via async execFile
1535
+ // (`runGit`); the only sync io is reading a single untracked file to show it
1536
+ // as additions. `diffText` holds the raw diff for the selected file;
1537
+ // `diffLoadToken` discards a slow diff whose selection has since moved on,
1538
+ // `diffStatusToken` a stale status/numstat merge (same race discipline).
1113
1539
  const [diffDir, setDiffDir] = createSignal(values.diff ?? invokeCwd);
1114
- const [diffFiles, setDiffFiles] = createSignal<StatusEntry[]>([]);
1540
+ const [diffEntries, setDiffEntries] = createSignal<DiffEntry[]>([]);
1115
1541
  const [diffSel, setDiffSel] = createSignal(0);
1116
1542
  const [diffText, setDiffText] = createSignal("");
1117
1543
  const [diffTop, setDiffTop] = createSignal(0); // diff-pane scroll (right)
1118
- const [diffFileTop, setDiffFileTop] = createSignal(0); // file-list scroll (left)
1544
+ const [diffFileTop, setDiffFileTop] = createSignal(0); // file-list scroll (left, in ROWS)
1119
1545
  const [diffMsg, setDiffMsg] = createSignal("");
1546
+ // The `/` filter over the grouped file list (diff surface only — Terminal's
1547
+ // `/` scrollback search is a different mode branch). null = off; while
1548
+ // non-null every printable key narrows live, escape/return clear + exit.
1549
+ const [diffFilter, setDiffFilter] = createSignal<string | null>(null);
1120
1550
  let diffLoadToken = 0;
1121
- // A diff file to re-select once `git status` repopulates the list (restore).
1551
+ let diffStatusToken = 0;
1552
+ // A diff file to re-select once `git status` repopulates the list: the
1553
+ // persisted path on restore, or a verb's follow target — path + preferred
1554
+ // group, so a just-staged file is re-selected in its NEW section.
1122
1555
  let pendingDiffFile: string | null = persisted.diffFile;
1556
+ let pendingDiffGroup: DiffGroup | null = null;
1123
1557
 
1124
1558
  // Body rows below header (1) + rule (1), above the footer (1) — shared by both
1125
1559
  // columns. The left column width is a capped fraction of the canvas.
1126
1560
  const diffBodyRows = () => Math.max(1, dims().height - 3 - TABBAR_H);
1127
1561
  const diffListW = () => Math.max(20, Math.min(48, Math.floor(canvasCols() * 0.34)));
1128
1562
  const diffLines = createMemo(() => classifyDiff(diffText()));
1563
+ // Grouped rows (section headers + files) and the flat selectable-file order,
1564
+ // both from ONE buildDiffRows pass over the filtered entries: the render,
1565
+ // the mouse router, and the selection all walk the SAME rows, so the hit
1566
+ // math cannot drift from what's drawn (the AGENTS_GAP_ROWS lesson).
1567
+ const diffRowsData = createMemo(() =>
1568
+ buildDiffRows(filterDiffEntries(diffEntries(), diffFilter() ?? "")),
1569
+ );
1570
+ const diffRows = () => diffRowsData().rows;
1571
+ const diffVisibleFiles = () => diffRowsData().files;
1572
+ const diffTotals = createMemo(() => totalCounts(diffVisibleFiles()));
1129
1573
  const diffVisible = createMemo(() => {
1130
1574
  const lines = diffLines();
1131
1575
  const rows = diffBodyRows();
@@ -1133,10 +1577,10 @@ render(
1133
1577
  return lines.slice(top, top + rows);
1134
1578
  });
1135
1579
  const fileVisible = createMemo(() => {
1136
- const files = diffFiles();
1137
- const rows = diffBodyRows();
1138
- const top = clampTop(diffFileTop(), files.length, rows);
1139
- return files.slice(top, top + rows).map((entry, i) => ({ entry, index: top + i }));
1580
+ const rows = diffRows();
1581
+ const view = diffBodyRows();
1582
+ const top = clampTop(diffFileTop(), rows.length, view);
1583
+ return rows.slice(top, top + view).map((row, i) => ({ row, rowIndex: top + i }));
1140
1584
  });
1141
1585
 
1142
1586
  const runGit = (args: string[], cb: (out: string) => void) => {
@@ -1147,14 +1591,15 @@ render(
1147
1591
  (err, stdout) => cb(err ? "" : stdout),
1148
1592
  );
1149
1593
  };
1594
+ const runGitP = (args: string[]) => new Promise<string>((resolve) => runGit(args, resolve));
1150
1595
 
1151
- /** Load the diff for one file: async `git diff` for tracked paths (falling
1152
- * back to `--cached` when the change is staged-only), or the untracked file's
1153
- * contents rendered as additions. Guarded by `diffLoadToken` against races. */
1154
- const loadDiff = (entry: StatusEntry) => {
1596
+ /** Load the diff for one entry, by its GROUP: staged rows diff `--cached`,
1597
+ * unstaged rows the worktree, and an untracked file's contents render as
1598
+ * additions. Guarded by `diffLoadToken` against races. */
1599
+ const loadDiff = (entry: DiffEntry) => {
1155
1600
  const token = ++diffLoadToken;
1156
1601
  setDiffMsg("");
1157
- if (entry.status === "?") {
1602
+ if (entry.group === "untracked") {
1158
1603
  try {
1159
1604
  const bytes = readFileSync(join(diffDir(), entry.path));
1160
1605
  if (isBinary(bytes)) {
@@ -1169,59 +1614,182 @@ render(
1169
1614
  }
1170
1615
  return;
1171
1616
  }
1172
- runGit(["diff", "--no-color", "--", entry.path], (out) => {
1617
+ const args =
1618
+ entry.group === "staged"
1619
+ ? ["diff", "--no-color", "--cached", "--", entry.path]
1620
+ : ["diff", "--no-color", "--", entry.path];
1621
+ runGit(args, (out) => {
1173
1622
  if (token !== diffLoadToken) return;
1174
- if (out.trim()) {
1175
- setDiffText((p) => (p === out ? p : out));
1176
- return;
1177
- }
1178
- runGit(["diff", "--no-color", "--cached", "--", entry.path], (cached) => {
1179
- if (token !== diffLoadToken) return;
1180
- setDiffText((p) => (p === cached ? p : cached));
1181
- });
1623
+ setDiffText((p) => (p === out ? p : out));
1182
1624
  });
1183
1625
  };
1184
1626
 
1185
- /** Select file `i`: highlight it, reset the diff scroll, keep it in view in the
1186
- * file list, and (re)load its diff. */
1627
+ /** Select FILE `i` (an index into the flat selectable order — section
1628
+ * headers are not selectable): highlight it, reset the diff scroll, keep
1629
+ * its ROW in view in the file list, and (re)load its diff. */
1187
1630
  const selectDiffFile = (i: number) => {
1188
- const files = diffFiles();
1631
+ const files = diffVisibleFiles();
1189
1632
  if (files.length === 0) return;
1190
1633
  const idx = clampSel(i, files.length);
1191
1634
  setDiffSel(idx);
1192
1635
  setDiffTop(0);
1193
- setDiffFileTop((t) => scrollToCursor(idx, t, diffBodyRows(), files.length));
1636
+ const rows = diffRows();
1637
+ const rowIdx = rowIndexOfFile(rows, idx);
1638
+ if (rowIdx !== -1)
1639
+ setDiffFileTop((t) => scrollToCursor(rowIdx, t, diffBodyRows(), rows.length));
1194
1640
  loadDiff(files[idx]!);
1195
1641
  };
1196
1642
  const moveDiffSel = (delta: number) => selectDiffFile(diffSel() + delta);
1197
1643
 
1198
- /** Re-run `git status --porcelain`, reconcile the selection, and reload the
1199
- * selected file's diff (so an external edit is reflected). */
1644
+ /** After a filter mutation: select the first match (reloading its diff), or
1645
+ * clear the diff pane when nothing matches. */
1646
+ const diffFilterReselect = () => {
1647
+ if (diffVisibleFiles().length === 0) {
1648
+ setDiffSel(0);
1649
+ setDiffText("");
1650
+ } else {
1651
+ selectDiffFile(0);
1652
+ }
1653
+ };
1654
+
1655
+ /** Re-run `git status --porcelain` + both `--numstat`s (and untracked line
1656
+ * counts via async reads), merge counts into the grouped entries, reconcile
1657
+ * the selection, and reload the selected file's diff (so an external edit
1658
+ * is reflected). Fully async; one race token guards the whole merge. */
1200
1659
  const refreshStatus = () => {
1201
- runGit(["status", "--porcelain"], (out) => {
1202
- const files = parseStatusPorcelain(out);
1203
- setDiffFiles(files);
1660
+ const token = ++diffStatusToken;
1661
+ const dir = diffDir();
1662
+ void (async () => {
1663
+ const [statusOut, unstagedOut, stagedOut] = await Promise.all([
1664
+ runGitP(["status", "--porcelain"]),
1665
+ runGitP(["diff", "--numstat"]),
1666
+ runGitP(["diff", "--numstat", "--cached"]),
1667
+ ]);
1668
+ if (token !== diffStatusToken) return;
1669
+ let entries = parseStatusGroups(statusOut);
1670
+ // Untracked ± = the file's line count (binaries skipped; the reads are
1671
+ // capped so a giant fresh tree can't fan out thousands of opens).
1672
+ const untrackedCounts = new Map<string, number>();
1673
+ await Promise.all(
1674
+ entries
1675
+ .filter((e) => e.group === "untracked")
1676
+ .slice(0, 200)
1677
+ .map(async (e) => {
1678
+ try {
1679
+ const bytes = await readFile(join(dir, e.path));
1680
+ if (!isBinary(bytes))
1681
+ untrackedCounts.set(e.path, untrackedLineCount(bytes.toString("utf8")));
1682
+ } catch {
1683
+ /* unreadable: counts stay null */
1684
+ }
1685
+ }),
1686
+ );
1687
+ if (token !== diffStatusToken) return;
1688
+ entries = applyCounts(
1689
+ entries,
1690
+ parseNumstat(stagedOut),
1691
+ parseNumstat(unstagedOut),
1692
+ untrackedCounts,
1693
+ );
1694
+ setDiffEntries(entries);
1695
+ const files = diffVisibleFiles();
1204
1696
  if (files.length === 0) {
1205
1697
  setDiffText("");
1206
1698
  setDiffSel(0);
1207
- setDiffMsg("working tree clean");
1699
+ if (entries.length === 0) setDiffMsg("working tree clean");
1208
1700
  return;
1209
1701
  }
1210
- // Restore: re-select the persisted diff file once it appears in the list.
1702
+ // Re-select a followed file: a verb's target in its NEW group (a staged
1703
+ // file moves to Staged), or the persisted path on restore.
1211
1704
  if (pendingDiffFile) {
1212
- const restored = files.findIndex((f) => f.path === pendingDiffFile);
1705
+ const path = pendingDiffFile;
1706
+ const group = pendingDiffGroup;
1213
1707
  pendingDiffFile = null;
1214
- if (restored !== -1) {
1215
- selectDiffFile(restored);
1708
+ pendingDiffGroup = null;
1709
+ const exact = group ? files.findIndex((f) => f.path === path && f.group === group) : -1;
1710
+ const found = exact !== -1 ? exact : files.findIndex((f) => f.path === path);
1711
+ if (found !== -1) {
1712
+ selectDiffFile(found);
1216
1713
  return;
1217
1714
  }
1218
1715
  }
1219
1716
  const idx = clampSel(diffSel(), files.length);
1220
1717
  setDiffSel(idx);
1221
1718
  loadDiff(files[idx]!);
1719
+ })();
1720
+ };
1721
+
1722
+ // ── Stage/unstage verbs (M24.5) ─────────────────────────────────────────
1723
+ // Reversible operations, so no confirms — each verb notes what it did,
1724
+ // follows the file into its new group, and refreshes (git is the truth).
1725
+ const stageEntry = (e: DiffEntry) => {
1726
+ if (e.group === "staged") {
1727
+ setStatusNote("already staged");
1728
+ return;
1729
+ }
1730
+ runGit(["add", "--", e.path], () => {
1731
+ pendingDiffFile = e.path;
1732
+ pendingDiffGroup = "staged";
1733
+ setStatusNote(`staged ${e.path}`);
1734
+ refreshStatus();
1735
+ });
1736
+ };
1737
+ const unstageEntry = (e: DiffEntry) => {
1738
+ if (e.group !== "staged") {
1739
+ setStatusNote("not staged");
1740
+ return;
1741
+ }
1742
+ runGit(["reset", "HEAD", "--", e.path], () => {
1743
+ pendingDiffFile = e.path;
1744
+ pendingDiffGroup = "unstaged";
1745
+ setStatusNote(`unstaged ${e.path}`);
1746
+ refreshStatus();
1747
+ });
1748
+ };
1749
+ const toggleStageEntry = (e: DiffEntry) =>
1750
+ e.group === "staged" ? unstageEntry(e) : stageEntry(e);
1751
+ const stageAll = () => {
1752
+ const cur = diffVisibleFiles()[diffSel()];
1753
+ runGit(["add", "-A"], () => {
1754
+ if (cur) {
1755
+ pendingDiffFile = cur.path;
1756
+ pendingDiffGroup = "staged";
1757
+ }
1758
+ setStatusNote("staged all changes");
1759
+ refreshStatus();
1760
+ });
1761
+ };
1762
+ const unstageAll = () => {
1763
+ const cur = diffVisibleFiles()[diffSel()];
1764
+ runGit(["reset", "HEAD"], () => {
1765
+ if (cur) {
1766
+ pendingDiffFile = cur.path;
1767
+ pendingDiffGroup = cur.group === "staged" ? "unstaged" : cur.group;
1768
+ }
1769
+ setStatusNote("unstaged all");
1770
+ refreshStatus();
1222
1771
  });
1223
1772
  };
1224
1773
 
1774
+ /** `]`/`[` — jump the diff view to the next/previous hunk header. */
1775
+ const jumpHunk = (dir: 1 | -1) => {
1776
+ const lines = diffLines();
1777
+ const cur = clampTop(diffTop(), lines.length, diffBodyRows());
1778
+ const next = nextHunkTop(lines, cur, dir);
1779
+ if (next !== null) setDiffTop(clampTop(next, lines.length, diffBodyRows()));
1780
+ };
1781
+
1782
+ /** ^e from the diff panel: open the selected file in the editor AT the
1783
+ * first changed line of the selected (top-visible) hunk — pure math in
1784
+ * hunkEditTarget; diffs without hunks (binary/untracked) open at 0. */
1785
+ const openSelectedInEditor = () => {
1786
+ const entry = diffVisibleFiles()[diffSel()];
1787
+ if (!entry) return;
1788
+ const lines = diffLines();
1789
+ const target = hunkEditTarget(lines, clampTop(diffTop(), lines.length, diffBodyRows()));
1790
+ openEditor(join(diffDir(), entry.path), target ?? undefined);
1791
+ };
1792
+
1225
1793
  /** Enter the diff panel for `dir` (from home `d`, the Diff tab, or `--diff`
1226
1794
  * on boot). */
1227
1795
  const enterDiff = (dir: string) => {
@@ -1231,20 +1799,75 @@ render(
1231
1799
  setDiffFileTop(0);
1232
1800
  setDiffText("");
1233
1801
  setDiffMsg("");
1802
+ setDiffFilter(null);
1234
1803
  setTab("diff");
1235
1804
  refreshStatus();
1236
1805
  };
1237
1806
 
1807
+ /** The diff footer's clickable verb chips — laid out from the main column's
1808
+ * first content cell, exactly matching the rendered `paddingLeft={1}
1809
+ * gap={1}` row (shared render↔router). */
1810
+ const diffVerbSpans = createMemo(() =>
1811
+ spans(
1812
+ DIFF_VERBS.map((v) => v.label),
1813
+ sidebarW() + 1,
1814
+ 1,
1815
+ ),
1816
+ );
1817
+ const runDiffVerb = (id: (typeof DIFF_VERBS)[number]["id"]) => {
1818
+ const cur = diffVisibleFiles()[diffSel()];
1819
+ if (id === "stage-all") stageAll();
1820
+ else if (id === "unstage-all") unstageAll();
1821
+ else if (cur && id === "stage") stageEntry(cur);
1822
+ else if (cur && id === "unstage") unstageEntry(cur);
1823
+ };
1824
+ /** A diff file row's right-anchored [s stage]/[u unstage] chip: label by
1825
+ * group, span pinned to the list column's right edge — the same
1826
+ * spansFromRight math the render's flexGrow spacer produces. */
1827
+ const diffRowChipLabel = (e: DiffEntry) =>
1828
+ e.group === "staged" ? DIFF_ROW_CHIP_UNSTAGE : DIFF_ROW_CHIP_STAGE;
1829
+ const diffRowChipSpan = (e: DiffEntry): Span =>
1830
+ spansFromRight([diffRowChipLabel(e)], sidebarW() + diffListW(), 0)[0]!;
1831
+ /** Truncate a diff row's path (keeping the tail — the filename is the
1832
+ * signal) so status + ± counts + the chip (when shown) fit the column. */
1833
+ const diffRowPath = (e: DiffEntry, chip: boolean): string => {
1834
+ const addW = (e.additions ?? 0) > 0 ? `+${e.additions}`.length + 1 : 0;
1835
+ const delW = (e.deletions ?? 0) > 0 ? `-${e.deletions}`.length + 1 : 0;
1836
+ const budget = Math.max(
1837
+ 4,
1838
+ diffListW() - 4 - addW - delW - (chip ? diffRowChipLabel(e).length + 1 : 0),
1839
+ );
1840
+ return e.path.length > budget ? "…" + e.path.slice(-(budget - 1)) : e.path;
1841
+ };
1842
+
1238
1843
  let mirror: SessionMirror | null = null;
1844
+ // ── EVENT-DRIVEN RE-PIN (M23.5) ──────────────────────────────────────────
1845
+ // The 200ms canvas size poll is gone: a createEffect on the renderer dims
1846
+ // signal (canvasCols/canvasRows read dims() + sidebarW()) re-pins the
1847
+ // mirror the moment the size actually changes. `lastPin` is what we last
1848
+ // asked tmux for; `repinInFlight` gates the size-truth hint while tmux
1849
+ // confirms (D4b — see the tick).
1850
+ let lastPin: Size = { cols: canvasCols(), rows: canvasRows() };
1851
+ let repinInFlight: RepinState | null = null;
1852
+ createEffect(() => {
1853
+ const next: Size = { cols: canvasCols(), rows: canvasRows() };
1854
+ if (next.cols === lastPin.cols && next.rows === lastPin.rows) return;
1855
+ repinInFlight = { prev: lastPin, at: performance.now() };
1856
+ lastPin = next;
1857
+ void mirror?.resize(next.cols, next.rows);
1858
+ });
1239
1859
  const attach = (name: string) => {
1240
1860
  mirror?.dispose();
1241
1861
  scrollOffsets.clear();
1242
1862
  setPanes([]);
1243
1863
  setStatus(`attaching ${name}…`);
1864
+ // A fresh mirror pins at the current canvas size — no re-pin in flight.
1865
+ lastPin = { cols: canvasCols(), rows: canvasRows() };
1866
+ repinInFlight = null;
1244
1867
  const m = new SessionMirror({
1245
1868
  target: name,
1246
- cols: canvasCols(),
1247
- rows: canvasRows(),
1869
+ cols: lastPin.cols,
1870
+ rows: lastPin.rows,
1248
1871
  onDirty: markDirty,
1249
1872
  onStatus: () => {
1250
1873
  markDirty();
@@ -1276,27 +1899,45 @@ render(
1276
1899
  clearSelection();
1277
1900
  if (name === curTarget() && mirror) {
1278
1901
  setTab("terminal");
1902
+ refreshFocusRecord();
1279
1903
  return;
1280
1904
  }
1281
1905
  setCurTarget(name);
1282
1906
  setTab("terminal");
1283
1907
  attach(name);
1908
+ refreshFocusRecord();
1284
1909
  };
1285
1910
  /** ^g / F1 — show the HOME tab. The mirror is KEPT ALIVE (it keeps streaming
1286
1911
  * in the background so a back-switch is instant); the session is untouched. */
1287
1912
  const goHome = () => {
1288
1913
  clearSelection();
1289
1914
  setTab("home");
1915
+ refreshFocusRecord();
1290
1916
  };
1291
1917
 
1292
- // ── FILES TAB (M18.4) ────────────────────────────────────────────────────
1293
- // A minimal, one-level-expandable file list (left) beside the M18.2 editor
1294
- // (right), rooted at the workspace dir. `fs.readdir` is ALWAYS async (the
1295
- // render-loop landmine); the flat-tree splice/prune math is pure in
1296
- // file-tree.ts.
1918
+ // ── FILES TAB (M18.4, uplifted M24.6) ────────────────────────────────────
1919
+ // An expandable file list (left) beside the M18.2 editor (right), rooted at
1920
+ // the workspace dir. `fs.readdir` and every git call are ALWAYS async (the
1921
+ // render-loop landmine); ordering, ignore/hidden filtering, git-status
1922
+ // decoration, the changed-file walk, the `/` filter view and the expansion-
1923
+ // preserving rebuild are all pure in file-tree.ts. Ignore rules come from
1924
+ // the workspace's .gitignore via the `ignore` package (matching is pure;
1925
+ // only the file read is io). SELECTION indexes the VISIBLE (filtered) rows;
1926
+ // expansion math maps back to the underlying flat list via FilteredRow.index.
1297
1927
  const [fileNodes, setFileNodes] = createSignal<FileNode[]>([]);
1298
1928
  const [fileSel, setFileSel] = createSignal(0);
1299
1929
  const [fileTop, setFileTop] = createSignal(0);
1930
+ // The H / I visibility toggles — persisted in app-state (default: hidden).
1931
+ const [showHiddenFiles, setShowHiddenFiles] = createSignal(persisted.filesShowHidden);
1932
+ const [showIgnoredFiles, setShowIgnoredFiles] = createSignal(persisted.filesShowIgnored);
1933
+ // Git decoration: porcelain entries for the workspace repo + its toplevel
1934
+ // (status paths are REPO-relative — the workspace dir may sit deeper).
1935
+ const [fileStatusEntries, setFileStatusEntries] = createSignal<StatusEntry[]>([]);
1936
+ const [filesGitTop, setFilesGitTop] = createSignal<string | null>(null);
1937
+ // The `/` filter: null = off, "" = filter mode just opened. The selection
1938
+ // to restore when the filter is escaped lives beside it (not reactive).
1939
+ const [filesQuery, setFilesQuery] = createSignal<string | null>(null);
1940
+ let filesPreFilterPath: string | null = null;
1300
1941
  // Which half of the Files tab has the keyboard: the file LIST (j/k/enter) or
1301
1942
  // the EDITOR (typing). Opening a file hands focus to the editor; esc hands it
1302
1943
  // back to the list.
@@ -1304,60 +1945,278 @@ render(
1304
1945
  const filesListW = () => Math.max(20, Math.min(44, Math.floor(canvasCols() * 0.34)));
1305
1946
  /** The workspace directory driving both the file list and the diff panel. */
1306
1947
  const workspaceDir = () => contextDir() || invokeCwd;
1948
+ const fileStatusMap = createMemo(() => statusMapFromEntries(fileStatusEntries()));
1949
+ const changedWalk = createMemo(() =>
1950
+ changedFileWalk(fileStatusEntries(), { showHidden: showHiddenFiles() }),
1951
+ );
1952
+ /** The rows the list actually shows: the flat tree through the `/` filter. */
1953
+ const visibleFiles = createMemo(() => filterView(fileNodes(), filesQuery()));
1307
1954
  const fileListVisible = createMemo(() => {
1308
- const nodes = fileNodes();
1309
- const rows = editorRows();
1310
- const top = clampTop(fileTop(), nodes.length, rows);
1311
- return nodes.slice(top, top + rows).map((node, i) => ({ node, index: top + i }));
1955
+ const rows = visibleFiles();
1956
+ const view = editorRows();
1957
+ const top = clampTop(fileTop(), rows.length, view);
1958
+ return rows.slice(top, top + view).map((row, i) => ({ node: row.node, index: top + i }));
1312
1959
  });
1960
+ /** The status letter for a node (repo-relative lookup incl. propagated
1961
+ * ancestor letters), or null outside a repo / for a clean path. */
1962
+ const fileStatusFor = (n: FileNode): string | null => {
1963
+ const top = filesGitTop();
1964
+ if (!top) return null;
1965
+ const rel = relPath(top, n.path);
1966
+ return rel ? (fileStatusMap().get(rel) ?? null) : null;
1967
+ };
1313
1968
 
1314
- const toRaw = (e: { name: string; isDirectory: () => boolean }): RawEntry => ({
1315
- name: e.name,
1316
- isDir: e.isDirectory(),
1317
- });
1318
- /** (Re)load the top-level listing for `dir` (async). Directories that were
1319
- * expanded collapse back to the fresh root — a lean v1 (deep re-expansion is
1320
- * a later nicety). */
1969
+ // The gitignore matcher for the CURRENT workspace root (reloaded when the
1970
+ // root changes or the tree refreshes — cheap: one small file read).
1971
+ let filesIg: Ignore = ignore();
1972
+ let filesIgDir = "";
1973
+ const loadIgnoreRules = async (root: string): Promise<void> => {
1974
+ const ig = ignore();
1975
+ try {
1976
+ ig.add(await readFile(join(root, ".gitignore"), "utf8"));
1977
+ } catch {
1978
+ // no .gitignore — everything passes
1979
+ }
1980
+ filesIg = ig;
1981
+ filesIgDir = root;
1982
+ };
1983
+ /** Async list of `dir`, annotated with the gitignore verdict and filtered
1984
+ * through the current H/I toggles (pure filterEntries). */
1985
+ const listDir = async (dir: string): Promise<RawEntry[]> => {
1986
+ const root = workspaceDir();
1987
+ if (filesIgDir !== root) await loadIgnoreRules(root);
1988
+ const ents = await readdir(dir, { withFileTypes: true });
1989
+ const raw: RawEntry[] = ents.map((e) => {
1990
+ const isDir = e.isDirectory();
1991
+ const rel = relPath(root, join(dir, e.name));
1992
+ let ignored = false;
1993
+ if (rel) {
1994
+ try {
1995
+ ignored = filesIg.ignores(isDir ? `${rel}/` : rel);
1996
+ } catch {
1997
+ // malformed path for the matcher — treat as not ignored
1998
+ }
1999
+ }
2000
+ return { name: e.name, isDir, ignored };
2001
+ });
2002
+ return filterEntries(raw, {
2003
+ showHidden: showHiddenFiles(),
2004
+ showIgnored: showIgnoredFiles(),
2005
+ });
2006
+ };
2007
+
2008
+ /** Re-run `git status --porcelain` for the workspace (async; also resolves
2009
+ * the repo toplevel the porcelain paths are relative to). */
2010
+ const runGitFiles = (args: string[], cb: (out: string) => void) => {
2011
+ execFile(
2012
+ "git",
2013
+ ["-C", workspaceDir(), "-c", "core.quotepath=false", "-c", "core.fsmonitor=false", ...args],
2014
+ { timeout: 10_000, maxBuffer: 16_000_000 },
2015
+ (err, stdout) => cb(err ? "" : stdout),
2016
+ );
2017
+ };
2018
+ const refreshFileStatus = () => {
2019
+ runGitFiles(["rev-parse", "--show-toplevel"], (top) => {
2020
+ const t = top.trim();
2021
+ setFilesGitTop(t || null);
2022
+ if (!t) {
2023
+ setFileStatusEntries([]);
2024
+ return;
2025
+ }
2026
+ runGitFiles(["status", "--porcelain"], (out) =>
2027
+ setFileStatusEntries(parseStatusPorcelain(out)),
2028
+ );
2029
+ });
2030
+ };
2031
+
2032
+ /** (Re)load the top-level listing for `dir` (async), reset the filter and
2033
+ * selection, refresh the git decoration, and (re)arm the watcher. */
1321
2034
  const loadFileList = (dir: string) => {
1322
- void readdir(dir, { withFileTypes: true })
1323
- .then((ents) => {
1324
- setFileNodes(buildNodes(dir, ents.map(toRaw), 0));
1325
- setFileSel(0);
1326
- setFileTop(0);
2035
+ void loadIgnoreRules(workspaceDir()).then(() =>
2036
+ listDir(dir)
2037
+ .then((ents) => {
2038
+ setFileNodes(buildNodes(dir, ents, 0));
2039
+ setFileSel(0);
2040
+ setFileTop(0);
2041
+ setFilesQuery(null);
2042
+ })
2043
+ .catch(() => setFileNodes([])),
2044
+ );
2045
+ refreshFileStatus();
2046
+ ensureFilesWatch(workspaceDir());
2047
+ };
2048
+
2049
+ /** Expansion-preserving refresh: re-read the root + every expanded dir with
2050
+ * the CURRENT toggles, rebuild the flat tree (pure), keep the selection on
2051
+ * the same path where it survived. Used by the watcher push, the H/I
2052
+ * toggles, `r`, and the file-mutation menu verbs. */
2053
+ let treeRefreshBusy = false;
2054
+ const refreshTree = () => {
2055
+ if (treeRefreshBusy) return;
2056
+ treeRefreshBusy = true;
2057
+ const root = workspaceDir();
2058
+ const keepPath = visibleFiles()[fileSel()]?.node.path ?? null;
2059
+ const expanded = new Set(
2060
+ fileNodes()
2061
+ .filter((n) => n.isDir && n.expanded)
2062
+ .map((n) => n.path),
2063
+ );
2064
+ // Fresh rules first (the .gitignore itself may have changed), then every
2065
+ // still-relevant dir in parallel; failed reads (vanished dirs) drop out.
2066
+ void loadIgnoreRules(root)
2067
+ .then(() =>
2068
+ Promise.all(
2069
+ [root, ...expanded].map(async (d) => [d, await listDir(d).catch(() => null)] as const),
2070
+ ),
2071
+ )
2072
+ .then((pairs) => {
2073
+ if (root !== workspaceDir()) return; // workspace moved on mid-flight
2074
+ const listing = new Map<string, RawEntry[]>();
2075
+ for (const [d, ents] of pairs) if (ents) listing.set(d, ents);
2076
+ setFileNodes(rebuildTree(root, listing, expanded));
2077
+ const rows = visibleFiles();
2078
+ const idx = keepPath ? rows.findIndex((r) => r.node.path === keepPath) : -1;
2079
+ const sel = idx !== -1 ? idx : clampSel(fileSel(), Math.max(1, rows.length));
2080
+ setFileSel(sel);
2081
+ setFileTop((t) => scrollToCursor(sel, t, editorRows(), rows.length));
1327
2082
  })
1328
- .catch(() => {
1329
- setFileNodes([]);
2083
+ .finally(() => {
2084
+ treeRefreshBusy = false;
1330
2085
  });
2086
+ refreshFileStatus();
2087
+ };
2088
+
2089
+ const toggleHiddenFiles = () => {
2090
+ setShowHiddenFiles((v) => !v);
2091
+ refreshTree();
1331
2092
  };
2093
+ const toggleIgnoredFiles = () => {
2094
+ setShowIgnoredFiles((v) => !v);
2095
+ refreshTree();
2096
+ };
2097
+
1332
2098
  const moveFileSel = (delta: number) => {
1333
- const nodes = fileNodes();
1334
- if (nodes.length === 0) return;
1335
- const idx = clampSel(fileSel() + delta, nodes.length);
2099
+ const rows = visibleFiles();
2100
+ if (rows.length === 0) return;
2101
+ const idx = clampSel(fileSel() + delta, rows.length);
1336
2102
  setFileSel(idx);
1337
- setFileTop((t) => scrollToCursor(idx, t, editorRows(), nodes.length));
1338
- };
1339
- /** Enter on a row: open a file in the editor, or toggle a directory (async
1340
- * readdir → splice children in, or prune the subtree). */
1341
- const activateFile = (index: number) => {
1342
- const node = fileNodes()[index];
1343
- if (!node) return;
1344
- setFileSel(index);
2103
+ setFileTop((t) => scrollToCursor(idx, t, editorRows(), rows.length));
2104
+ };
2105
+ /** Enter on a VISIBLE row: open a file in the editor, or toggle a directory
2106
+ * (async readdir → splice children in, or prune the subtree). Expansion
2107
+ * applies at the row's UNDERLYING index, re-resolved by path at apply time
2108
+ * (a watcher refresh may have reshaped the list under a slow readdir). */
2109
+ const activateFile = (visIndex: number) => {
2110
+ const row = visibleFiles()[visIndex];
2111
+ if (!row) return;
2112
+ setFileSel(visIndex);
2113
+ const node = row.node;
1345
2114
  if (!node.isDir) {
1346
2115
  openEditor(node.path);
1347
2116
  return;
1348
2117
  }
1349
2118
  if (node.expanded) {
1350
- setFileNodes((list) => removeSubtreeAt(list, index));
2119
+ setFileNodes((list) => removeSubtreeAt(list, indexOfPath(list, node.path)));
1351
2120
  return;
1352
2121
  }
1353
- void readdir(node.path, { withFileTypes: true })
2122
+ void listDir(node.path)
1354
2123
  .then((ents) => {
1355
- const children = buildNodes(node.path, ents.map(toRaw), node.depth + 1);
1356
- setFileNodes((list) => insertChildrenAt(list, index, children));
2124
+ const children = buildNodes(node.path, ents, node.depth + 1);
2125
+ setFileNodes((list) => insertChildrenAt(list, indexOfPath(list, node.path), children));
1357
2126
  })
1358
2127
  .catch(() => {});
1359
2128
  };
1360
2129
 
2130
+ /** Reveal `absPath` in the tree: expand each collapsed ancestor in turn
2131
+ * (async readdirs), then select the row. Bails silently when a segment is
2132
+ * filtered out of view (the changed walk pre-filters what it offers). */
2133
+ const revealPath = async (absPath: string): Promise<void> => {
2134
+ const root = workspaceDir();
2135
+ const rel = relPath(root, absPath);
2136
+ if (!rel) return;
2137
+ for (const anc of ancestorDirs(rel)) {
2138
+ const ancAbs = join(root, anc);
2139
+ const idx = indexOfPath(fileNodes(), ancAbs);
2140
+ const node = fileNodes()[idx];
2141
+ if (!node || !node.isDir) return;
2142
+ if (!node.expanded) {
2143
+ const ents = await listDir(ancAbs).catch(() => null);
2144
+ if (!ents) return;
2145
+ const children = buildNodes(ancAbs, ents, node.depth + 1);
2146
+ setFileNodes((list) => insertChildrenAt(list, indexOfPath(list, ancAbs), children));
2147
+ }
2148
+ }
2149
+ const idx = indexOfPath(fileNodes(), absPath);
2150
+ if (idx === -1) return;
2151
+ setFileSel(idx);
2152
+ setFileTop((t) => scrollToCursor(idx, t, editorRows(), visibleFiles().length));
2153
+ };
2154
+
2155
+ /** `[` / `]` — hop the selection to the prev/next CHANGED file (tree display
2156
+ * order, wrapping), auto-expanding collapsed ancestors. Clears the `/`
2157
+ * filter first: the hop is defined on the whole tree. */
2158
+ const hopChanged = (dir: 1 | -1) => {
2159
+ const top = filesGitTop();
2160
+ const walk = changedWalk();
2161
+ if (!top || walk.length === 0) return;
2162
+ if (filesQuery() !== null) setFilesQuery(null);
2163
+ const cur = visibleFiles()[fileSel()]?.node ?? null;
2164
+ const curRel = cur ? relPath(top, cur.path) || null : null;
2165
+ const next = nextChangedPath(walk, curRel, dir);
2166
+ if (next) void revealPath(join(top, next));
2167
+ };
2168
+
2169
+ // ── WATCHER PUSH REFRESH (M24.6) ─────────────────────────────────────────
2170
+ // widgets/lib/watcher.ts (@parcel/watcher; fs.watch fallback in the compiled
2171
+ // binary) — measured working under bun in this process (import + events).
2172
+ // Events refresh the visible tree expansion-preservingly; while the Files
2173
+ // tab is backgrounded they only mark it stale (refreshed on tab return).
2174
+ // The watcher ignores .git, so index-only changes (external staging) ride
2175
+ // the 3s status poll armed in onMount instead.
2176
+ let stopFilesWatch: (() => Promise<void>) | null = null;
2177
+ let filesWatchDir = "";
2178
+ let filesStale = false;
2179
+ const onFilesWatchEvent = () => {
2180
+ if (mode() === "editor") {
2181
+ refreshTree();
2182
+ } else {
2183
+ filesStale = true;
2184
+ }
2185
+ };
2186
+ const ensureFilesWatch = (root: string) => {
2187
+ if (filesWatchDir === root) return;
2188
+ filesWatchDir = root;
2189
+ const prev = stopFilesWatch;
2190
+ stopFilesWatch = null;
2191
+ void prev?.().catch(() => {});
2192
+ void watchDirectory(root, onFilesWatchEvent, { ignore: [...ALWAYS_IGNORE] })
2193
+ .then((stop) => {
2194
+ if (filesWatchDir !== root) {
2195
+ void stop().catch(() => {});
2196
+ return;
2197
+ }
2198
+ stopFilesWatch = stop;
2199
+ })
2200
+ .catch(() => {
2201
+ // watcher unavailable — the Files-tab status poll still runs, and
2202
+ // `r` / toggles / mutations refresh on demand.
2203
+ });
2204
+ };
2205
+ onCleanup(() => void stopFilesWatch?.().catch(() => {}));
2206
+ /** A tab switch back onto a stale Files surface catches up in one shot. */
2207
+ const catchUpFilesIfStale = () => {
2208
+ if (!filesStale) return;
2209
+ filesStale = false;
2210
+ refreshTree();
2211
+ };
2212
+ // Status letters must also follow index-only changes (external staging),
2213
+ // which the directory watcher never sees — a light poll while the Files
2214
+ // tab is active, mirroring the diff panel's 3s discipline.
2215
+ const filesStatusPoll = setInterval(() => {
2216
+ if (mode() === "editor" && fileNodes().length > 0) refreshFileStatus();
2217
+ }, 3000);
2218
+ onCleanup(() => clearInterval(filesStatusPoll));
2219
+
1361
2220
  /** The project dir the fleet payload records for `session` (null if unknown). */
1362
2221
  const dirForSession = (name: string): string | null => {
1363
2222
  for (const p of projectsData()) if (p.sessions.some((s) => s.name === name)) return p.dir;
@@ -1395,6 +2254,21 @@ render(
1395
2254
  });
1396
2255
  };
1397
2256
 
2257
+ /** FRONT DOOR (M25.1): a session the app itself creates is WATCHED — the
2258
+ * adopted marker is stamped (inert re: chrome painting; none of adopt's
2259
+ * status-row/border options are set — see ../chrome/front-door.ts) and
2260
+ * the background updater is ensured up, probed the way adopt probes. So
2261
+ * pure app users get blocked/done notifications without ever running
2262
+ * `adopt`, with zero visible dock changes. Async execFile only (the
2263
+ * render-loop law); everything best-effort. */
2264
+ const watchCreatedSession = (name: string) => {
2265
+ execFile("tmux", adoptMarkArgv(name), () => {
2266
+ execFile("tmux", updaterProbeArgv(), (probeErr) => {
2267
+ if (probeErr) execFile("tmux", updaterSpawnArgv(), () => {});
2268
+ });
2269
+ });
2270
+ };
2271
+
1398
2272
  /** Create a detached session named `name` in `dir` and open it as the
1399
2273
  * workspace (M21.9 — the home "launch project" / "new session" verbs).
1400
2274
  * ASYNC execFile only (the render-loop law); an already-existing session
@@ -1409,6 +2283,7 @@ render(
1409
2283
  }
1410
2284
  if (!err) {
1411
2285
  execFile("tmux", ["set-environment", "-t", name, "TMUX_IDE", "1"], () => {});
2286
+ watchCreatedSession(name);
1412
2287
  setStatusNote(`launched ${name}`);
1413
2288
  }
1414
2289
  fleetRefresh?.();
@@ -1416,6 +2291,353 @@ render(
1416
2291
  });
1417
2292
  };
1418
2293
 
2294
+ // ── AGENT LIFECYCLE (M23.1) — spawn / restart / stop / close ────────────
2295
+ // The verbs go to tmux DIRECTLY (async execFile — the render-loop law, and
2296
+ // the target agent may live in a session the mirror isn't attached to).
2297
+ // The kind list / launch commands / exact argv are pure in
2298
+ // agent-lifecycle.ts; only the dialog flows and the io live here.
2299
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
2300
+ /** One awaited tmux call; errors are swallowed (a dead pane target is a
2301
+ * normal race — the fleet poll shows the truth moments later). */
2302
+ const tmuxRun = (args: string[]) =>
2303
+ new Promise<void>((resolve) => execFile("tmux", args, () => resolve()));
2304
+
2305
+ /** Out-of-band stop hygiene: killing an agent ourselves fires NO lifecycle
2306
+ * hook (a clean exit's SessionEnd stamps idle), so a working/blocked
2307
+ * authority stamp would keep lying until the 10-minute staleness guard.
2308
+ * Unset both pane options — the same "no authority" end state. */
2309
+ const clearAgentAuthority = async (paneId: string) => {
2310
+ for (const args of clearAuthorityArgs(paneId)) await tmuxRun(args);
2311
+ };
2312
+
2313
+ /** ctrl-c TWICE: TUI agents (claude, codex) treat a single ^c as "clear
2314
+ * input / cancel turn" and only a quick second one as exit; a plain
2315
+ * process ignores the repeat. */
2316
+ const interruptAgent = async (paneId: string) => {
2317
+ await tmuxRun(interruptArgs(paneId));
2318
+ await sleep(INTERRUPT_TAP_GAP_MS);
2319
+ await tmuxRun(interruptArgs(paneId));
2320
+ };
2321
+
2322
+ const stopAgentFlow = async (a: Pick<AgentRowInput, "paneId" | "kind">) => {
2323
+ const ok = await DialogConfirm.show({
2324
+ title: `Stop ${a.kind}?`,
2325
+ body: "Interrupts the agent (ctrl-c). The pane and its shell stay open.",
2326
+ yesLabel: "Stop it",
2327
+ noLabel: "Cancel",
2328
+ defaultNo: true,
2329
+ });
2330
+ if (!ok) return;
2331
+ await interruptAgent(a.paneId);
2332
+ await clearAgentAuthority(a.paneId);
2333
+ setStatusNote(`stopped ${a.kind}`);
2334
+ setTimeout(() => fleetRefresh?.(), 500);
2335
+ };
2336
+
2337
+ /** The pane's `pane_start_command` (its ROOT: "" = default shell) +
2338
+ * `pane_current_path` + our `@agent_launch` stamp (M24.1 — the exact argv
2339
+ * our spawn verb ran, the preferred relaunch source), or null when the
2340
+ * pane is gone. One async display call (tab-joined; the stamp rides LAST
2341
+ * and re-joins, so a command containing tabs survives). NOT
2342
+ * pane_current_command — that is the FOREGROUND process, so a user-typed
2343
+ * `claude` under zsh would read as `claude` too and be indistinguishable
2344
+ * from a pane-command agent (measured). */
2345
+ const paneStartAndPath = (paneId: string) =>
2346
+ new Promise<{ start: string; path: string; launch: string } | null>((resolve) =>
2347
+ execFile(
2348
+ "tmux",
2349
+ [
2350
+ "display",
2351
+ "-p",
2352
+ "-t",
2353
+ paneId,
2354
+ "#{pane_start_command}\t#{pane_current_path}\t#{@agent_launch}",
2355
+ ],
2356
+ (err, stdout) => {
2357
+ if (err) return resolve(null);
2358
+ const [start = "", path = "", ...rest] = stdout.trimEnd().split("\t");
2359
+ resolve({ start, path, launch: rest.join("\t") });
2360
+ },
2361
+ ),
2362
+ );
2363
+
2364
+ /** Two restart strategies, picked by what the pane's ROOT process is:
2365
+ * shell-hosted agents get ctrl-c + relaunch via send-keys (the shell
2366
+ * survives to type into); when the agent IS the pane's own process (our
2367
+ * spawn verb's panes), ctrl-c would end the pane — respawn it in place
2368
+ * instead (same pane id, cwd pinned explicitly). Both paths clear the
2369
+ * authority stamps. */
2370
+ const restartAgentFlow = async (a: Pick<AgentRowInput, "paneId" | "kind">) => {
2371
+ const manifests = getManifests();
2372
+ const live = await paneStartAndPath(a.paneId);
2373
+ if (!live) {
2374
+ setStatusNote("that pane is gone — refreshing");
2375
+ setTimeout(() => fleetRefresh?.(), 300);
2376
+ return;
2377
+ }
2378
+ // The @agent_launch stamp (our own spawn's exact argv — flags included)
2379
+ // beats the kind's generic launch command when present (M24.1).
2380
+ const command = live.launch || launchCommandFor(a.kind, manifests);
2381
+ const underShell = paneHostsShell(live.start, manifests);
2382
+ const ok = await DialogConfirm.show({
2383
+ title: `Restart ${a.kind}?`,
2384
+ body: underShell
2385
+ ? `Stops it with ctrl-c, waits a moment, then runs "${command}" again in the same pane.`
2386
+ : `The agent is this pane's own process, so the pane is relaunched in place running "${command}".`,
2387
+ yesLabel: "Restart it",
2388
+ noLabel: "Cancel",
2389
+ defaultNo: true,
2390
+ });
2391
+ if (!ok) return;
2392
+ if (underShell) {
2393
+ await interruptAgent(a.paneId);
2394
+ await clearAgentAuthority(a.paneId);
2395
+ await sleep(RESTART_GRACE_MS);
2396
+ for (const args of relaunchArgs(a.paneId, command)) await tmuxRun(args);
2397
+ } else {
2398
+ await clearAgentAuthority(a.paneId);
2399
+ await tmuxRun(respawnArgs(a.paneId, command, live.path || null));
2400
+ }
2401
+ setStatusNote(`restarted ${a.kind}`);
2402
+ setTimeout(() => fleetRefresh?.(), 1500);
2403
+ };
2404
+
2405
+ /** The destructive twin of stop: kill the agent's pane. Confirmation is the
2406
+ * caller's job (the menu's armed "confirm: y" state). The pane's options
2407
+ * die with it, so no authority cleanup is needed. */
2408
+ const closeAgentPane = (a: Pick<AgentRowInput, "paneId" | "kind">) => {
2409
+ execFile("tmux", ["kill-pane", "-t", a.paneId], () =>
2410
+ setTimeout(() => fleetRefresh?.(), 300),
2411
+ );
2412
+ setStatusNote(`closed ${a.kind}'s pane`);
2413
+ };
2414
+
2415
+ // ── THE SPAWN FLOW (M24.1 — one dialog, defaults everywhere) ─────────────
2416
+ // The flow never ASKS what it can default: ONE kind picker whose Enter
2417
+ // spawns at the context's default placement (split right of a focused
2418
+ // pane / a new window in the session / a fresh session for project rows);
2419
+ // placement ALTERNATIVES are footer ctrl-actions, never a second dialog.
2420
+ // The picker's TOP row repeats the last spawn remembered for this context
2421
+ // (per project/session-dir, app-state), custom commands keep a global
2422
+ // recents list, and DialogPrompt only ever appears for a brand-new custom
2423
+ // command. Detection needs no extra wiring: the spawned pane's command IS
2424
+ // the agent, so the next fleet poll classifies it.
2425
+ interface NewAgentContext {
2426
+ session?: string;
2427
+ dir: string | null;
2428
+ paneId?: string;
2429
+ /** Names the fresh session when there is no live one (project rows). */
2430
+ sessionName?: string;
2431
+ }
2432
+ /** The context's shape for the pure placement decisions. */
2433
+ const spawnShape = (ctx: NewAgentContext) => ({
2434
+ pane: ctx.paneId !== undefined,
2435
+ session: ctx.session !== undefined,
2436
+ });
2437
+ /** The context's "again"-memory key + remembered spawn (null when none). */
2438
+ const spawnMemoryFor = (
2439
+ ctx: NewAgentContext,
2440
+ ): { key: string | null; last: LastSpawn | null } => {
2441
+ const key = spawnMemoryKey(ctx.dir, ctx.session ?? ctx.sessionName);
2442
+ return { key, last: key ? (lastSpawns()[key] ?? null) : null };
2443
+ };
2444
+ /** ASYNC — a pane's `#{pane_current_path}`, or null when unreadable. */
2445
+ const paneCurrentPath = (paneId: string) =>
2446
+ new Promise<string | null>((resolve) =>
2447
+ execFile("tmux", ["display", "-p", "-t", paneId, "#{pane_current_path}"], (err, stdout) =>
2448
+ resolve(err ? null : stdout.trim() || null),
2449
+ ),
2450
+ );
2451
+ /** Run ONE spawn: resolve the cwd policy (Terminal-surface spawns inherit
2452
+ * the FOCUSED pane's cwd under `app.newAgentCwd: "pane"`, the default),
2453
+ * build the argv (`-P -F` returns the new pane id), then — in the same
2454
+ * breath — auto-label the pane/window after the agent and stamp
2455
+ * `@agent_launch` with the exact command, and remember the spawn for the
2456
+ * again row / palette action / custom recents. */
2457
+ const runSpawn = async (
2458
+ ctx: NewAgentContext,
2459
+ choice: { kind: string; command: string; placement: SpawnWhere },
2460
+ ) => {
2461
+ const { kind, command, placement } = choice;
2462
+ let dir = ctx.dir;
2463
+ if (ctx.paneId && loadAppConfig().app.newAgentCwd === "pane") {
2464
+ dir = (await paneCurrentPath(ctx.paneId)) ?? ctx.dir;
2465
+ }
2466
+ const label = spawnLabelFor(kind, command);
2467
+ // Remember FIRST (fire-and-forget spawn callbacks shouldn't gate it):
2468
+ // the again memory is keyed per project/session-dir, custom argv joins
2469
+ // the global recents.
2470
+ const { key } = spawnMemoryFor(ctx);
2471
+ if (key) setLastSpawns((m) => rememberSpawn(m, key, { kind, command, placement }));
2472
+ if (kind === CUSTOM_KIND_ID) setCustomCommands((l) => addCustomCommand(l, command));
2473
+ /** Post-spawn follow-ups against the printed pane id: title the pane
2474
+ * (or its window), stamp the launch argv. Best-effort, async. */
2475
+ const decorate = (stdout: string) => {
2476
+ const paneId = stdout.trim();
2477
+ if (!paneId.startsWith("%")) return;
2478
+ const labelArgs =
2479
+ placement === "window" ? labelWindowArgs(paneId, label) : labelPaneArgs(paneId, label);
2480
+ execFile("tmux", labelArgs, () => {});
2481
+ execFile("tmux", stampLaunchArgs(paneId, command), () => {});
2482
+ };
2483
+ if (placement === "session" || !ctx.session) {
2484
+ const base = ctx.sessionName ?? basename(ctx.dir ?? invokeCwd);
2485
+ const name = sessionNameFor(base || "agents");
2486
+ execFile("tmux", spawnSessionArgs(name, dir, command), (err, stdout) => {
2487
+ setStatusNote(err ? `couldn't start ${command}` : `started ${command} in ${name}`);
2488
+ if (!err) {
2489
+ execFile("tmux", ["set-environment", "-t", name, "TMUX_IDE", "1"], () => {});
2490
+ watchCreatedSession(name);
2491
+ decorate(stdout);
2492
+ }
2493
+ setTimeout(() => fleetRefresh?.(), 300);
2494
+ });
2495
+ return;
2496
+ }
2497
+ const target = { session: ctx.session, paneId: ctx.paneId };
2498
+ const args = spawnAgentArgs(placement as SpawnPlacement, target, dir, command);
2499
+ execFile("tmux", args, (err, stdout) => {
2500
+ setStatusNote(err ? `couldn't start ${command}` : `started ${command} in ${ctx.session}`);
2501
+ if (!err) {
2502
+ decorate(stdout);
2503
+ // The agent's session must be watched for its blocked/done pings to
2504
+ // exist — front-door sessions were stamped at create, but a spawn
2505
+ // can target a pre-existing, never-adopted session too.
2506
+ watchCreatedSession(target.session);
2507
+ }
2508
+ setTimeout(() => fleetRefresh?.(), 300);
2509
+ });
2510
+ };
2511
+ const newAgentFlow = async (ctx: NewAgentContext) => {
2512
+ setHoverIf(null); // the overlay owns the pointer, like the palette
2513
+ const manifests = getManifests();
2514
+ const shape = spawnShape(ctx);
2515
+ const fallback = defaultSpawnPlacement(shape);
2516
+ const { last } = spawnMemoryFor(ctx);
2517
+ // The again row replays its remembered placement where the context still
2518
+ // allows it (a remembered split needs a focused pane); else the default.
2519
+ const againPlacement =
2520
+ last && compatiblePlacement(last.placement, shape) ? last.placement : fallback;
2521
+ const res = await DialogSelect.show({
2522
+ title: "New agent",
2523
+ items: newAgentItems({ manifests, last, againPlacement, customRecents: customCommands() }),
2524
+ actions: placementActions(shape),
2525
+ footerHint: `enter: ${placementLabel(fallback)}`,
2526
+ });
2527
+ if (!res) return;
2528
+ let kind: string;
2529
+ let command: string;
2530
+ const recentIdx = customRecentIndex(res.item.id);
2531
+ if (res.item.id === AGAIN_ID && last) {
2532
+ kind = last.kind;
2533
+ command = last.command;
2534
+ } else if (recentIdx !== null) {
2535
+ kind = CUSTOM_KIND_ID;
2536
+ command = customCommands()[recentIdx] ?? "";
2537
+ if (!command) return;
2538
+ } else if (res.item.id === CUSTOM_KIND_ID) {
2539
+ const typed = await DialogPrompt.show({
2540
+ title: "Custom command",
2541
+ placeholder: "my-agent --flag",
2542
+ footerHint: "runs as the new pane's command",
2543
+ validate: (v) => (v.trim().length > 0 ? null : "Type a command, or press esc to go back"),
2544
+ });
2545
+ if (typed === null) return;
2546
+ kind = CUSTOM_KIND_ID;
2547
+ command = typed.trim();
2548
+ } else {
2549
+ kind = res.item.id;
2550
+ command = launchCommandFor(res.item.id, manifests);
2551
+ }
2552
+ // WHERE: Enter keeps the default (the again row: its remembered
2553
+ // placement); a footer ctrl-action (^w / ^d) overrides.
2554
+ const base = res.item.id === AGAIN_ID ? againPlacement : fallback;
2555
+ await runSpawn(ctx, { kind, command, placement: resolvePlacement(base, res.action) });
2556
+ };
2557
+ /** Repeat the current context's remembered spawn DIRECTLY — the palette's
2558
+ * "New agent: <kind> (again)" action (no dialog). Falls through to the
2559
+ * full flow when nothing is remembered (shouldn't happen — the action is
2560
+ * only offered with memory). */
2561
+ const newAgentAgain = (ctx: NewAgentContext) => {
2562
+ const { last } = spawnMemoryFor(ctx);
2563
+ if (!last) {
2564
+ void newAgentFlow(ctx);
2565
+ return;
2566
+ }
2567
+ const shape = spawnShape(ctx);
2568
+ const placement = compatiblePlacement(last.placement, shape)
2569
+ ? last.placement
2570
+ : defaultSpawnPlacement(shape);
2571
+ void runSpawn(ctx, { kind: last.kind, command: last.command, placement });
2572
+ };
2573
+
2574
+ /** "New agent…" for a home row (the [+ agent] chip, the `a` key, the home
2575
+ * palette command): a session row spawns into that session, a project/
2576
+ * recent row into a fresh session in its dir; with nothing useful selected
2577
+ * fall back to the working directory. */
2578
+ const newAgentFromHome = (it: HomeItem | undefined) => {
2579
+ void newAgentFlow(homeAgentContext(it));
2580
+ };
2581
+ /** The spawn context a home row implies (shared by the row chips and the
2582
+ * contextual resolver below). */
2583
+ const homeAgentContext = (it: HomeItem | undefined): NewAgentContext => {
2584
+ if (it?.kind === "session") return { session: it.session, dir: it.dir };
2585
+ if (it?.kind === "project") return { dir: it.dir, sessionName: it.name };
2586
+ if (it?.kind === "recent") return { dir: it.dir };
2587
+ return { dir: invokeCwd };
2588
+ };
2589
+ /** THE contextual spawn target — one resolver shared by the palette's
2590
+ * new-agent actions, the sidebar's [+ agent] chip, and the Team dialog:
2591
+ * the Terminal surface spawns beside its focused pane, home uses the
2592
+ * selected row, anywhere else the workspace session, else a fresh one. */
2593
+ const currentNewAgentContext = (): NewAgentContext => {
2594
+ if (mode() === "mirror") {
2595
+ // The mirrored session's OWN dir first: contextDir can be a stale
2596
+ // persisted workspace when the app booted straight to --target.
2597
+ return {
2598
+ session: curTarget(),
2599
+ dir: dirForSession(curTarget()) ?? (contextDir() || null),
2600
+ paneId: mirror?.focusedPane() ?? undefined,
2601
+ };
2602
+ }
2603
+ if (mode() === "home") return homeAgentContext(selectedHomeItem());
2604
+ if (contextSession()) return { session: contextSession(), dir: contextDir() || null };
2605
+ return { dir: workspaceDir() };
2606
+ };
2607
+ /** What the current context's remembered spawn is called, or null — drives
2608
+ * the palette's pinned "New agent: <name> (again)" action. */
2609
+ const currentAgainName = (): string | null => {
2610
+ const { last } = spawnMemoryFor(currentNewAgentContext());
2611
+ return last ? lastSpawnName(last) : null;
2612
+ };
2613
+
2614
+ /** The TEAM dialog (M24.1): every fleet agent in one surface — Enter/click
2615
+ * JUMPS to the agent, ^r restarts, ^s stops (both confirm via their own
2616
+ * flows), and a pinned "+ new agent" row opens the one-dialog kind picker.
2617
+ * Opened from the sidebar's agents-header click and the palette's
2618
+ * "Manage team…". */
2619
+ const manageTeamFlow = async () => {
2620
+ setHoverIf(null); // the overlay owns the pointer, like the palette
2621
+ const agents = fleetAgents();
2622
+ const res = await DialogSelect.show({
2623
+ title: `Team — ${agents.length} agent${agents.length === 1 ? "" : "s"}`,
2624
+ items: teamItems(agents, Math.floor(Date.now() / 1000)),
2625
+ actions: TEAM_ACTIONS,
2626
+ footerHint: "enter jumps",
2627
+ });
2628
+ if (!res) return;
2629
+ if (res.item.id === TEAM_NEW_ID) {
2630
+ void newAgentFlow(currentNewAgentContext());
2631
+ return;
2632
+ }
2633
+ const idx = teamAgentIndex(res.item.id);
2634
+ const a = idx !== null ? agents[idx] : undefined;
2635
+ if (!a) return;
2636
+ if (res.action === "r") void restartAgentFlow(a);
2637
+ else if (res.action === "s") void stopAgentFlow(a);
2638
+ else jumpToAgent(a);
2639
+ };
2640
+
1419
2641
  // ── OPEN A FOLDER (M22.5) — the non-technicals' front door ───────────────
1420
2642
  // A filesystem picker (a DialogSelect browse loop over ASYNC readdir) →
1421
2643
  // create-or-attach a session in the chosen dir → openWorkspace, then two
@@ -1621,12 +2843,17 @@ render(
1621
2843
  const selectTab = (t: Tab) => {
1622
2844
  clearSelection();
1623
2845
  if (t === "diff") {
1624
- if (diffFiles().length === 0) enterDiff(workspaceDir());
2846
+ if (diffEntries().length === 0) enterDiff(workspaceDir());
1625
2847
  else setTab("diff");
2848
+ refreshFocusRecord(); // visibility changed — tell the notify path now
1626
2849
  return;
1627
2850
  }
1628
- if (t === "files" && fileNodes().length === 0) loadFileList(workspaceDir());
2851
+ if (t === "files") {
2852
+ if (fileNodes().length === 0) loadFileList(workspaceDir());
2853
+ else catchUpFilesIfStale();
2854
+ }
1629
2855
  setTab(t);
2856
+ refreshFocusRecord(); // visibility changed — tell the notify path now
1630
2857
  };
1631
2858
 
1632
2859
  // ── COMMAND PALETTE (M18.4, mouse-complete M21.9) ───────────────────────
@@ -1641,26 +2868,82 @@ render(
1641
2868
  // a press outside dismisses. Keyboard behavior is unchanged.
1642
2869
  const [paletteOpen, setPaletteOpen] = createSignal(false);
1643
2870
  const [paletteQuery, setPaletteQuery] = createSignal("");
2871
+ // "Go to file:" source (M24.6): the workspace's ignore-respecting file list,
2872
+ // repo-relative, capped, refreshed on each palette open (async — the rows
2873
+ // appear as soon as the list lands). `git ls-files -co --exclude-standard`
2874
+ // where the workspace is a repo; a capped, filtered async walk elsewhere.
2875
+ const REPO_FILES_CAP = 2000;
2876
+ const REPO_WALK_DEPTH = 8;
2877
+ const [repoFiles, setRepoFiles] = createSignal<string[]>([]);
2878
+ const walkRepoFiles = async (root: string): Promise<string[]> => {
2879
+ const out: string[] = [];
2880
+ let queue: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
2881
+ while (queue.length > 0 && out.length < REPO_FILES_CAP) {
2882
+ const next: typeof queue = [];
2883
+ for (const { dir, depth } of queue) {
2884
+ const ents = await listDir(dir).catch(() => []);
2885
+ for (const e of ents) {
2886
+ if (out.length >= REPO_FILES_CAP) break;
2887
+ const abs = join(dir, e.name);
2888
+ if (e.isDir) {
2889
+ if (depth + 1 < REPO_WALK_DEPTH) next.push({ dir: abs, depth: depth + 1 });
2890
+ } else {
2891
+ const rel = relPath(root, abs);
2892
+ if (rel) out.push(rel);
2893
+ }
2894
+ }
2895
+ }
2896
+ queue = next;
2897
+ }
2898
+ return out;
2899
+ };
2900
+ const loadRepoFiles = () => {
2901
+ const root = workspaceDir();
2902
+ runGitFiles(["ls-files", "-co", "--exclude-standard"], (out) => {
2903
+ if (root !== workspaceDir()) return;
2904
+ if (out) {
2905
+ setRepoFiles(out.split("\n").filter(Boolean).slice(0, REPO_FILES_CAP));
2906
+ return;
2907
+ }
2908
+ void walkRepoFiles(root)
2909
+ .then((files) => {
2910
+ if (root === workspaceDir()) setRepoFiles(files);
2911
+ })
2912
+ .catch(() => setRepoFiles([]));
2913
+ });
2914
+ };
1644
2915
  const [paletteSel, setPaletteSel] = createSignal(0);
1645
2916
  // The wheel-scrolled window top of the result list (0 unless scrolled — the
1646
2917
  // keyboard never moves it, so keyboard-only sessions render exactly as
1647
2918
  // before). Reset wherever the list identity changes (query edits, level
1648
2919
  // swaps, reopen).
1649
2920
  const [paletteTop, setPaletteTop] = createSignal(0);
1650
- const paletteActions = createMemo(() =>
1651
- filterPaletteActions(
2921
+ // ROWS, not bare actions (M24.4): an empty query opens grouped — "recent"
2922
+ // (persisted usage), "suggested" (surface verbs; BLOCKED agents' jumps
2923
+ // first), then "commands" — a typed query is one flat ranked list. Headers
2924
+ // are real, non-selectable rows; the selection helpers below skip them.
2925
+ const paletteRowList = createMemo(() =>
2926
+ paletteRows(
1652
2927
  paletteQuery(),
1653
2928
  fleet().map((s) => s.name),
1654
2929
  {
1655
2930
  terminal: mode() === "mirror",
2931
+ surface: tab(),
1656
2932
  agents: fleetAgents(),
1657
2933
  sizeMismatch: windowMismatch() !== null,
1658
2934
  appMousePane: panes().find((p) => p.active)?.appMouse === true,
2935
+ // Pins "New agent: <name> (again)" FIRST when this context has spawn
2936
+ // memory (M24.1) — F5 → Enter repeats the last spawn.
2937
+ againName: currentAgainName(),
2938
+ usage: paletteUsage(),
2939
+ keycaps: PALETTE_ROW_KEYCAPS,
2940
+ // "Go to file:" rows (M24.6) — appended after everything.
2941
+ repoFiles: repoFiles(),
1659
2942
  },
1660
2943
  ),
1661
2944
  );
1662
- /** The current palette LIST length — buffers level when open, else actions. */
1663
- const paletteCount = () => paletteBuffers()?.length ?? paletteActions().length;
2945
+ /** The current palette LIST length — buffers level when open, else rows. */
2946
+ const paletteCount = () => paletteBuffers()?.length ?? paletteRowList().length;
1664
2947
  /** The palette box geometry as placed by the render, for the router. */
1665
2948
  const paletteGeom = (): PaletteGeom => {
1666
2949
  const { left, top } = palettePos(dims().width, dims().height, PALETTE_W);
@@ -1673,13 +2956,21 @@ render(
1673
2956
  };
1674
2957
  const openPalette = () => {
1675
2958
  setPaletteQuery("");
1676
- setPaletteSel(0);
1677
2959
  setPaletteTop(0);
1678
2960
  setPaletteBuffers(null); // always open on the action list, never mid-picker
2961
+ // The selection starts on the first ACTION row — under grouping that is
2962
+ // the row after the "recent" header, keeping F5 → Enter one gesture.
2963
+ setPaletteSel(Math.max(0, firstPaletteAction(paletteRowList())));
1679
2964
  setHoverIf(null); // the overlay owns the pointer; drop any underlying tint
2965
+ loadRepoFiles(); // refresh the "Go to file:" source (async, M24.6)
1680
2966
  setPaletteOpen(true);
1681
2967
  };
1682
2968
  const runPaletteAction = (a: PaletteAction) => {
2969
+ // Usage history (M24.4): every dispatched action bumps its stable key —
2970
+ // count + lastUsed feed the "recent" group and the ranking tie-break.
2971
+ setPaletteUsage((u) =>
2972
+ recordPaletteUse(u, paletteActionKey(a), Math.floor(Date.now() / 1e3)),
2973
+ );
1683
2974
  // "Paste buffer…" descends into the second-level picker instead of
1684
2975
  // dispatching — keep the palette open and load the buffer list.
1685
2976
  if (a.kind === "paste-buffer") {
@@ -1690,6 +2981,11 @@ render(
1690
2981
  }
1691
2982
  setPaletteOpen(false);
1692
2983
  switch (a.kind) {
2984
+ case "search-scrollback":
2985
+ // The live-prompt entry to scrollback search — `/` only works while
2986
+ // scrolled (it belongs to the pane's agent at the bottom).
2987
+ openSearch();
2988
+ break;
1693
2989
  case "tab":
1694
2990
  selectTab(a.tab);
1695
2991
  break;
@@ -1702,9 +2998,33 @@ render(
1702
2998
  case "jump-agent":
1703
2999
  jumpToAgent(a);
1704
3000
  break;
3001
+ case "new-agent":
3002
+ // Contextual target (currentNewAgentContext): the Terminal surface
3003
+ // spawns beside its focused pane; home uses the selected row;
3004
+ // anywhere else the workspace session, else a fresh one.
3005
+ void newAgentFlow(currentNewAgentContext());
3006
+ break;
3007
+ case "new-agent-again":
3008
+ // Repeat the remembered spawn directly — no dialog (M24.1: the
3009
+ // pinned action makes F5 → Enter the whole repeat gesture).
3010
+ newAgentAgain(currentNewAgentContext());
3011
+ break;
3012
+ case "manage-team":
3013
+ void manageTeamFlow();
3014
+ break;
3015
+ case "restart-agent":
3016
+ void restartAgentFlow({ paneId: a.paneId, kind: a.agentKind });
3017
+ break;
3018
+ case "stop-agent":
3019
+ void stopAgentFlow({ paneId: a.paneId, kind: a.agentKind });
3020
+ break;
1705
3021
  case "open-file":
1706
3022
  openEditor(a.path);
1707
3023
  break;
3024
+ case "go-file":
3025
+ // A fuzzy-matched repo file (M24.6) — path is workspace-relative.
3026
+ openEditor(join(workspaceDir(), a.path));
3027
+ break;
1708
3028
  case "save":
1709
3029
  saveEditor();
1710
3030
  break;
@@ -1798,9 +3118,11 @@ render(
1798
3118
  void runSettingsCommand(a.id);
1799
3119
  break;
1800
3120
  case "quit":
1801
- mirror?.dispose();
1802
- editBuffer?.destroy();
1803
- process.exit(0);
3121
+ // Renderer destruction disposes the Solid root first, so the shared
3122
+ // onCleanup path owns mirrors/buffers and the host-mode guard restores
3123
+ // DECAWM after OpenTUI's native terminal teardown.
3124
+ appRenderer.destroy();
3125
+ break;
1804
3126
  }
1805
3127
  };
1806
3128
  /** Feed one key to the palette overlay. Returns true when the key was consumed
@@ -1830,23 +3152,23 @@ render(
1830
3152
  }
1831
3153
  return;
1832
3154
  }
1833
- const actions = paletteActions();
3155
+ const rows = paletteRowList();
1834
3156
  if (evt.name === "escape") {
1835
3157
  setPaletteOpen(false);
1836
3158
  } else if (evt.name === "return") {
1837
- const a = actions[Math.min(paletteSel(), actions.length - 1)];
1838
- if (a) runPaletteAction(a);
3159
+ const r = rows[Math.min(paletteSel(), rows.length - 1)];
3160
+ if (r?.type === "action") runPaletteAction(r.action);
1839
3161
  } else if (evt.name === "up") {
1840
- setPaletteSel((s) => Math.max(0, s - 1));
3162
+ setPaletteSel((s) => stepPaletteRow(rows, s, -1));
1841
3163
  } else if (evt.name === "down") {
1842
- setPaletteSel((s) => Math.min(Math.max(0, actions.length - 1), s + 1));
3164
+ setPaletteSel((s) => stepPaletteRow(rows, s, 1));
1843
3165
  } else if (evt.name === "backspace") {
1844
3166
  setPaletteQuery((q) => q.slice(0, -1));
1845
- setPaletteSel(0);
3167
+ setPaletteSel(Math.max(0, firstPaletteAction(paletteRowList())));
1846
3168
  setPaletteTop(0);
1847
3169
  } else if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
1848
3170
  setPaletteQuery((q) => q + (evt.shift ? evt.name.toUpperCase() : evt.name));
1849
- setPaletteSel(0);
3171
+ setPaletteSel(Math.max(0, firstPaletteAction(paletteRowList())));
1850
3172
  setPaletteTop(0);
1851
3173
  }
1852
3174
  };
@@ -1954,7 +3276,7 @@ render(
1954
3276
  const choice = await DialogSelect.show({
1955
3277
  title: "Quiet hours",
1956
3278
  items: quietHoursItems(prefs),
1957
- footerHint: "silences macOS banners during the window",
3279
+ footerHint: "silences banners, sounds & bells during the window",
1958
3280
  });
1959
3281
  if (!choice) return false;
1960
3282
  if (choice.item.id === "off") {
@@ -2000,6 +3322,31 @@ render(
2000
3322
  await runQuietHours();
2001
3323
  continue; // back to the list with fresh details either way
2002
3324
  }
3325
+ if (choice.item.id === "sound") {
3326
+ const picked = await DialogSelect.show({
3327
+ title: "Notification sound",
3328
+ items: soundItems(prefs),
3329
+ footerHint: HINT_LIVE,
3330
+ });
3331
+ if (picked) {
3332
+ updateAppConfig(soundPatch(picked.item.id));
3333
+ setStatusNote(`sound: ${picked.item.label} — ${HINT_LIVE}`);
3334
+ }
3335
+ continue;
3336
+ }
3337
+ if (choice.item.id === "delaySeconds") {
3338
+ const v = await DialogPrompt.show({
3339
+ title: "Alert delay (seconds)",
3340
+ initial: String(prefs.delaySeconds),
3341
+ validate: validateDelaySeconds,
3342
+ footerHint: `waits, then re-checks the agent still needs you · ${HINT_LIVE}`,
3343
+ });
3344
+ if (v !== null) {
3345
+ updateAppConfig(delaySecondsPatch(v));
3346
+ setStatusNote(`alert delay ${v.trim()} s — ${HINT_LIVE}`);
3347
+ }
3348
+ continue;
3349
+ }
2003
3350
  const id = choice.item.id as NotificationToggleId;
2004
3351
  updateAppConfig(notificationTogglePatch(id, prefs));
2005
3352
  setStatusNote(`${choice.item.label}: ${prefs[id] ? "off" : "on"} — ${HINT_LIVE}`);
@@ -2074,7 +3421,7 @@ render(
2074
3421
  const runKeybindViewer = async (): Promise<boolean> => {
2075
3422
  await DialogSelect.show({
2076
3423
  title: "Keyboard shortcuts",
2077
- items: keybindingItems(freshCfg().keys),
3424
+ items: keybindingItems(freshCfg().keys, KITTY_KEYS),
2078
3425
  footerHint: "read-only — edit keys.* in ~/.tmux-ide/config.json",
2079
3426
  });
2080
3427
  return false; // viewing commits nothing; the umbrella reopens
@@ -2147,9 +3494,14 @@ render(
2147
3494
  lastTab: tab(),
2148
3495
  contextSession: contextSession() || null,
2149
3496
  openFile: editorPath(),
2150
- diffFile: diffFiles()[diffSel()]?.path ?? null,
3497
+ diffFile: diffVisibleFiles()[diffSel()]?.path ?? null,
2151
3498
  sidebarW: sidebarW(),
2152
3499
  recentFolders: recentFolders(),
3500
+ lastSpawns: lastSpawns(),
3501
+ customCommands: customCommands(),
3502
+ paletteUsage: paletteUsage(),
3503
+ filesShowHidden: showHiddenFiles(),
3504
+ filesShowIgnored: showIgnoredFiles(),
2153
3505
  };
2154
3506
  if (saveTimer) clearTimeout(saveTimer);
2155
3507
  saveTimer = setTimeout(() => void saveAppState(snapshot), 400);
@@ -2176,6 +3528,22 @@ render(
2176
3528
  if (mode() === "diff") refreshStatus();
2177
3529
  if (mode() === "mirror" && curTarget()) attach(curTarget());
2178
3530
  const t = setInterval(() => {
3531
+ // Edge auto-scroll (M25.6): while a mirror drag parks the pointer at
3532
+ // the pane's top/bottom content row, extend ~1 row per state tick —
3533
+ // the existing 8ms cadence, no new timers. The clamps stop it at the
3534
+ // scrollback top (up) / the live bottom (down); release or escape
3535
+ // clears the gesture (clearSelection / the release branch in `route`).
3536
+ if (mirror && dragAutoScroll && selecting?.surface === "mirror") {
3537
+ const paneId = selecting.paneId;
3538
+ const depth = mirror.scrollbackDepth(paneId);
3539
+ const cur = Math.min(scrollOffsets.get(paneId) ?? 0, depth);
3540
+ const next = dragAutoScroll === "up" ? Math.min(cur + 1, depth) : Math.max(cur - 1, 0);
3541
+ if (next !== cur) {
3542
+ scrollOffsets.set(paneId, next);
3543
+ extendSelection(lastDragPointer.x, lastDragPointer.y);
3544
+ dirty = true;
3545
+ }
3546
+ }
2179
3547
  if (!dirty || !mirror) return;
2180
3548
  dirty = false;
2181
3549
  const t0 = performance.now();
@@ -2183,15 +3551,28 @@ render(
2183
3551
  // styled-row rebuild) — the <pane_surface> reads cells via the blit and
2184
3552
  // gates its walk on the version, so unchanged panes cost nothing.
2185
3553
  const raw = mirror.panes(scrollOffsets, !FB_PANES);
2186
- // Size truth (M22.8): the RAW pane bounding box is the effective window
2187
- // size. When a co-attached terminal shrank it below our pinned canvas we
2188
- // surface the honest hint AND center the grid — the offset is baked into
3554
+ // Size truth (M22.8, event-driven M23.5): the effective window size is
3555
+ // the layout ROOT's WxH pushed by %layout-change (the pane bounding
3556
+ // box only seeds it before the first layout lands). When a co-attached
3557
+ // terminal sized the window away from our pinned canvas we surface the
3558
+ // honest hint AND center the grid — the offset is baked into
2189
3559
  // pane.left/top HERE (one place), so every render and pointer-routing
2190
3560
  // read (all expressed relative to pane.left/top or `inside(pane,…)`)
2191
- // stays consistent for free without touching the mouse math.
3561
+ // stays consistent for free without touching the mouse math. A re-pin
3562
+ // in flight suppresses the mismatch (D4b): between our refresh-client
3563
+ // -C and tmux's %layout-change the stale size is expected, and honest-
3564
+ // hinting it flashed "window sized by another terminal" + a letterbox
3565
+ // jump on every grow (measured).
2192
3566
  const pinned: Size = { cols: canvasCols(), rows: canvasRows() };
2193
- const effective = effectiveWindowSize(raw);
2194
- const mm = effective ? detectSizeMismatch(pinned, effective) : null;
3567
+ const effective = mirror.windowSize() ?? effectiveWindowSize(raw);
3568
+ const mm = effective
3569
+ ? detectSizeMismatchWithRepin(pinned, effective, repinInFlight, performance.now())
3570
+ : null;
3571
+ // The transition completed (sizes agree) — retire the grace so a LATER
3572
+ // genuine co-attach shrink to exactly the old size still surfaces.
3573
+ if (effective && effective.cols === pinned.cols && effective.rows === pinned.rows) {
3574
+ repinInFlight = null;
3575
+ }
2195
3576
  setWindowMismatch(mm);
2196
3577
  const off = mm ? letterboxOffset(pinned, mm) : { x: 0, y: 0 };
2197
3578
  setPanes(
@@ -2216,6 +3597,11 @@ render(
2216
3597
  // input (mouse events die during the storm). The child does the work.
2217
3598
  let fleetInFlight = false;
2218
3599
  const refreshFleet = () => {
3600
+ pruneDragOverrides();
3601
+ // The M25.1 handshakes piggyback on the poll cadence: publish what the
3602
+ // app is showing, and consume any banner-click jump request.
3603
+ refreshFocusRecord();
3604
+ consumeJumpRequest();
2219
3605
  if (fleetInFlight) return;
2220
3606
  fleetInFlight = true;
2221
3607
  execFile("node", [cliPath, "team", "--json"], { timeout: 10_000 }, (err, stdout) => {
@@ -2224,6 +3610,7 @@ render(
2224
3610
  try {
2225
3611
  const data = JSON.parse(stdout) as { projects?: FleetProject[] };
2226
3612
  setProjectsData(data.projects ?? []);
3613
+ noteAttention(data.projects ?? []);
2227
3614
  // Reconcile a RESTORED context session to its project dir once the
2228
3615
  // fleet lands (persistence carries the name, not the dir). One-shot:
2229
3616
  // only while contextDir is still unresolved.
@@ -2248,20 +3635,10 @@ render(
2248
3635
  const diffTimer = setInterval(() => {
2249
3636
  if (mode() === "diff") refreshStatus();
2250
3637
  }, 3000);
2251
- let lastW = canvasCols();
2252
- let lastH = canvasRows();
2253
- const sizeTimer = setInterval(() => {
2254
- if (canvasCols() !== lastW || canvasRows() !== lastH) {
2255
- lastW = canvasCols();
2256
- lastH = canvasRows();
2257
- void mirror?.resize(lastW, lastH);
2258
- }
2259
- }, 200);
2260
3638
  onCleanup(() => {
2261
3639
  clearInterval(t);
2262
3640
  clearInterval(fleetTimer);
2263
3641
  clearInterval(diffTimer);
2264
- clearInterval(sizeTimer);
2265
3642
  if (saveTimer) clearTimeout(saveTimer);
2266
3643
  if (noteTimer) clearTimeout(noteTimer);
2267
3644
  mirror?.dispose();
@@ -2502,8 +3879,13 @@ render(
2502
3879
  return p.snapshot.rows.map((runs) => runs.map((r) => r.text).join(""));
2503
3880
  };
2504
3881
  const commitMirrorCopy = (paneId: string, anchor: Cell, head: Cell) => {
3882
+ // Cells are ABSOLUTE buffer coordinates (M25.6); the extractor reads the
3883
+ // full span straight from the pane's buffer — scrollback included — with
3884
+ // the same trim/collapse as the old visible-rows path, built capped so a
3885
+ // runaway span never materializes unbounded (copyText still refuses over
3886
+ // MAX_CLIP_BYTES, with the honest over-limit byte count).
2505
3887
  const { start, end } = orderCells(anchor, head);
2506
- const text = extractSelection(paneRowTexts(paneId), start, end);
3888
+ const text = mirror?.extractText(paneId, start, end, MAX_CLIP_BYTES) ?? "";
2507
3889
  if (text.length > 0) {
2508
3890
  copyText(text);
2509
3891
  // A completed copy ends the pane's select mode (M22.9) — forwarding
@@ -2559,10 +3941,13 @@ render(
2559
3941
  }
2560
3942
  const s = selection();
2561
3943
  if (s && s.surface === "mirror" && s.paneId === pane.id) {
3944
+ // Selection cells are ABSOLUTE buffer lines (M25.6) — map each visible
3945
+ // row through the same baseY the search pass uses above.
3946
+ const baseY = pane.scrollbackDepth - pane.snapshot.scrollOffset;
2562
3947
  const { start, end } = orderCells(s.anchor, s.head);
2563
3948
  rows = rows.map((runs, r) => {
2564
3949
  const rowLen = runs.reduce((n, run) => n + run.text.length, 0);
2565
- const range = rowSelectionRange(r, rowLen, start, end);
3950
+ const range = rowSelectionRange(baseY + r, rowLen, start, end);
2566
3951
  return range ? tintRunsInverse(runs, range.from, range.to) : runs;
2567
3952
  });
2568
3953
  }
@@ -2570,7 +3955,10 @@ render(
2570
3955
  };
2571
3956
 
2572
3957
  // FB-path twins of the two paneSelRows passes, shaped as <pane_surface> props
2573
- // (cell-column based; the renderable applies them over the blitted cells).
3958
+ // (the renderable applies them over the blitted cells). Both are ABSOLUTE-
3959
+ // space inputs (M25.6): the selection range is absolute buffer cells and the
3960
+ // search matches absolute lines — the surface maps them to visible rows
3961
+ // per-frame against the pane's current baseY, so highlights ride the scroll.
2574
3962
  // Reading selection()/paneSearches() here subscribes the surface so the prop
2575
3963
  // re-sets — and the blit re-runs — only when they actually change.
2576
3964
  const mirrorSelForPane = (paneId: string): { start: Cell; end: Cell } | null => {
@@ -2593,6 +3981,19 @@ render(
2593
3981
  col: Math.max(0, Math.min(pane.width - 1, gx - sidebarW() - pane.left)),
2594
3982
  row: Math.max(0, Math.min(pane.height - 1, gy - HEADER_ROWS - pane.top)),
2595
3983
  });
3984
+ /** The view's current absolute top for a pane (M25.6): live scrollback
3985
+ * depth − the clamped LOCAL offset, both read from the mirror/offset map
3986
+ * at EVENT time — the LivePane snapshot lags one 8ms tick, and a wheel
3987
+ * that just moved the offset must map the very next pointer cell right. */
3988
+ const paneBaseY = (paneId: string): number => {
3989
+ const depth = mirror?.scrollbackDepth(paneId) ?? 0;
3990
+ return depth - Math.max(0, Math.min(scrollOffsets.get(paneId) ?? 0, depth));
3991
+ };
3992
+ /** A pointer position as an ABSOLUTE buffer cell of `pane` (M25.6). */
3993
+ const paneAbsCell = (pane: LivePane, gx: number, gy: number): Cell => {
3994
+ const cell = paneCell(pane, gx, gy);
3995
+ return { row: paneBaseY(pane.id) + cell.row, col: cell.col };
3996
+ };
2596
3997
 
2597
3998
  // ── SCROLLBARS (M19.5) ────────────────────────────────────────────────────
2598
3999
  // The scroll geometry for one surface: the global column its 1-col track sits
@@ -2728,10 +4129,21 @@ render(
2728
4129
  if (y === 0) return null; // the surface tab bar owns row 0
2729
4130
  const gy = y - TABBAR_H;
2730
4131
  if (x < sidebarW()) {
2731
- // Only SESSION rows carry a context menu; agent rows (left-click jumps),
2732
- // the gap, header, and empty-state line have none. Route through the same
2733
- // sidebarHit the click/hover resolvers use so the ranges never diverge.
4132
+ // SESSION rows carry the session menu; AGENT rows carry the lifecycle
4133
+ // menu (M23.1 — left-click still jumps). The gap, header, and
4134
+ // empty-state line have none. Route through the same sidebarHit the
4135
+ // click/hover resolvers use so the ranges never diverge.
2734
4136
  const hit = sidebarHit(gy, fleet().length, fleetAgents().length);
4137
+ if (hit?.kind === "agent") {
4138
+ const a = fleetAgents()[hit.index];
4139
+ if (!a) return null;
4140
+ return {
4141
+ region: "agent",
4142
+ title: `${a.kind} · ${a.session}`,
4143
+ items: MENU_ITEMS.agent,
4144
+ agent: a,
4145
+ };
4146
+ }
2735
4147
  if (hit?.kind !== "session") return null;
2736
4148
  const s = fleet()[hit.index];
2737
4149
  if (!s) return null;
@@ -2761,9 +4173,9 @@ render(
2761
4173
  const overList = x < sidebarW() + filesListW();
2762
4174
  const contentY = gy - HEADER_ROWS;
2763
4175
  if (!overList || contentY < 0) return null;
2764
- const top = clampTop(fileTop(), fileNodes().length, editorRows());
4176
+ const top = clampTop(fileTop(), visibleFiles().length, editorRows());
2765
4177
  const idx = top + contentY;
2766
- const node = fileNodes()[idx];
4178
+ const node = visibleFiles()[idx]?.node;
2767
4179
  if (!node) return null;
2768
4180
  return {
2769
4181
  region: "file",
@@ -2779,15 +4191,15 @@ render(
2779
4191
  const overList = x < sidebarW() + diffListW();
2780
4192
  const contentY = gy - HEADER_ROWS;
2781
4193
  if (!overList || contentY < 0) return null;
2782
- const top = clampTop(diffFileTop(), diffFiles().length, diffBodyRows());
2783
- const idx = top + contentY;
2784
- const entry = diffFiles()[idx];
2785
- if (!entry) return null;
4194
+ const rows = diffRows();
4195
+ const top = clampTop(diffFileTop(), rows.length, diffBodyRows());
4196
+ const row = rows[top + contentY];
4197
+ if (!row || row.kind !== "file") return null;
2786
4198
  return {
2787
4199
  region: "difffile",
2788
- title: basename(entry.path),
4200
+ title: basename(row.entry.path),
2789
4201
  items: MENU_ITEMS.difffile,
2790
- diffPath: join(diffDir(), entry.path),
4202
+ diffPath: join(diffDir(), row.entry.path),
2791
4203
  };
2792
4204
  }
2793
4205
  // mirror: gy=0 is the target/status row (no menu); gy=1 is the WINDOW STRIP —
@@ -2819,8 +4231,9 @@ render(
2819
4231
  return {
2820
4232
  region: "pane",
2821
4233
  title: pane.id,
2822
- // App-mouse panes lead with "Select text…" / "Stop selecting" (M22.9).
2823
- items: paneMenuItems(pane.appMouse, selectModePane() === pane.id),
4234
+ // App-mouse panes lead with "Select text…" / "Stop selecting" (M22.9)
4235
+ // and the per-pane drag-default toggle (M24.2).
4236
+ items: paneMenuItems(pane.appMouse, selectModePane() === pane.id, paneDrag(pane.id)),
2824
4237
  paneId: pane.id,
2825
4238
  };
2826
4239
  };
@@ -2895,6 +4308,18 @@ render(
2895
4308
  const m = menu();
2896
4309
  if (!m) return;
2897
4310
  const val = (input ?? "").trim();
4311
+ if (m.region === "agent") {
4312
+ // The sidebar agent row's lifecycle verbs (M23.1). restart/stop confirm
4313
+ // via DialogConfirm inside their flows; close fired through the menu's
4314
+ // armed "confirm: y" state (danger), so it runs immediately here.
4315
+ const a = m.agent!;
4316
+ closeMenu();
4317
+ if (id === "jump") jumpToAgent(a);
4318
+ else if (id === "restart") void restartAgentFlow(a);
4319
+ else if (id === "stop") void stopAgentFlow(a);
4320
+ else if (id === "close") closeAgentPane(a);
4321
+ return;
4322
+ }
2898
4323
  if (m.region === "session") {
2899
4324
  const name = m.session!;
2900
4325
  if (id === "attach") {
@@ -2902,6 +4327,11 @@ render(
2902
4327
  openWorkspace(name, m.sessionDir ?? null);
2903
4328
  return;
2904
4329
  }
4330
+ if (id === "new-agent") {
4331
+ closeMenu();
4332
+ void newAgentFlow({ session: name, dir: m.sessionDir ?? null });
4333
+ return;
4334
+ }
2905
4335
  if (id === "kill") {
2906
4336
  execFile("tmux", ["kill-session", "-t", name], () =>
2907
4337
  setTimeout(() => fleetRefresh?.(), 200),
@@ -2931,7 +4361,7 @@ render(
2931
4361
  void writeFile(p, "", { flag: "wx" })
2932
4362
  .then(() => {
2933
4363
  setStatusNote(`created ${val}`);
2934
- loadFileList(workspaceDir());
4364
+ refreshTree(); // expansion-preserving (M24.6)
2935
4365
  })
2936
4366
  .catch((e) => setStatusNote(`create failed: ${(e as Error).message}`));
2937
4367
  } else if (id === "rename" && val && m.filePath) {
@@ -2939,14 +4369,14 @@ render(
2939
4369
  void rename(m.filePath, p)
2940
4370
  .then(() => {
2941
4371
  setStatusNote(`renamed → ${val}`);
2942
- loadFileList(workspaceDir());
4372
+ refreshTree();
2943
4373
  })
2944
4374
  .catch((e) => setStatusNote(`rename failed: ${(e as Error).message}`));
2945
4375
  } else if (id === "delete" && m.filePath) {
2946
4376
  void rm(m.filePath, { recursive: true, force: false })
2947
4377
  .then(() => {
2948
4378
  setStatusNote(`deleted ${basename(m.filePath!)}`);
2949
- loadFileList(workspaceDir());
4379
+ refreshTree();
2950
4380
  })
2951
4381
  .catch((e) => setStatusNote(`delete failed: ${(e as Error).message}`));
2952
4382
  }
@@ -2993,6 +4423,17 @@ render(
2993
4423
  closeMenu();
2994
4424
  return;
2995
4425
  }
4426
+ // The drag-default toggle (M24.2): a session-scoped per-pane override
4427
+ // (pruned when the pane dies) flipping whether a plain drag selects
4428
+ // locally or forwards to the pane's app.
4429
+ if (id === "drag-select" || id === "drag-forward") {
4430
+ dragOverrides.set(pid, id === "drag-select" ? "select" : "forward");
4431
+ setStatusNote(
4432
+ id === "drag-select" ? "drag selects in this pane" : "drags forward to the app",
4433
+ );
4434
+ closeMenu();
4435
+ return;
4436
+ }
2996
4437
  // Synchronize-panes is a WINDOW option tmux won't notify us about, so we
2997
4438
  // flip it explicitly and re-query the strip. Keep the menu OPEN so the
2998
4439
  // ✓/✗ checkbox visibly flips (every other pane verb closes on fire).
@@ -3124,9 +4565,40 @@ render(
3124
4565
 
3125
4566
  useKeyboard((evt) => {
3126
4567
  if (evt.ctrl && evt.name === "q") {
3127
- mirror?.dispose();
3128
- editBuffer?.destroy();
3129
- process.exit(0);
4568
+ // HOSTED (M23.2): put the cockpit away and keep running — the palette's
4569
+ // "Quit" verb is the real exit. A client that came here via
4570
+ // switch-client (launched inside tmux) bounces BACK to its last
4571
+ // session; a client that attached from a plain terminal has no last
4572
+ // session, so `switch-client -l` fails and it detaches instead. No -t:
4573
+ // tmux resolves "current client" from the pane's $TMUX to the most
4574
+ // recently active client on our session — the presser.
4575
+ if (HOSTED) {
4576
+ execFile("tmux", ["switch-client", "-l"], (err) => {
4577
+ if (err) execFile("tmux", ["detach-client"], () => {});
4578
+ });
4579
+ return;
4580
+ }
4581
+ appRenderer.destroy();
4582
+ return;
4583
+ }
4584
+ // SUPER-modified keys arrive only under the kitty keyboard protocol
4585
+ // (M24.4). ⌘K opens the palette — suppressed while any overlay owns the
4586
+ // keyboard (dialog/menu/palette/search), mirroring F5. Every OTHER super
4587
+ // combo is consumed here: it must never type into a prompt/editor/query
4588
+ // and never reach a pane (forwarding modifier-rich keys into panes is a
4589
+ // separate card — #83).
4590
+ if (evt.super) {
4591
+ if (
4592
+ evt.name === "k" &&
4593
+ !evt.ctrl &&
4594
+ dialogStack.depth() === 0 &&
4595
+ !menu() &&
4596
+ !paletteOpen() &&
4597
+ !search()
4598
+ ) {
4599
+ openPalette();
4600
+ }
4601
+ return;
3130
4602
  }
3131
4603
  // A DIALOG owns the keyboard while open (M22.4) — topmost overlay, so it
3132
4604
  // is checked before the menu and the palette. EVERY key is consumed here
@@ -3148,12 +4620,13 @@ render(
3148
4620
  return;
3149
4621
  }
3150
4622
  // The scrollback-search session owns the keyboard while open (the bottom input
3151
- // line + n/N navigation), so no key leaks to the pane; ^q above still quits.
4623
+ // line + n/N navigation), so no key leaks to the pane; ^q above still works.
3152
4624
  if (search()) {
3153
4625
  searchKey(evt);
3154
4626
  return;
3155
4627
  }
3156
- // F5 (and ^p when it arrives) open the command palette.
4628
+ // F5 (and ^p when it arrives) open the command palette; ⌘K is the kitty
4629
+ // fast path handled above.
3157
4630
  if (evt.name === "f5" || (evt.ctrl && evt.name === "p")) {
3158
4631
  openPalette();
3159
4632
  return;
@@ -3165,16 +4638,13 @@ render(
3165
4638
  selectTab(fTab.key);
3166
4639
  return;
3167
4640
  }
3168
- // ^e — from the diff panel, open the SELECTED file in the editor at its
3169
- // repo-relative path; elsewhere toggle the editor against the previous mode
3170
- // (no-op until a file is opened via `o`/`--edit`).
4641
+ // ^e — from the diff panel, open the SELECTED file in the editor at the
4642
+ // first changed line of the top-visible hunk (M24.5); elsewhere toggle the
4643
+ // editor against the previous mode (no-op until a file is opened via
4644
+ // `o`/`--edit`).
3171
4645
  if (evt.ctrl && evt.name === "e") {
3172
- if (mode() === "diff") {
3173
- const entry = diffFiles()[diffSel()];
3174
- if (entry) openEditor(join(diffDir(), entry.path));
3175
- } else {
3176
- toggleEditor();
3177
- }
4646
+ if (mode() === "diff") openSelectedInEditor();
4647
+ else toggleEditor();
3178
4648
  return;
3179
4649
  }
3180
4650
  // ^g, not ^h: legacy encoding makes ctrl+h indistinguishable from
@@ -3213,10 +4683,59 @@ render(
3213
4683
  return;
3214
4684
  }
3215
4685
  // File LIST focus: j/k navigate, enter opens a file (→ editor focus) or
3216
- // toggles a directory. Otherwise the EDITOR has focus and types; esc hands
3217
- // focus back to the list.
4686
+ // toggles a directory; `/` opens the live name filter, [ / ] hop the
4687
+ // changed files, H / I toggle hidden / gitignored visibility (M24.6).
4688
+ // Otherwise the EDITOR has focus and types; esc hands focus back to the
4689
+ // list.
3218
4690
  if (filesFocus() === "list") {
3219
- if (evt.name === "j" || evt.name === "down") moveFileSel(1);
4691
+ const q = filesQuery();
4692
+ if (q !== null) {
4693
+ // Filter input active: printable chars narrow live; arrows move in
4694
+ // the FILTERED rows; enter activates the row (exiting the filter);
4695
+ // escape restores the full list and the pre-filter selection.
4696
+ if (evt.name === "escape") {
4697
+ setFilesQuery(null);
4698
+ const back = filesPreFilterPath ? indexOfPath(fileNodes(), filesPreFilterPath) : -1;
4699
+ const idx = back !== -1 ? back : 0;
4700
+ setFileSel(idx);
4701
+ setFileTop((t) => scrollToCursor(idx, t, editorRows(), visibleFiles().length));
4702
+ } else if (evt.name === "return") {
4703
+ const row = visibleFiles()[fileSel()];
4704
+ setFilesQuery(null);
4705
+ if (row) {
4706
+ const idx = indexOfPath(fileNodes(), row.node.path);
4707
+ if (idx !== -1) {
4708
+ setFileSel(idx);
4709
+ setFileTop((t) => scrollToCursor(idx, t, editorRows(), visibleFiles().length));
4710
+ activateFile(idx);
4711
+ }
4712
+ }
4713
+ } else if (evt.name === "backspace") {
4714
+ setFilesQuery(q.slice(0, -1));
4715
+ setFileSel(0);
4716
+ setFileTop(0);
4717
+ } else if (evt.name === "down") {
4718
+ moveFileSel(1);
4719
+ } else if (evt.name === "up") {
4720
+ moveFileSel(-1);
4721
+ } else if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
4722
+ setFilesQuery(q + (evt.shift ? evt.name.toUpperCase() : evt.name));
4723
+ setFileSel(0);
4724
+ setFileTop(0);
4725
+ }
4726
+ return;
4727
+ }
4728
+ if (evt.name === "/") {
4729
+ filesPreFilterPath = visibleFiles()[fileSel()]?.node.path ?? null;
4730
+ setFilesQuery("");
4731
+ setFileSel(0);
4732
+ setFileTop(0);
4733
+ } else if (evt.name === "]") hopChanged(1);
4734
+ else if (evt.name === "[") hopChanged(-1);
4735
+ else if (evt.shift && evt.name === "h") toggleHiddenFiles();
4736
+ else if (evt.shift && evt.name === "i") toggleIgnoredFiles();
4737
+ else if (evt.name === "r") refreshTree();
4738
+ else if (evt.name === "j" || evt.name === "down") moveFileSel(1);
3220
4739
  else if (evt.name === "k" || evt.name === "up") moveFileSel(-1);
3221
4740
  else if (evt.name === "return") activateFile(fileSel());
3222
4741
  return;
@@ -3229,10 +4748,40 @@ render(
3229
4748
  return;
3230
4749
  }
3231
4750
  if (mode() === "diff") {
3232
- // ^e / ^g / ^q are handled above; here j/k move the file selection and `r`
3233
- // forces a status+diff refresh.
4751
+ // The `/` filter owns the keyboard while active (M24.5): printable keys
4752
+ // narrow the grouped list live, escape/return clear + exit (widget
4753
+ // semantics), arrows still move the (filtered) selection.
4754
+ if (diffFilter() !== null) {
4755
+ if (evt.name === "escape" || evt.name === "return") {
4756
+ setDiffFilter(null);
4757
+ diffFilterReselect();
4758
+ } else if (evt.name === "backspace") {
4759
+ setDiffFilter((q) => (q ?? "").slice(0, -1));
4760
+ diffFilterReselect();
4761
+ } else if (evt.name === "up") moveDiffSel(-1);
4762
+ else if (evt.name === "down") moveDiffSel(1);
4763
+ else if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
4764
+ setDiffFilter((q) => (q ?? "") + (evt.shift ? evt.name.toUpperCase() : evt.name));
4765
+ diffFilterReselect();
4766
+ }
4767
+ return;
4768
+ }
4769
+ // ^e / ^g / ^q are handled above; j/k move the file selection, s/u
4770
+ // stage/unstage the selected file (S/U everything), ]/[ jump between
4771
+ // hunks, `/` filters, and `r` forces a status+diff refresh.
3234
4772
  if (evt.name === "j" || evt.name === "down") moveDiffSel(1);
3235
4773
  else if (evt.name === "k" || evt.name === "up") moveDiffSel(-1);
4774
+ else if (evt.name === "s" && evt.shift) stageAll();
4775
+ else if (evt.name === "u" && evt.shift) unstageAll();
4776
+ else if (evt.name === "s") {
4777
+ const cur = diffVisibleFiles()[diffSel()];
4778
+ if (cur) stageEntry(cur);
4779
+ } else if (evt.name === "u") {
4780
+ const cur = diffVisibleFiles()[diffSel()];
4781
+ if (cur) unstageEntry(cur);
4782
+ } else if (evt.name === "]") jumpHunk(1);
4783
+ else if (evt.name === "[") jumpHunk(-1);
4784
+ else if (evt.name === "/" && !evt.ctrl && !evt.meta) setDiffFilter("");
3236
4785
  else if (evt.name === "r") refreshStatus();
3237
4786
  return;
3238
4787
  }
@@ -3273,6 +4822,12 @@ render(
3273
4822
  setSessionPrompt("");
3274
4823
  return;
3275
4824
  }
4825
+ // `a` — the row [+ agent] chip's keyboard twin (M23.1): spawn an agent
4826
+ // for the selected row (or a fresh session when nothing is selected).
4827
+ if (evt.name === "a") {
4828
+ newAgentFromHome(selectedHomeItem());
4829
+ return;
4830
+ }
3276
4831
  // `d` — open the diff panel for the selected row's project dir (the
3277
4832
  // home item carries it via the team payload), adopting it as context.
3278
4833
  if (evt.name === "d") {
@@ -3317,10 +4872,18 @@ render(
3317
4872
  setStatusNote("select mode off");
3318
4873
  return;
3319
4874
  }
3320
- // `/` opens scrollback search on the focused pane (copy-mode's finder). Only
3321
- // in Terminal mode (home/editor/diff returned above); a raw `/` still reaches
3322
- // the pane once search is open, since search then owns the keyboard.
3323
- if (evt.name === "/" && !evt.ctrl && !evt.meta) {
4875
+ // `/` opens scrollback search ONLY when the focused pane is scrolled into
4876
+ // history — at the live prompt `/` belongs to the PANE (agents' slash
4877
+ // commands; user report 2026-07-11: "we cannot hijack that"). Scrolled up,
4878
+ // you're reading, not talking, so `/` means find. At the live bottom the
4879
+ // palette's "Search scrollback" action is the entry. Once search is open
4880
+ // it owns the keyboard as before.
4881
+ if (
4882
+ evt.name === "/" &&
4883
+ !evt.ctrl &&
4884
+ !evt.meta &&
4885
+ (scrollOffsets.get(mirror.focusedPane()) ?? 0) > 0
4886
+ ) {
3324
4887
  openSearch();
3325
4888
  return;
3326
4889
  }
@@ -3416,6 +4979,7 @@ render(
3416
4979
  ? [
3417
4980
  { id: "home-openfolder", label: "[f open folder]" },
3418
4981
  { id: "home-new", label: "[n new session]" },
4982
+ { id: "home-agent", label: "[a new agent]" },
3419
4983
  { id: "home-open", label: "[o open]" },
3420
4984
  { id: "home-diff", label: "[d diff]" },
3421
4985
  ]
@@ -3470,6 +5034,7 @@ render(
3470
5034
  const pid = mirror?.focusedPane();
3471
5035
  if (pid) void mirror?.command(`resize-pane -Z -t ${pid}`).catch(() => {});
3472
5036
  } else if (id === "home-openfolder") void openFolderFlow();
5037
+ else if (id === "home-agent") newAgentFromHome(selectedHomeItem());
3473
5038
  else if (id === "home-open") setPathPrompt("");
3474
5039
  else if (id === "home-new") setSessionPrompt("");
3475
5040
  else if (id === "home-diff") {
@@ -3537,11 +5102,39 @@ render(
3537
5102
  : it?.kind === "recent"
3538
5103
  ? HOME_CHIP_RECENT
3539
5104
  : "";
3540
- const homeChipHit = (it: HomeItem | undefined, x: number): boolean => {
3541
- const label = homeChipLabel(it);
3542
- if (!label) return false;
3543
- return spanHit(spansFromRight([label], buttonRightEdge(), 0), x) === 0;
5105
+ /** A home row's chip list, left to right: session/project rows lead with
5106
+ * the [+ agent] spawn chip (M23.1), then every row's PRIMARY verb chip.
5107
+ * The render walks these defs and the hit-test lays the SAME labels out
5108
+ * from the right edge, so clicks land exactly on what's drawn. */
5109
+ const homeRowChips = (
5110
+ it: HomeItem | undefined,
5111
+ ): { id: "agent" | "primary"; label: string }[] => {
5112
+ const defs: { id: "agent" | "primary"; label: string }[] = [];
5113
+ if (it?.kind === "session" || it?.kind === "project") {
5114
+ defs.push({ id: "agent", label: HOME_CHIP_AGENT });
5115
+ }
5116
+ const primary = homeChipLabel(it);
5117
+ if (primary) defs.push({ id: "primary", label: primary });
5118
+ return defs;
5119
+ };
5120
+ /** Which chip (if any) column `x` hits on the row for `it`. */
5121
+ const homeChipAt = (it: HomeItem | undefined, x: number): "agent" | "primary" | null => {
5122
+ const defs = homeRowChips(it);
5123
+ if (defs.length === 0) return null;
5124
+ const i = spanHit(
5125
+ spansFromRight(
5126
+ defs.map((d) => d.label),
5127
+ buttonRightEdge(),
5128
+ 0,
5129
+ ),
5130
+ x,
5131
+ );
5132
+ return i >= 0 ? defs[i]!.id : null;
3544
5133
  };
5134
+ /** The `[+ agent]` chip's x-span on the sidebar's AGENTS header row and the
5135
+ * empty-state row (M24.1) — right-anchored flush to the sidebar's edge; the
5136
+ * render (label · flexGrow spacer · chip) lays out the same cells. */
5137
+ const agentsChipSpans = createMemo(() => spansFromRight([AGENTS_ADD_CHIP], sidebarW(), 0));
3545
5138
  /** A home row's muted meta text — sessions keep the exact `3w · project`
3546
5139
  * string the panel always showed; projects show their dir + origin. */
3547
5140
  const homeRowMeta = (it: HomeItem): string =>
@@ -3580,7 +5173,19 @@ render(
3580
5173
  const hit = sidebarHit(gy, fleet().length, fleetAgents().length);
3581
5174
  if (hit?.kind === "session") setHoverIf({ region: "sidebar", index: hit.index });
3582
5175
  else if (hit?.kind === "agent") setHoverIf({ region: "sidebaragent", index: hit.index });
3583
- else setHoverIf(null);
5176
+ else if (hit?.kind === "agents-header") {
5177
+ // The [+ agent] chip lifts on its own; the rest of the header row
5178
+ // tints as the Team-dialog target (M24.1).
5179
+ setHoverIf(
5180
+ spanHit(agentsChipSpans(), x) === 0
5181
+ ? { region: "agentschip", index: 0 }
5182
+ : { region: "agentshdr", index: 0 },
5183
+ );
5184
+ } else if (hit?.kind === "agents-empty") {
5185
+ setHoverIf(
5186
+ spanHit(agentsChipSpans(), x) === 0 ? { region: "agentschip", index: 1 } : null,
5187
+ );
5188
+ } else setHoverIf(null);
3584
5189
  return;
3585
5190
  }
3586
5191
  const m = mode();
@@ -3600,7 +5205,11 @@ render(
3600
5205
  setHoverIf(null);
3601
5206
  return;
3602
5207
  }
3603
- setHoverIf({ region: homeChipHit(it, x) ? "homechip" : "home", index: idx });
5208
+ const chip = homeChipAt(it, x);
5209
+ setHoverIf({
5210
+ region: chip === "agent" ? "homeagentchip" : chip === "primary" ? "homechip" : "home",
5211
+ index: idx,
5212
+ });
3604
5213
  return;
3605
5214
  }
3606
5215
  if (m === "editor") {
@@ -3615,9 +5224,11 @@ render(
3615
5224
  setHoverIf(null);
3616
5225
  return;
3617
5226
  }
3618
- const top = clampTop(fileTop(), fileNodes().length, editorRows());
5227
+ const top = clampTop(fileTop(), visibleFiles().length, editorRows());
3619
5228
  const idx = top + contentY;
3620
- setHoverIf(idx >= 0 && idx < fileNodes().length ? { region: "files", index: idx } : null);
5229
+ setHoverIf(
5230
+ idx >= 0 && idx < visibleFiles().length ? { region: "files", index: idx } : null,
5231
+ );
3621
5232
  return;
3622
5233
  }
3623
5234
  if (m === "diff") {
@@ -3626,15 +5237,24 @@ render(
3626
5237
  setHoverIf(i >= 0 ? { region: "button", index: i } : null);
3627
5238
  return;
3628
5239
  }
5240
+ // The footer's stage/unstage verb chips (last screen row).
5241
+ if (y === dims().height - 1) {
5242
+ const i = spanHit(diffVerbSpans(), x);
5243
+ setHoverIf(i >= 0 ? { region: "diffverb", index: i } : null);
5244
+ return;
5245
+ }
3629
5246
  const contentY = gy - HEADER_ROWS;
3630
5247
  const overList = x < sidebarW() + diffListW();
3631
5248
  if (!overList || contentY < 0) {
3632
5249
  setHoverIf(null);
3633
5250
  return;
3634
5251
  }
3635
- const top = clampTop(diffFileTop(), diffFiles().length, diffBodyRows());
5252
+ // Only FILE rows are hoverable (section headers are inert); the hover
5253
+ // index is the ROW index, matching the render's slice.
5254
+ const rows = diffRows();
5255
+ const top = clampTop(diffFileTop(), rows.length, diffBodyRows());
3636
5256
  const idx = top + contentY;
3637
- setHoverIf(idx >= 0 && idx < diffFiles().length ? { region: "diff", index: idx } : null);
5257
+ setHoverIf(rows[idx]?.kind === "file" ? { region: "diff", index: idx } : null);
3638
5258
  return;
3639
5259
  }
3640
5260
  // mirror mode: the per-window strip lives on gy=1, with the [+ split] button
@@ -3656,7 +5276,11 @@ render(
3656
5276
  * sidebar, main). Geometry is ours. The tab bar is the top screen row; every
3657
5277
  * other region is offset below it, so we subtract TABBAR_H once (`gy`) and the
3658
5278
  * per-mode math below is exactly as it was before the bar existed. */
3659
- /** Extend a live selection's head to the pointer (surface-local cells). */
5279
+ /** Extend a live selection's head to the pointer. Mirror cells are ABSOLUTE
5280
+ * (M25.6): the head derives from the pointer + the pane's CURRENT view
5281
+ * offset, the anchor is re-based over any scrollback-cap rotation since
5282
+ * the press, and the pointer parking at/beyond the pane's top/bottom
5283
+ * content row arms the edge auto-scroll the 8ms tick drives. */
3660
5284
  const extendSelection = (x: number, y: number) => {
3661
5285
  if (!selecting) return;
3662
5286
  const gy = y - TABBAR_H;
@@ -3664,11 +5288,14 @@ render(
3664
5288
  const paneId = selecting.paneId;
3665
5289
  const pane = panes().find((p) => p.id === paneId);
3666
5290
  if (!pane) return;
5291
+ lastDragPointer = { x, y };
5292
+ const rawRow = gy - HEADER_ROWS - pane.top;
5293
+ dragAutoScroll = rawRow <= 0 ? "up" : rawRow >= pane.height - 1 ? "down" : null;
3667
5294
  setSelection({
3668
5295
  surface: "mirror",
3669
5296
  paneId: pane.id,
3670
- anchor: selAnchor,
3671
- head: paneCell(pane, x, gy),
5297
+ anchor: trimAdjustCell(selAnchor, (mirror?.lineTrim(paneId) ?? 0) - selTrimBase),
5298
+ head: paneAbsCell(pane, x, gy),
3672
5299
  });
3673
5300
  } else {
3674
5301
  const { line, col } = editorCellAt(x, gy);
@@ -3787,7 +5414,14 @@ render(
3787
5414
  }
3788
5415
  if (type === "move" || type === "over" || type === "drag") {
3789
5416
  const ri = paletteRowAt(g, x, y);
3790
- if (ri >= 0) setPaletteSel(paletteTop() + ri);
5417
+ // A header row is not selectable (M24.4) — motion over it keeps the
5418
+ // selection where it was, like the box chrome.
5419
+ if (ri >= 0) {
5420
+ const abs = paletteTop() + ri;
5421
+ if (paletteBuffers() !== null || paletteRowList()[abs]?.type === "action") {
5422
+ setPaletteSel(abs);
5423
+ }
5424
+ }
3791
5425
  return;
3792
5426
  }
3793
5427
  if (type !== "down") return;
@@ -3795,14 +5429,17 @@ render(
3795
5429
  if (ri >= 0) {
3796
5430
  if (e.button === 2) return; // right press on a row: no-op, stay open
3797
5431
  const abs = paletteTop() + ri;
3798
- setPaletteSel(abs);
3799
5432
  const bufs = paletteBuffers();
3800
5433
  if (bufs !== null) {
5434
+ setPaletteSel(abs);
3801
5435
  const b = bufs[abs];
3802
5436
  if (b) pasteBuffer(b.name);
3803
5437
  } else {
3804
- const a = paletteActions()[abs];
3805
- if (a) runPaletteAction(a);
5438
+ const r = paletteRowList()[abs];
5439
+ if (r?.type === "action") {
5440
+ setPaletteSel(abs);
5441
+ runPaletteAction(r.action);
5442
+ } // a header click is a no-op — the palette stays open
3806
5443
  }
3807
5444
  return;
3808
5445
  }
@@ -3909,6 +5546,44 @@ render(
3909
5546
  // suppressed until the gesture ends.
3910
5547
  return;
3911
5548
  }
5549
+ // A DEFERRED press (M24.2) resolves on the next event: a drag that leaves
5550
+ // the press cell starts the selection the press was withheld for (nothing
5551
+ // is ever forwarded, no stray down); a release still in that cell forwards
5552
+ // the owed SGR press/release pair — the click the pane's app was due.
5553
+ // Everything else mid-press is swallowed, like the resize gestures above;
5554
+ // a second down without a release (never seen live) just drops the debt.
5555
+ if (pendingPress) {
5556
+ const pp = pendingPress;
5557
+ const pane = panesById().get(pp.paneId);
5558
+ if (type === "drag") {
5559
+ if (!pane) {
5560
+ pendingPress = null; // the pane died mid-press — nothing is owed
5561
+ return;
5562
+ }
5563
+ const cell = paneCell(pane, x, y - TABBAR_H);
5564
+ if (cell.row !== pp.cell.row || cell.col !== pp.cell.col) {
5565
+ pendingPress = null;
5566
+ // Anchor at the press's frozen ABSOLUTE cell (M25.6); the extend
5567
+ // derives the head from the pointer's current absolute cell.
5568
+ selAnchor = pp.absCell;
5569
+ selTrimBase = pp.trimBase;
5570
+ selecting = { surface: "mirror", paneId: pane.id };
5571
+ extendSelection(x, y);
5572
+ }
5573
+ return;
5574
+ }
5575
+ if (type === "up" || type === "drag-end" || type === "drop") {
5576
+ pendingPress = null;
5577
+ if (pane) {
5578
+ forwardPress(pane, pp.x, pp.gy, false);
5579
+ forwardPress(pane, pp.x, pp.gy, true);
5580
+ }
5581
+ return;
5582
+ }
5583
+ if (type === "down")
5584
+ pendingPress = null; // drop the debt, route the press
5585
+ else return;
5586
+ }
3912
5587
  // Motion (bubbled from child text runs) drives hover only; "out" clears it.
3913
5588
  // Handled first so every click branch below stays a pure down/up/scroll path.
3914
5589
  if (type === "out") {
@@ -3921,20 +5596,50 @@ render(
3921
5596
  extendSelection(x, y);
3922
5597
  return;
3923
5598
  }
5599
+ // The wheel during a live MIRROR selection (M25.6): the drag owns it. It
5600
+ // adjusts the selecting pane's LOCAL offset — never forwarded to the
5601
+ // pane's app (even on app-mouse panes), never cancels the drag — and the
5602
+ // head re-derives at the pointer's new absolute cell, so the highlight
5603
+ // extends across the scroll. The scroll badge updates via the same tick.
5604
+ if (type === "scroll" && selecting && selecting.surface === "mirror") {
5605
+ const dir = e.scroll?.direction;
5606
+ if (dir === "up" || dir === "down") {
5607
+ const paneId = selecting.paneId;
5608
+ const depth = mirror?.scrollbackDepth(paneId) ?? 0;
5609
+ const cur = Math.min(scrollOffsets.get(paneId) ?? 0, depth);
5610
+ const next =
5611
+ dir === "up" ? Math.min(cur + SCROLL_STEP, depth) : Math.max(cur - SCROLL_STEP, 0);
5612
+ if (next !== cur) {
5613
+ scrollOffsets.set(paneId, next);
5614
+ markDirty();
5615
+ }
5616
+ extendSelection(x, y);
5617
+ }
5618
+ return;
5619
+ }
3924
5620
  if (type === "move" || type === "over" || type === "drag") {
3925
5621
  resolveHover(x, y);
3926
5622
  return;
3927
5623
  }
3928
5624
  // Release ends a live selection: the mirror copies what was dragged; the
3929
5625
  // editor keeps its selection for ^c. Discrete word/line selections leave
3930
- // `selecting` null, so their trailing release passes straight through — as
3931
- // does any release on an appMouse pane (which never starts a selection).
5626
+ // `selecting` null, so their trailing release passes straight through.
3932
5627
  if (type === "up" || type === "drag-end" || type === "drop") {
3933
5628
  if (selecting) {
3934
5629
  const s = selection();
3935
5630
  if (s && s.surface === "mirror" && selecting.surface === "mirror")
3936
5631
  commitMirrorCopy(s.paneId, s.anchor, s.head);
3937
5632
  selecting = null;
5633
+ dragAutoScroll = null;
5634
+ return;
5635
+ }
5636
+ // A FORWARDED press's release: pay the debt to the pane that got the
5637
+ // down — at the pointer's release cell, clamped into that pane — and
5638
+ // only once (the synthesized duplicates find no debt and stay local).
5639
+ if (forwardedDown) {
5640
+ const pane = panesById().get(forwardedDown);
5641
+ forwardedDown = null;
5642
+ if (pane && selectModePane() !== pane.id) forwardPress(pane, x, y - TABBAR_H, true);
3938
5643
  return;
3939
5644
  }
3940
5645
  }
@@ -3962,7 +5667,9 @@ render(
3962
5667
  return;
3963
5668
  }
3964
5669
  // Session rows switch the workspace; agent rows JUMP to their exact pane
3965
- // (M22.2). The agents-header row and the empty-state line are inert.
5670
+ // (M22.2). The agents-header row opens the TEAM dialog — its [+ agent]
5671
+ // chip (also on the empty-state row) spawns via the one-dialog kind
5672
+ // picker (M24.1).
3966
5673
  const hit = sidebarHit(gy, fleet().length, fleetAgents().length);
3967
5674
  if (hit?.kind === "session") {
3968
5675
  const s = fleet()[hit.index];
@@ -3970,6 +5677,11 @@ render(
3970
5677
  } else if (hit?.kind === "agent") {
3971
5678
  const a = fleetAgents()[hit.index];
3972
5679
  if (a) jumpToAgent(a);
5680
+ } else if (hit?.kind === "agents-header") {
5681
+ if (spanHit(agentsChipSpans(), x) === 0) void newAgentFlow(currentNewAgentContext());
5682
+ else void manageTeamFlow();
5683
+ } else if (hit?.kind === "agents-empty") {
5684
+ if (spanHit(agentsChipSpans(), x) === 0) void newAgentFlow(currentNewAgentContext());
3973
5685
  }
3974
5686
  return;
3975
5687
  }
@@ -3990,12 +5702,17 @@ render(
3990
5702
  void openFolderFlow();
3991
5703
  return;
3992
5704
  }
3993
- // A row click: the right-aligned verb chip wins over the row body;
5705
+ // A row click: the right-aligned verb chips win over the row body
5706
+ // ([+ agent] spawns — M23.1; the primary chip diffs/launches/reopens);
3994
5707
  // header rows are inert. Sessions open, projects launch, recents reopen.
3995
5708
  const idx = homeItemIndexAt(gy);
3996
5709
  const it = homeItems()[idx];
3997
5710
  if (!it || it.kind === "header") return;
3998
- if (homeChipHit(it, x)) runHomeChip(idx);
5711
+ const chip = homeChipAt(it, x);
5712
+ if (chip === "agent") {
5713
+ setSel(idx);
5714
+ newAgentFromHome(it);
5715
+ } else if (chip === "primary") runHomeChip(idx);
3999
5716
  else activateHomeItem(idx);
4000
5717
  return;
4001
5718
  }
@@ -4010,7 +5727,7 @@ render(
4010
5727
  const dir = e.scroll?.direction;
4011
5728
  if (dir !== "up" && dir !== "down") return;
4012
5729
  const step = dir === "up" ? -SCROLL_STEP : SCROLL_STEP;
4013
- if (overList) setFileTop((t) => clampTop(t + step, fileNodes().length, editorRows()));
5730
+ if (overList) setFileTop((t) => clampTop(t + step, visibleFiles().length, editorRows()));
4014
5731
  else setEditorTop((t) => clampTop(t + step, editorLines().length, editorRows()));
4015
5732
  return;
4016
5733
  }
@@ -4025,9 +5742,9 @@ render(
4025
5742
  const contentY = gy - HEADER_ROWS;
4026
5743
  if (contentY < 0 || contentY >= editorRows()) return;
4027
5744
  if (overList) {
4028
- const top = clampTop(fileTop(), fileNodes().length, editorRows());
5745
+ const top = clampTop(fileTop(), visibleFiles().length, editorRows());
4029
5746
  const idx = top + contentY;
4030
- if (idx >= 0 && idx < fileNodes().length) {
5747
+ if (idx >= 0 && idx < visibleFiles().length) {
4031
5748
  clearSelection();
4032
5749
  setFilesFocus("list");
4033
5750
  activateFile(idx);
@@ -4060,9 +5777,11 @@ render(
4060
5777
  setEditorRev((r) => r + 1);
4061
5778
  return;
4062
5779
  }
4063
- // DIFF mode: header (gy=0) + rule (gy=1), body from gy=2. Left column
4064
- // [0,listW) is the file list, the rest is the diff. Wheel scrolls whichever
4065
- // column the pointer is over; a left-column click selects that file row.
5780
+ // DIFF mode: header (gy=0) + rule (gy=1), body from gy=2, footer verbs on
5781
+ // the last screen row. Left column [0,listW) is the grouped file list, the
5782
+ // rest is the diff. Wheel scrolls whichever column the pointer is over; a
5783
+ // left-column click selects that file ROW (headers are inert), and the
5784
+ // row's right-anchored [s stage]/[u unstage] chip wins over selection.
4066
5785
  if (mode() === "diff") {
4067
5786
  const overList = x < sidebarW() + diffListW();
4068
5787
  if (type === "scroll") {
@@ -4070,7 +5789,7 @@ render(
4070
5789
  if (dir !== "up" && dir !== "down") return;
4071
5790
  const step = dir === "up" ? -SCROLL_STEP : SCROLL_STEP;
4072
5791
  if (overList) {
4073
- setDiffFileTop((t) => clampTop(t + step, diffFiles().length, diffBodyRows()));
5792
+ setDiffFileTop((t) => clampTop(t + step, diffRows().length, diffBodyRows()));
4074
5793
  } else {
4075
5794
  setDiffTop((t) => clampTop(t + step, diffLines().length, diffBodyRows()));
4076
5795
  }
@@ -4084,11 +5803,24 @@ render(
4084
5803
  if (i >= 0) runButton(hb.defs[i]!.id);
4085
5804
  return;
4086
5805
  }
5806
+ // The footer's stage/unstage verb chips (same spans the render draws).
5807
+ if (y === dims().height - 1) {
5808
+ const i = spanHit(diffVerbSpans(), x);
5809
+ if (i >= 0) runDiffVerb(DIFF_VERBS[i]!.id);
5810
+ return;
5811
+ }
4087
5812
  const contentY = gy - HEADER_ROWS;
4088
5813
  if (contentY < 0 || !overList) return;
4089
- const top = clampTop(diffFileTop(), diffFiles().length, diffBodyRows());
4090
- const idx = top + contentY;
4091
- if (idx >= 0 && idx < diffFiles().length) selectDiffFile(idx);
5814
+ const rows = diffRows();
5815
+ const top = clampTop(diffFileTop(), rows.length, diffBodyRows());
5816
+ const row = rows[top + contentY];
5817
+ if (!row || row.kind !== "file") return;
5818
+ const chip = diffRowChipSpan(row.entry);
5819
+ if (x >= chip.start && x < chip.start + chip.width) {
5820
+ toggleStageEntry(row.entry);
5821
+ return;
5822
+ }
5823
+ selectDiffFile(row.fileIndex);
4092
5824
  return;
4093
5825
  }
4094
5826
  // The per-window strip (gy=1) — resolved by the SAME x-span math the render
@@ -4116,21 +5848,37 @@ render(
4116
5848
  if (!pane) return;
4117
5849
  if (type === "down") {
4118
5850
  mirror?.focus(pane.id);
4119
- // App-mouse panes forward the press UNLESS this pane's select mode is
4120
- // on or the press is shift-modified (M22.9) — then the normal selection
4121
- // machine below runs instead (non-app-mouse panes are unchanged).
4122
- if (
4123
- !pressStartsSelection(
4124
- pane.appMouse,
4125
- selectModePane() === pane.id,
4126
- e.modifiers?.shift === true,
4127
- )
4128
- ) {
5851
+ // Where the press goes (M22.9 + M24.2): plain panes and select mode run
5852
+ // the selection machine below; app-mouse panes follow the pane's drag
5853
+ // default (agents select, others forward; shift inverts) — a select
5854
+ // default DEFERS the press so a genuine click still reaches the app
5855
+ // (see the pendingPress resolution above).
5856
+ const routing = routePanePress(
5857
+ pane.appMouse,
5858
+ selectModePane() === pane.id,
5859
+ e.modifiers?.shift === true,
5860
+ paneDrag(pane.id),
5861
+ );
5862
+ if (routing === "forward") {
5863
+ forwardedDown = pane.id;
4129
5864
  forwardPress(pane, x, gy, false);
4130
5865
  return;
4131
5866
  }
5867
+ if (routing === "defer") {
5868
+ pendingPress = {
5869
+ paneId: pane.id,
5870
+ x,
5871
+ gy,
5872
+ cell: paneCell(pane, x, gy),
5873
+ absCell: paneAbsCell(pane, x, gy),
5874
+ trimBase: mirror?.lineTrim(pane.id) ?? 0,
5875
+ };
5876
+ return;
5877
+ }
4132
5878
  // Begin a drag selection, or on a repeat click select
4133
- // the word (double) / line (triple) and copy it immediately.
5879
+ // the word (double) / line (triple) and copy it immediately. Click
5880
+ // cadence tracks in VIEWPORT cells (the same physical spot); the
5881
+ // selection itself anchors in ABSOLUTE buffer cells (M25.6).
4134
5882
  const cell = paneCell(pane, x, gy);
4135
5883
  const now = Date.now();
4136
5884
  const count = clickCount(lastClick, cell, now, CLICK_MS);
@@ -4138,21 +5886,23 @@ render(
4138
5886
  if (count >= 2) {
4139
5887
  const rowText = paneRowTexts(pane.id)[cell.row] ?? "";
4140
5888
  const r = count === 2 ? wordRangeAt(rowText, cell.col) : lineRangeAt(rowText);
4141
- const anchor = { row: cell.row, col: r.from };
4142
- const head = { row: cell.row, col: r.to };
5889
+ const absRow = paneBaseY(pane.id) + cell.row;
5890
+ const anchor = { row: absRow, col: r.from };
5891
+ const head = { row: absRow, col: r.to };
4143
5892
  setSelection({ surface: "mirror", paneId: pane.id, anchor, head });
4144
5893
  selecting = null;
4145
5894
  commitMirrorCopy(pane.id, anchor, head);
4146
5895
  } else {
4147
- selAnchor = cell;
5896
+ selAnchor = paneAbsCell(pane, x, gy);
5897
+ selTrimBase = mirror?.lineTrim(pane.id) ?? 0;
4148
5898
  selecting = { surface: "mirror", paneId: pane.id };
4149
5899
  setSelection(null);
4150
5900
  }
4151
- } else if (type === "up") {
4152
- // Releases of a local selection gesture never reach here (the
4153
- // `selecting` branch above returns); select mode suppresses the rest.
4154
- if (pane.appMouse && selectModePane() !== pane.id) forwardPress(pane, x, gy, true);
4155
5901
  } else if (type === "scroll") {
5902
+ // (Releases never reach here: local gestures are consumed by the
5903
+ // selecting/pendingPress branches above and a forwarded press's
5904
+ // release is paid via the `forwardedDown` debt — never re-derived
5905
+ // from whichever pane happens to sit under the pointer.)
4156
5906
  const dir = e.scroll?.direction;
4157
5907
  if (dir === "up" || dir === "down") {
4158
5908
  const { col, row } = paneCell(pane, x, gy);
@@ -4188,7 +5938,10 @@ render(
4188
5938
  if (!e || !p) return null;
4189
5939
  // Budget: pane width minus the left inset (1) + padding (2), capped so a
4190
5940
  // wide pane's chip stays a chip (and clears the top-right scroll badge).
4191
- return chipLabel(e, STATUS_GLYPH[e.state], Date.now(), Math.min(p.width - 3, 28));
5941
+ // A self-reported status text earns a wider cap (M25.4) — "● claude ·
5942
+ // refactoring auth" needs the room; chipLabel still truncates to fit.
5943
+ const cap = e.statusText ? 44 : 28;
5944
+ return chipLabel(e, STATUS_GLYPH[e.state], Date.now(), Math.min(p.width - 3, cap));
4192
5945
  };
4193
5946
  return (
4194
5947
  <Show when={label()}>
@@ -4297,106 +6050,28 @@ render(
4297
6050
  )}
4298
6051
  </For>
4299
6052
  </box>
4300
- <box flexDirection="row" flexGrow={1} backgroundColor={DEFAULT_BG}>
4301
- <box
6053
+ <box flexDirection="row" flexGrow={1} backgroundColor={DEFAULT_BG} overflow="hidden">
6054
+ <Sidebar
4302
6055
  width={sidebarW()}
6056
+ sessions={fleet()}
6057
+ agents={fleetAgents()}
6058
+ current={curTarget()}
6059
+ nowSec={Math.floor(Date.now() / 1000)}
6060
+ isHovered={isHovered}
6061
+ flashed={(paneId: string) => attnFlash().has(paneId)}
6062
+ hint={{
6063
+ pre: SIDEBAR_HINT_PRE,
6064
+ btn: SIDEBAR_HINT_BTN,
6065
+ post: SIDEBAR_HINT_POST,
6066
+ }}
6067
+ onMouse={(e) => route(e as RouteEvent)}
6068
+ />
6069
+ <box
4303
6070
  flexDirection="column"
4304
- backgroundColor={SIDEBAR_BG}
4305
- paddingLeft={1}
6071
+ flexGrow={1}
6072
+ overflow="hidden"
4306
6073
  onMouse={(e: RouteEvent) => route(e)}
4307
6074
  >
4308
- <text fg={ACCENT} attributes={1}>
4309
- tmux-ide
4310
- </text>
4311
- <text fg={MUTED}>{"─".repeat(sidebarW() - 2)}</text>
4312
- <box flexDirection="column">
4313
- <For each={fleet()}>
4314
- {(s, i) => (
4315
- <box
4316
- flexDirection="row"
4317
- gap={1}
4318
- backgroundColor={
4319
- s.name === curTarget()
4320
- ? TAB_ACTIVE_BG
4321
- : isHovered("sidebar", i())
4322
- ? HOVER_BG
4323
- : SIDEBAR_BG
4324
- }
4325
- >
4326
- <text fg={STATUS_COLOR[s.status]}>{STATUS_GLYPH[s.status]}</text>
4327
- <text fg={s.name === curTarget() ? DEFAULT_FG : MUTED}>
4328
- {s.name.slice(0, sidebarW() - 5)}
4329
- </text>
4330
- </box>
4331
- )}
4332
- </For>
4333
- </box>
4334
- {/* AGENTS section (M22.2): the fleet's agents at a glance, one row
4335
- per agent, sorted attention-first (fleetAgents), each a JUMP target
4336
- (click → its session/window/pane). REUSES the session rows' glyph +
4337
- state-color grammar (STATUS_GLYPH/STATUS_COLOR by state). Row
4338
- y-accounting for the router is pure in `sidebarHit`: this section
4339
- starts right after the session <For>, one header row then the agent
4340
- rows (or one quiet empty-state line). Hover reveals the state age. */}
4341
- <box flexDirection="column" marginTop={AGENTS_GAP_ROWS}>
4342
- <text fg={MUTED} attributes={1}>
4343
- {agentsHeaderLabel(fleetAgents().length, sidebarW() - 2)}
4344
- </text>
4345
- <Show
4346
- when={fleetAgents().length > 0}
4347
- fallback={<text fg={MUTED}>{AGENTS_EMPTY_LINE.slice(0, sidebarW() - 2)}</text>}
4348
- >
4349
- <For each={fleetAgents()}>
4350
- {(a, i) => {
4351
- const age = () =>
4352
- agentAgeLabel(a.state, a.since, Math.floor(Date.now() / 1000));
4353
- const hovered = () => isHovered("sidebaragent", i());
4354
- const ageShown = () => (hovered() ? age() : null);
4355
- const labelBudget = () => {
4356
- const a2 = ageShown();
4357
- return sidebarW() - 5 - (a2 ? a2.length + 1 : 0);
4358
- };
4359
- // Blocked is the attention state: bold, matching the
4360
- // statusline's grammar (`blocked` reads bold there too).
4361
- const attn = () => (a.state === "blocked" ? 1 : 0);
4362
- return (
4363
- <box
4364
- flexDirection="row"
4365
- gap={1}
4366
- backgroundColor={hovered() ? HOVER_BG : SIDEBAR_BG}
4367
- >
4368
- <text fg={STATUS_COLOR[a.state]} attributes={attn()}>
4369
- {STATUS_GLYPH[a.state]}
4370
- </text>
4371
- <text
4372
- fg={a.state === "blocked" ? STATUS_COLOR.blocked : MUTED}
4373
- attributes={attn()}
4374
- >
4375
- {agentRowLabel(a.kind, a.session, labelBudget())}
4376
- </text>
4377
- <Show when={ageShown()}>
4378
- <box flexGrow={1} />
4379
- <text fg={MUTED}>{ageShown()}</text>
4380
- </Show>
4381
- </box>
4382
- );
4383
- }}
4384
- </For>
4385
- </Show>
4386
- </box>
4387
- <box flexGrow={1} />
4388
- {/* Footer hint — its "F5 palette" segment is a CHIP (M21.9): the
4389
- router hit-tests SIDEBAR_HINT_SPAN on the last screen row, and
4390
- these three runs render the exact same cells. */}
4391
- <box flexDirection="row">
4392
- <text fg={MUTED}>{SIDEBAR_HINT_PRE}</text>
4393
- <text fg={MUTED} bg={isHovered("sidebtn", 0) ? BUTTON_HOVER_BG : SIDEBAR_BG}>
4394
- {SIDEBAR_HINT_BTN}
4395
- </text>
4396
- <text fg={MUTED}>{SIDEBAR_HINT_POST}</text>
4397
- </box>
4398
- </box>
4399
- <box flexDirection="column" flexGrow={1} onMouse={(e: RouteEvent) => route(e)}>
4400
6075
  <Show when={mode() === "home"}>
4401
6076
  {/* HOME header (y=0) + rule (y=1); rows below start at y=2 — the
4402
6077
  coordinate `route` reverses for a home-row click. */}
@@ -4490,12 +6165,23 @@ render(
4490
6165
  </text>
4491
6166
  <text fg={MUTED}>{homeRowMeta(it)}</text>
4492
6167
  <box flexGrow={1} />
4493
- <text
4494
- fg={BUTTON_FG}
4495
- bg={isHovered("homechip", i()) ? BUTTON_HOVER_BG : BUTTON_BG}
4496
- >
4497
- {homeChipLabel(it)}
4498
- </text>
6168
+ {/* The row's chips (M23.1: [+ agent] before the primary
6169
+ verb) — the SAME defs homeChipAt lays out from the
6170
+ right edge, so hover/click match cell-for-cell. */}
6171
+ <For each={homeRowChips(it)}>
6172
+ {(c) => (
6173
+ <text
6174
+ fg={BUTTON_FG}
6175
+ bg={
6176
+ isHovered(c.id === "agent" ? "homeagentchip" : "homechip", i())
6177
+ ? BUTTON_HOVER_BG
6178
+ : BUTTON_BG
6179
+ }
6180
+ >
6181
+ {c.label}
6182
+ </text>
6183
+ )}
6184
+ </For>
4499
6185
  </box>
4500
6186
  </Show>
4501
6187
  )}
@@ -4570,7 +6256,7 @@ render(
4570
6256
  </Show>
4571
6257
  {buttonRow(stripButtons)}
4572
6258
  </box>
4573
- <box position="relative" flexGrow={1} backgroundColor={GUTTER_BG}>
6259
+ <box position="relative" flexGrow={1} backgroundColor={GUTTER_BG} overflow="hidden">
4574
6260
  {/* M21.3 — framebuffer blit (flagged). ONE <pane_surface> per
4575
6261
  pane blits the grid straight into packed buffers; the For keys
4576
6262
  on the stable id list so a content tick reuses each surface (and
@@ -4590,6 +6276,7 @@ render(
4590
6276
  height={pane.height}
4591
6277
  flexDirection="column"
4592
6278
  backgroundColor={DEFAULT_BG}
6279
+ overflow="hidden"
4593
6280
  >
4594
6281
  <For each={paneSelRows(pane)}>
4595
6282
  {(runs) => (
@@ -4646,6 +6333,7 @@ render(
4646
6333
  width={pane()!.width}
4647
6334
  height={pane()!.height}
4648
6335
  backgroundColor={DEFAULT_BG}
6336
+ overflow="hidden"
4649
6337
  >
4650
6338
  <pane_surface
4651
6339
  width={pane()!.width}
@@ -4771,6 +6459,9 @@ render(
4771
6459
  <text fg={MUTED}>{`${editorCursor().row + 1}:${editorCursor().col + 1}`}</text>
4772
6460
  <text fg={MUTED}>{`${editorLines().length}L`}</text>
4773
6461
  <text fg={MUTED}>{editorMsg()}</text>
6462
+ <Show when={filesQuery() !== null}>
6463
+ <text fg={ACCENT}>{`/${filesQuery()}▏`}</text>
6464
+ </Show>
4774
6465
  {buttonRow(headerButtons)}
4775
6466
  </box>
4776
6467
  <Show
@@ -4788,13 +6479,16 @@ render(
4788
6479
  {(row) => {
4789
6480
  const n = row.node;
4790
6481
  const selected = () => row.index === fileSel() && filesFocus() === "list";
6482
+ // Reactive: the status map refreshes on the watcher/poll.
6483
+ const letter = () => fileStatusFor(n);
4791
6484
  const prefix =
4792
6485
  " ".repeat(n.depth) + (n.isDir ? (n.expanded ? "▾ " : "▸ ") : " ");
4793
- const label = (prefix + n.name).slice(0, filesListW() - 1);
6486
+ const label = (prefix + n.name).slice(0, filesListW() - 4);
4794
6487
  return (
4795
6488
  <box
4796
6489
  paddingLeft={1}
4797
6490
  height={1}
6491
+ flexDirection="row"
4798
6492
  backgroundColor={
4799
6493
  selected()
4800
6494
  ? TAB_ACTIVE_BG
@@ -4803,9 +6497,25 @@ render(
4803
6497
  : GUTTER_BG
4804
6498
  }
4805
6499
  >
4806
- <text fg={n.isDir ? DIR_FG : selected() ? DEFAULT_FG : MUTED}>
6500
+ <text
6501
+ flexGrow={1}
6502
+ fg={
6503
+ n.ignored
6504
+ ? DIFF_META_FG // gitignored: dimmed when shown
6505
+ : n.isDir
6506
+ ? DIR_FG
6507
+ : selected()
6508
+ ? DEFAULT_FG
6509
+ : MUTED
6510
+ }
6511
+ >
4807
6512
  {label}
4808
6513
  </text>
6514
+ <Show when={letter()}>
6515
+ <text fg={STATUS_LETTER_FG[letter()!] ?? DEFAULT_FG}>
6516
+ {` ${letter()!}`}
6517
+ </text>
6518
+ </Show>
4809
6519
  </box>
4810
6520
  );
4811
6521
  }}
@@ -4856,19 +6566,34 @@ render(
4856
6566
  </box>
4857
6567
  <box paddingLeft={1}>
4858
6568
  <text fg={MUTED}>
4859
- {"j/k file · enter open · ^s save · esc list · ^g home · ^q quit"}
6569
+ {`j/k · enter open · [/] change · / filter · H dot:${
6570
+ showHiddenFiles() ? "on" : "off"
6571
+ } · I ign:${
6572
+ showIgnoredFiles() ? "on" : "off"
6573
+ } · ^s save · esc list · ^g home · ${QUIT_HINT}`}
4860
6574
  </text>
4861
6575
  </box>
4862
6576
  </Show>
4863
6577
  <Show when={mode() === "diff"}>
4864
- {/* header (y=0) · rule (y=1) · two-column body (y=2+). `route` reverses
4865
- this geometry: left column = file list, right = diff. NO onMouse on
4866
- the rows — the main column container routes everything. */}
6578
+ {/* header (y=0) · rule (y=1) · two-column body (y=2+) · footer
6579
+ verbs (last row). `route` reverses this geometry: left column =
6580
+ grouped file list (headers + rows from the SAME buildDiffRows the
6581
+ router walks), right = diff. NO onMouse on the rows — the main
6582
+ column container routes everything. */}
4867
6583
  <box paddingLeft={1} flexDirection="row" gap={1}>
4868
6584
  <text fg={ACCENT} attributes={1}>
4869
6585
  {basename(diffDir()) || "diff"}
4870
6586
  </text>
4871
- <text fg={MUTED}>{`${diffFiles().length} changed`}</text>
6587
+ <text fg={MUTED}>{`${diffVisibleFiles().length} files`}</text>
6588
+ <Show when={diffTotals().additions > 0}>
6589
+ <text fg={DIFF_ADD_FG}>{`+${diffTotals().additions}`}</text>
6590
+ </Show>
6591
+ <Show when={diffTotals().deletions > 0}>
6592
+ <text fg={DIFF_DEL_FG}>{`-${diffTotals().deletions}`}</text>
6593
+ </Show>
6594
+ <Show when={diffFilter() !== null}>
6595
+ <text fg={ACCENT}>{`/${diffFilter()}▏`}</text>
6596
+ </Show>
4872
6597
  <Show when={diffMsg()}>
4873
6598
  <text fg={MUTED}>{`· ${diffMsg()}`}</text>
4874
6599
  </Show>
@@ -4876,50 +6601,89 @@ render(
4876
6601
  </box>
4877
6602
  <text fg={MUTED}>{"─".repeat(Math.max(4, canvasCols() - 2))}</text>
4878
6603
  <box flexDirection="row" flexGrow={1}>
4879
- {/* Left: changed-file list. */}
6604
+ {/* Left: the grouped changed-file list (Staged / Unstaged /
6605
+ Untracked). Section headers are inert; file rows show ±
6606
+ counts and, when selected or hovered, a right-anchored
6607
+ stage/unstage chip the router hit-tests with the SAME
6608
+ spansFromRight math. */}
4880
6609
  <box width={diffListW()} flexDirection="column" backgroundColor={GUTTER_BG}>
4881
6610
  <For each={fileVisible()}>
4882
- {(row) => (
4883
- <box
4884
- flexDirection="row"
4885
- gap={1}
4886
- paddingLeft={1}
4887
- backgroundColor={
4888
- row.index === diffSel()
4889
- ? TAB_ACTIVE_BG
4890
- : isHovered("diff", row.index)
4891
- ? HOVER_BG
4892
- : GUTTER_BG
4893
- }
4894
- >
4895
- <text fg={STATUS_LETTER_FG[row.entry.status] ?? DEFAULT_FG}>
4896
- {row.entry.status}
4897
- </text>
4898
- <text fg={row.index === diffSel() ? DEFAULT_FG : MUTED}>
4899
- {row.entry.path.length > diffListW() - 4
4900
- ? "…" + row.entry.path.slice(-(diffListW() - 5))
4901
- : row.entry.path}
4902
- </text>
4903
- </box>
4904
- )}
6611
+ {({ row, rowIndex }) =>
6612
+ row.kind === "header" ? (
6613
+ <box height={1} paddingLeft={1} backgroundColor={GUTTER_BG}>
6614
+ <text fg={ACCENT} attributes={1}>
6615
+ {row.label}
6616
+ </text>
6617
+ </box>
6618
+ ) : (
6619
+ <box
6620
+ height={1}
6621
+ flexDirection="row"
6622
+ gap={1}
6623
+ paddingLeft={1}
6624
+ backgroundColor={
6625
+ row.fileIndex === diffSel()
6626
+ ? TAB_ACTIVE_BG
6627
+ : isHovered("diff", rowIndex)
6628
+ ? HOVER_BG
6629
+ : GUTTER_BG
6630
+ }
6631
+ >
6632
+ <text fg={STATUS_LETTER_FG[row.entry.status] ?? DEFAULT_FG}>
6633
+ {row.entry.status}
6634
+ </text>
6635
+ <text fg={row.fileIndex === diffSel() ? DEFAULT_FG : MUTED}>
6636
+ {diffRowPath(
6637
+ row.entry,
6638
+ row.fileIndex === diffSel() || isHovered("diff", rowIndex),
6639
+ )}
6640
+ </text>
6641
+ <Show when={(row.entry.additions ?? 0) > 0}>
6642
+ <text fg={DIFF_ADD_FG}>{`+${row.entry.additions}`}</text>
6643
+ </Show>
6644
+ <Show when={(row.entry.deletions ?? 0) > 0}>
6645
+ <text fg={DIFF_DEL_FG}>{`-${row.entry.deletions}`}</text>
6646
+ </Show>
6647
+ <Show when={row.fileIndex === diffSel() || isHovered("diff", rowIndex)}>
6648
+ <box flexGrow={1} />
6649
+ <text fg={BUTTON_FG} bg={BUTTON_BG}>
6650
+ {diffRowChipLabel(row.entry)}
6651
+ </text>
6652
+ </Show>
6653
+ </box>
6654
+ )
6655
+ }
4905
6656
  </For>
4906
6657
  </box>
4907
- {/* Right: unified diff of the selected file, with a right-edge
4908
- scrollbar overlaid on the last column. */}
6658
+ {/* Right: unified diff of the selected file — add/del rows carry
6659
+ the widget-derived background fills under the fg classes — with
6660
+ a right-edge scrollbar overlaid on the last column. */}
4909
6661
  <box position="relative" flexGrow={1} flexDirection="column" paddingLeft={1}>
4910
6662
  {scrollbarOverlay(diffScrollGeom)}
4911
6663
  <For each={diffVisible()}>
4912
6664
  {(ln) => (
4913
- <box height={1}>
6665
+ <box height={1} backgroundColor={DIFF_LINE_BG[ln.kind] ?? DEFAULT_BG}>
4914
6666
  <text fg={DIFF_FG[ln.kind]}>{ln.text || " "}</text>
4915
6667
  </box>
4916
6668
  )}
4917
6669
  </For>
4918
6670
  </box>
4919
6671
  </box>
4920
- <box paddingLeft={1}>
6672
+ {/* Footer: clickable stage/unstage verbs (the spans the router
6673
+ hit-tests) followed by plain keyboard hints. */}
6674
+ <box paddingLeft={1} flexDirection="row" gap={1}>
6675
+ <For each={DIFF_VERBS}>
6676
+ {(v, i) => (
6677
+ <text
6678
+ fg={BUTTON_FG}
6679
+ bg={isHovered("diffverb", i()) ? BUTTON_HOVER_BG : BUTTON_BG}
6680
+ >
6681
+ {v.label}
6682
+ </text>
6683
+ )}
6684
+ </For>
4921
6685
  <text fg={MUTED}>
4922
- {"j/k file · wheel scroll · ^e edit · r refresh · ^g home · ^q quit"}
6686
+ {`]/[ hunk · ^e edit · / filter · r refresh · ^g home · ${QUIT_HINT}`}
4923
6687
  </text>
4924
6688
  </box>
4925
6689
  </Show>
@@ -4959,21 +6723,40 @@ render(
4959
6723
  <text fg={DEFAULT_FG}>{`${paletteQuery()}▏`}</text>
4960
6724
  </box>
4961
6725
  <text fg={MUTED}>{"─".repeat(PALETTE_W - 4)}</text>
4962
- <For each={paletteActions().slice(paletteTop(), paletteTop() + PALETTE_ROWS)}>
4963
- {(a, i) => (
6726
+ {/* Rows (M24.4): group headers render as inert muted lines;
6727
+ action rows carry the selection prefix + a right-aligned
6728
+ keycap (paletteRowText keeps the keycap inside the width
6729
+ however long the label). Hit-testing stays row-level — the
6730
+ router skips headers by row type. */}
6731
+ <For each={paletteRowList().slice(paletteTop(), paletteTop() + PALETTE_ROWS)}>
6732
+ {(r, i) => (
4964
6733
  <box
4965
6734
  height={1}
4966
6735
  backgroundColor={
4967
- paletteTop() + i() === paletteSel() ? TAB_ACTIVE_BG : PALETTE_BG
6736
+ r.type === "action" && paletteTop() + i() === paletteSel()
6737
+ ? TAB_ACTIVE_BG
6738
+ : PALETTE_BG
4968
6739
  }
4969
6740
  >
4970
- <text fg={paletteTop() + i() === paletteSel() ? DEFAULT_FG : MUTED}>
4971
- {(paletteTop() + i() === paletteSel() ? "› " : " ") + a.label}
4972
- </text>
6741
+ <Show
6742
+ when={r.type === "action"}
6743
+ fallback={
6744
+ <text fg={MUTED}>{`— ${r.type === "header" ? r.label : ""}`}</text>
6745
+ }
6746
+ >
6747
+ <text fg={paletteTop() + i() === paletteSel() ? DEFAULT_FG : MUTED}>
6748
+ {(paletteTop() + i() === paletteSel() ? "› " : " ") +
6749
+ paletteRowText(
6750
+ r.type === "action" ? r.action.label : "",
6751
+ r.type === "action" ? r.shortcut : null,
6752
+ PALETTE_W - 6,
6753
+ )}
6754
+ </text>
6755
+ </Show>
4973
6756
  </box>
4974
6757
  )}
4975
6758
  </For>
4976
- <Show when={paletteActions().length === 0}>
6759
+ <Show when={paletteRowList().length === 0}>
4977
6760
  <text fg={MUTED}>{" no matches"}</text>
4978
6761
  </Show>
4979
6762
  </>
@@ -5258,8 +7041,8 @@ render(
5258
7041
  </Show>
5259
7042
  </box>
5260
7043
  );
5261
- },
5262
- // targetFps stays EXPLICIT: @opentui 0.4.3 still silently defaults it to 30
5263
- // (maxFps already defaults to 60). Re-confirmed on the 0.4.3 bump (M21.2).
5264
- { targetFps: 60, maxFps: 60 },
5265
- );
7044
+ }, appRenderer);
7045
+ } catch (error) {
7046
+ appRenderer.destroy();
7047
+ throw error;
7048
+ }