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.
- package/README.md +22 -5
- package/bin/cli.js +3532 -1090
- package/bin/cli.ts +368 -71
- package/package.json +2 -1
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- package/packages/daemon/dist/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- package/packages/daemon/src/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +103 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +582 -84
- package/packages/daemon/src/tui/chrome/updater.ts +268 -62
- package/packages/daemon/src/tui/detect/classify.ts +34 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
- package/packages/daemon/src/tui/detect/manifest.ts +24 -3
- package/packages/daemon/src/tui/detect/manifests.ts +240 -6
- package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
- package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
- package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
- package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
- package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
- package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
- package/packages/daemon/src/tui/mirror/palette.ts +299 -9
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
- package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
- package/packages/daemon/src/tui/mirror/selection.ts +122 -8
- package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
- package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/sessions.ts +85 -7
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- 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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
731
|
-
//
|
|
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(
|
|
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
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
// `
|
|
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 [
|
|
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
|
-
|
|
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
|
|
1137
|
-
const
|
|
1138
|
-
const top = clampTop(diffFileTop(),
|
|
1139
|
-
return
|
|
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
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
const loadDiff = (entry:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1186
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
1199
|
-
*
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
-
//
|
|
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
|
|
1705
|
+
const path = pendingDiffFile;
|
|
1706
|
+
const group = pendingDiffGroup;
|
|
1213
1707
|
pendingDiffFile = null;
|
|
1214
|
-
|
|
1215
|
-
|
|
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:
|
|
1247
|
-
rows:
|
|
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
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
// render-loop landmine);
|
|
1296
|
-
// file-
|
|
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
|
|
1309
|
-
const
|
|
1310
|
-
const top = clampTop(fileTop(),
|
|
1311
|
-
return
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
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
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
.
|
|
1329
|
-
|
|
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
|
|
1334
|
-
if (
|
|
1335
|
-
const idx = clampSel(fileSel() + delta,
|
|
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(),
|
|
1338
|
-
};
|
|
1339
|
-
/** Enter on a row: open a file in the editor, or toggle a directory
|
|
1340
|
-
* readdir → splice children in, or prune the subtree).
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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,
|
|
2119
|
+
setFileNodes((list) => removeSubtreeAt(list, indexOfPath(list, node.path)));
|
|
1351
2120
|
return;
|
|
1352
2121
|
}
|
|
1353
|
-
void
|
|
2122
|
+
void listDir(node.path)
|
|
1354
2123
|
.then((ents) => {
|
|
1355
|
-
const children = buildNodes(node.path, ents
|
|
1356
|
-
setFileNodes((list) => insertChildrenAt(list,
|
|
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 (
|
|
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"
|
|
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
|
-
|
|
1651
|
-
|
|
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
|
|
1663
|
-
const paletteCount = () => paletteBuffers()?.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
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
|
3155
|
+
const rows = paletteRowList();
|
|
1834
3156
|
if (evt.name === "escape") {
|
|
1835
3157
|
setPaletteOpen(false);
|
|
1836
3158
|
} else if (evt.name === "return") {
|
|
1837
|
-
const
|
|
1838
|
-
if (
|
|
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) =>
|
|
3162
|
+
setPaletteSel((s) => stepPaletteRow(rows, s, -1));
|
|
1841
3163
|
} else if (evt.name === "down") {
|
|
1842
|
-
setPaletteSel((s) =>
|
|
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
|
|
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:
|
|
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
|
|
2187
|
-
//
|
|
2188
|
-
//
|
|
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
|
|
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 =
|
|
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
|
-
// (
|
|
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
|
-
//
|
|
2732
|
-
//
|
|
2733
|
-
//
|
|
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(),
|
|
4176
|
+
const top = clampTop(fileTop(), visibleFiles().length, editorRows());
|
|
2765
4177
|
const idx = top + contentY;
|
|
2766
|
-
const node =
|
|
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
|
|
2783
|
-
const
|
|
2784
|
-
const
|
|
2785
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
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
|
|
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
|
|
3169
|
-
//
|
|
3170
|
-
// (no-op until a file is opened via
|
|
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
|
-
|
|
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
|
|
3217
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
3233
|
-
//
|
|
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
|
|
3321
|
-
//
|
|
3322
|
-
//
|
|
3323
|
-
|
|
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
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
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
|
|
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
|
-
|
|
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(),
|
|
5227
|
+
const top = clampTop(fileTop(), visibleFiles().length, editorRows());
|
|
3619
5228
|
const idx = top + contentY;
|
|
3620
|
-
setHoverIf(
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
3805
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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(),
|
|
5745
|
+
const top = clampTop(fileTop(), visibleFiles().length, editorRows());
|
|
4029
5746
|
const idx = top + contentY;
|
|
4030
|
-
if (idx >= 0 && idx <
|
|
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
|
|
4064
|
-
// [0,listW) is the file list, the
|
|
4065
|
-
//
|
|
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,
|
|
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
|
|
4090
|
-
const
|
|
4091
|
-
|
|
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
|
-
//
|
|
4120
|
-
//
|
|
4121
|
-
//
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
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
|
|
4142
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
4305
|
-
|
|
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
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
>
|
|
4497
|
-
{
|
|
4498
|
-
|
|
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() -
|
|
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
|
|
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
|
-
{
|
|
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+)
|
|
4865
|
-
this geometry: left column =
|
|
4866
|
-
|
|
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}>{`${
|
|
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
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
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
|
|
4908
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
4963
|
-
|
|
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()
|
|
6736
|
+
r.type === "action" && paletteTop() + i() === paletteSel()
|
|
6737
|
+
? TAB_ACTIVE_BG
|
|
6738
|
+
: PALETTE_BG
|
|
4968
6739
|
}
|
|
4969
6740
|
>
|
|
4970
|
-
<
|
|
4971
|
-
{
|
|
4972
|
-
|
|
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={
|
|
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
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
7044
|
+
}, appRenderer);
|
|
7045
|
+
} catch (error) {
|
|
7046
|
+
appRenderer.destroy();
|
|
7047
|
+
throw error;
|
|
7048
|
+
}
|