tmux-ide 2.6.0 → 2.7.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 +14 -9
- package/bin/cli.js +1024 -519
- package/bin/cli.ts +63 -6
- package/bunfig.toml +4 -0
- package/package.json +18 -7
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +298 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +427 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +382 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +510 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +121 -0
- package/packages/daemon/src/tui/detect/classify.ts +208 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest.ts +199 -0
- package/packages/daemon/src/tui/detect/manifests.ts +354 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +82 -0
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +38 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +83 -0
- package/packages/daemon/src/tui/team/sessions.ts +433 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +26 -2
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InputCoalescer — the pure core of the input fast path (M21.5).
|
|
3
|
+
*
|
|
4
|
+
* Burst-typed LITERAL characters coalesce into one buffered run per pane and
|
|
5
|
+
* leave as a single `send-keys -H <hex…>` write per flush; named/ctrl keys
|
|
6
|
+
* (Enter, C-c, Up, …) are emitted immediately. THE ordering invariant: any
|
|
7
|
+
* buffered literal run flushes BEFORE a named key — or a literal for a
|
|
8
|
+
* DIFFERENT pane — is emitted, so bytes reach the pane in exactly the order
|
|
9
|
+
* the user produced them.
|
|
10
|
+
*
|
|
11
|
+
* Pure core, io shell: this class owns no timers and does no io. The caller
|
|
12
|
+
* injects `emit` (where flushed actions go — the control client's
|
|
13
|
+
* fire-and-forget write in production) and `schedule` (when a pending literal
|
|
14
|
+
* buffer should auto-flush — a microtask in production, a hand-cranked stub in
|
|
15
|
+
* tests). A microtask flush still happens inside the same macrotask that
|
|
16
|
+
* buffered the keystroke — strictly before the process can read more input —
|
|
17
|
+
* so coalescing adds no perceivable latency; it only merges keystrokes that
|
|
18
|
+
* arrived together.
|
|
19
|
+
*
|
|
20
|
+
* Flushes re-chunk the buffered run under {@link SEND_KEYS_CHUNK_BYTES} so a
|
|
21
|
+
* large paste routed through the same path never produces an oversized
|
|
22
|
+
* control-mode command.
|
|
23
|
+
*/
|
|
24
|
+
import { chunkByBytes } from "./selection.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Max UTF-8 payload bytes per `send-keys -H` write. Every byte travels as its
|
|
28
|
+
* own `xx` argv token, and each token hits tmux's yacc command parser — which
|
|
29
|
+
* is the REAL constraint, measured empirically against tmux 3.6b control mode
|
|
30
|
+
* (M21.5), not a line-length cap:
|
|
31
|
+
*
|
|
32
|
+
* - HARD LIMIT: a 16 KB payload (≈16k tokens) is REJECTED outright with
|
|
33
|
+
* `%error: yacc stack overflow` (yacc's ~10k-entry stack).
|
|
34
|
+
* - PARSE COST is superlinear in tokens: one command's reply takes ~3 ms at
|
|
35
|
+
* 1 KB, ~9 ms at 2 KB, ~56 ms at 4 KB, ~170 ms at 8 KB — big chunks STALL
|
|
36
|
+
* the tmux server (and every interleaved keystroke behind it).
|
|
37
|
+
* - END-TO-END, a 100 KB paste lands byte-perfect in ~246/155/180/233/255/
|
|
38
|
+
* 561/1483 ms at 64/128/256/512/1024/2048/4096-byte chunks — the optimum
|
|
39
|
+
* is ~128–256 B; the pre-M21.5 1024 was past the knee, and RAISING the
|
|
40
|
+
* chunk (the naive read of "fewer commands = faster") is strictly worse.
|
|
41
|
+
*
|
|
42
|
+
* 256 B is chosen: within noise of the throughput optimum, ~1 ms of server
|
|
43
|
+
* parse per command (imperceptible for input interleaving), 5× headroom from
|
|
44
|
+
* nothing, and 40× from the yacc cliff.
|
|
45
|
+
*/
|
|
46
|
+
export const SEND_KEYS_CHUNK_BYTES = 256;
|
|
47
|
+
|
|
48
|
+
/** One flushed input action, ready to become a control-mode write. */
|
|
49
|
+
export type InputAction =
|
|
50
|
+
| { kind: "literal"; pane: string; text: string }
|
|
51
|
+
| { kind: "key"; pane: string; key: string };
|
|
52
|
+
|
|
53
|
+
export class InputCoalescer {
|
|
54
|
+
private pane = "";
|
|
55
|
+
private buf = "";
|
|
56
|
+
private scheduled = false;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly emit: (action: InputAction) => void,
|
|
60
|
+
private readonly schedule: (flush: () => void) => void,
|
|
61
|
+
private readonly maxChunkBytes: number = SEND_KEYS_CHUNK_BYTES,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
/** Buffer literal text for `pane`; a pending run for ANOTHER pane flushes
|
|
65
|
+
* first so cross-pane order is preserved. Schedules an auto-flush once per
|
|
66
|
+
* pending run. */
|
|
67
|
+
literal(pane: string, text: string): void {
|
|
68
|
+
if (!pane || !text) return;
|
|
69
|
+
if (this.buf.length > 0 && this.pane !== pane) this.flush();
|
|
70
|
+
this.pane = pane;
|
|
71
|
+
this.buf += text;
|
|
72
|
+
if (!this.scheduled) {
|
|
73
|
+
this.scheduled = true;
|
|
74
|
+
this.schedule(() => {
|
|
75
|
+
this.scheduled = false;
|
|
76
|
+
this.flush();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Emit a named tmux key (Enter, C-c, Up, …) — pending literals flush first
|
|
82
|
+
* (synchronously) so the key can never overtake buffered characters. */
|
|
83
|
+
key(pane: string, key: string): void {
|
|
84
|
+
if (!pane || !key) return;
|
|
85
|
+
this.flush();
|
|
86
|
+
this.emit({ kind: "key", pane, key });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Drain the pending literal run now (chunked under the byte cap). Also the
|
|
90
|
+
* ordering barrier callers place before reply-matched structural commands. */
|
|
91
|
+
flush(): void {
|
|
92
|
+
if (this.buf.length === 0) return;
|
|
93
|
+
const pane = this.pane;
|
|
94
|
+
const text = this.buf;
|
|
95
|
+
this.buf = "";
|
|
96
|
+
for (const chunk of chunkByBytes(text, this.maxChunkBytes)) {
|
|
97
|
+
this.emit({ kind: "literal", pane, text: chunk });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Buffered-but-unflushed character count (tests/introspection only). */
|
|
102
|
+
pending(): number {
|
|
103
|
+
return this.buf.length;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The right-click context-menu model (M19.2) — PURE so it unit-tests without
|
|
3
|
+
* OpenTUI. app.tsx opens a small overlay at the pointer on a right-button press
|
|
4
|
+
* (SGR button 2); the ITEM DESCRIPTORS per region and all the geometry (dims,
|
|
5
|
+
* on-screen clamp, click hit-test) live here, while the closures that actually
|
|
6
|
+
* attach / kill / split / rename stay in the app (a `run` switch over the
|
|
7
|
+
* item's `id`).
|
|
8
|
+
*
|
|
9
|
+
* The overlay is late-mounted inside a <Show>, so — per the app's mouse
|
|
10
|
+
* landmine laws — it carries NO per-item handlers: clicks are routed centrally
|
|
11
|
+
* by coordinate math against {@link menuItemAt}, exactly the way the surface
|
|
12
|
+
* tab bar and window strip resolve their clicks by x-span math. Keeping that
|
|
13
|
+
* math in one tested place is the whole point.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The surfaces a right-click can target. Each maps to a fixed item list; the
|
|
17
|
+
* concrete payload (session name, file path, pane id) rides in the app's menu
|
|
18
|
+
* state, not here. */
|
|
19
|
+
export type MenuRegion = "session" | "file" | "difffile" | "pane" | "window";
|
|
20
|
+
|
|
21
|
+
/** One menu entry. `id` is what the app dispatches on; `danger` items rearm to
|
|
22
|
+
* a "confirm: y" state instead of firing immediately; `input` items open an
|
|
23
|
+
* inline text line (the string is the prompt) before firing; `children` items
|
|
24
|
+
* open a SUBMENU column beside the row (one nesting level); `checkbox` items
|
|
25
|
+
* carry a live ✓/✗ state the app supplies (the toggle keeps the menu open). */
|
|
26
|
+
export interface MenuItem {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
danger?: boolean;
|
|
30
|
+
input?: string;
|
|
31
|
+
children?: MenuItem[];
|
|
32
|
+
checkbox?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** PURE — the fixed item list for each region. The app supplies the context. */
|
|
36
|
+
export const MENU_ITEMS: Record<MenuRegion, MenuItem[]> = {
|
|
37
|
+
session: [
|
|
38
|
+
{ id: "attach", label: "Attach" },
|
|
39
|
+
{ id: "rename", label: "Rename", input: "rename to" },
|
|
40
|
+
{ id: "kill", label: "Kill session", danger: true },
|
|
41
|
+
],
|
|
42
|
+
file: [
|
|
43
|
+
{ id: "open", label: "Open" },
|
|
44
|
+
{ id: "newfile", label: "New file", input: "new file" },
|
|
45
|
+
{ id: "rename", label: "Rename", input: "rename to" },
|
|
46
|
+
{ id: "delete", label: "Delete", danger: true },
|
|
47
|
+
],
|
|
48
|
+
difffile: [
|
|
49
|
+
{ id: "open", label: "Open in editor" },
|
|
50
|
+
{ id: "copypath", label: "Copy path" },
|
|
51
|
+
],
|
|
52
|
+
pane: [
|
|
53
|
+
{ id: "split-h", label: "Split horizontal" },
|
|
54
|
+
{ id: "split-v", label: "Split vertical" },
|
|
55
|
+
{ id: "zoom", label: "Zoom toggle" },
|
|
56
|
+
{ id: "swap-next", label: "Swap with next" },
|
|
57
|
+
{ id: "break", label: "Break to window" },
|
|
58
|
+
{ id: "rotate", label: "Rotate panes" },
|
|
59
|
+
{
|
|
60
|
+
id: "layouts",
|
|
61
|
+
label: "Layouts",
|
|
62
|
+
children: [
|
|
63
|
+
{ id: "layout:even-horizontal", label: "even-horizontal" },
|
|
64
|
+
{ id: "layout:even-vertical", label: "even-vertical" },
|
|
65
|
+
{ id: "layout:main-horizontal", label: "main-horizontal" },
|
|
66
|
+
{ id: "layout:main-vertical", label: "main-vertical" },
|
|
67
|
+
{ id: "layout:tiled", label: "tiled" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{ id: "sync-toggle", label: "Synchronize panes", checkbox: true },
|
|
71
|
+
{ id: "kill", label: "Kill pane", danger: true },
|
|
72
|
+
],
|
|
73
|
+
window: [
|
|
74
|
+
{ id: "new", label: "New window" },
|
|
75
|
+
{ id: "rename", label: "Rename window", input: "rename to" },
|
|
76
|
+
{ id: "kill", label: "Kill window", danger: true },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** PURE — the pane menu's item list for a concrete pane (M22.9). App-mouse
|
|
81
|
+
* panes (the app turned mouse reporting on — presses are forwarded, so a drag
|
|
82
|
+
* can't start a selection) get a leading "Select text…" verb that pauses
|
|
83
|
+
* forwarding for that pane; while its select mode is active the entry flips to
|
|
84
|
+
* the exit verb. Ordinary panes keep the fixed list untouched. */
|
|
85
|
+
export function paneMenuItems(appMouse: boolean, selectModeOn: boolean): MenuItem[] {
|
|
86
|
+
if (!appMouse) return MENU_ITEMS.pane;
|
|
87
|
+
const entry: MenuItem = selectModeOn
|
|
88
|
+
? { id: "select-text-off", label: "Stop selecting" }
|
|
89
|
+
: { id: "select-text", label: "Select text…" };
|
|
90
|
+
return [entry, ...MENU_ITEMS.pane];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The overlay's border (1) + horizontal padding (1 each side). The header row
|
|
94
|
+
* and every item row live INSIDE this frame. */
|
|
95
|
+
const BORDER = 1;
|
|
96
|
+
const PAD = 1;
|
|
97
|
+
/** A minimum inner text width so a short menu still reads as a panel. */
|
|
98
|
+
const MIN_INNER = 16;
|
|
99
|
+
/** The suffix a destructive item rearms to (label + this). Reserved in the
|
|
100
|
+
* width so "confirm: y" is never clipped — the render appends the SAME string. */
|
|
101
|
+
export const CONFIRM_SUFFIX = " confirm: y";
|
|
102
|
+
/** The caret a `children` item renders flush-right ("› Layouts ▸"): a
|
|
103
|
+
* leading space + the glyph, reserved in the width so a submenu row's caret is
|
|
104
|
+
* never clipped. The base "› " prefix (2) already sits on every row. */
|
|
105
|
+
export const SUBMENU_CARET = " ▸";
|
|
106
|
+
|
|
107
|
+
/** PURE — the overlay's cell dimensions for `title` + `items`. Width fits the
|
|
108
|
+
* longest of the title and item rows (each item reserves 2 cells for the "› "
|
|
109
|
+
* selection prefix); height is the top border + header + items + bottom border.
|
|
110
|
+
* Item labels leave slack for the longer "confirm: y" / input renderings, which
|
|
111
|
+
* never need to exceed the base width in practice. */
|
|
112
|
+
export function menuDims(title: string, items: MenuItem[]): { width: number; height: number } {
|
|
113
|
+
const inner = Math.max(
|
|
114
|
+
MIN_INNER,
|
|
115
|
+
title.length,
|
|
116
|
+
// A danger item's rearmed "label confirm: y" must fit; a `children` item
|
|
117
|
+
// reserves the "› " prefix AND the flush-right submenu caret; a `checkbox`
|
|
118
|
+
// item's ✓/✗ mark is the same width as the "› " prefix it replaces. Others
|
|
119
|
+
// reserve 2 cells for the "› " selection prefix.
|
|
120
|
+
...items.map((it) => {
|
|
121
|
+
if (it.danger) return it.label.length + CONFIRM_SUFFIX.length;
|
|
122
|
+
if (it.children) return it.label.length + 2 + SUBMENU_CARET.length;
|
|
123
|
+
return it.label.length + 2;
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
return { width: inner + PAD * 2 + BORDER * 2, height: items.length + 3 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** PURE — the on-screen top-left for a menu of `width`×`height` opened at
|
|
130
|
+
* (`x`,`y`), clamped so the whole box stays on a `screenW`×`screenH` grid. */
|
|
131
|
+
export function clampMenuPos(
|
|
132
|
+
x: number,
|
|
133
|
+
y: number,
|
|
134
|
+
width: number,
|
|
135
|
+
height: number,
|
|
136
|
+
screenW: number,
|
|
137
|
+
screenH: number,
|
|
138
|
+
): { left: number; top: number } {
|
|
139
|
+
const left = Math.max(0, Math.min(x, screenW - width));
|
|
140
|
+
const top = Math.max(0, Math.min(y, screenH - height));
|
|
141
|
+
return { left, top };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The placed overlay's geometry — what the hit-tests need. */
|
|
145
|
+
export interface MenuGeom {
|
|
146
|
+
left: number;
|
|
147
|
+
top: number;
|
|
148
|
+
width: number;
|
|
149
|
+
height: number;
|
|
150
|
+
itemCount: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** PURE — the item index under (`x`,`y`), or -1 when the point is not on an item
|
|
154
|
+
* row (the border, the header, or outside the box). Item `i` renders one row
|
|
155
|
+
* below the top border + header, i.e. at screen y = top + 2 + i. */
|
|
156
|
+
export function menuItemAt(m: MenuGeom, x: number, y: number): number {
|
|
157
|
+
if (x < m.left || x >= m.left + m.width) return -1;
|
|
158
|
+
const i = y - (m.top + BORDER + 1);
|
|
159
|
+
return i >= 0 && i < m.itemCount ? i : -1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** PURE — is (`x`,`y`) anywhere inside the overlay box (item, header, or border)?
|
|
163
|
+
* A down INSIDE that isn't on an item is a no-op (menu stays); a down OUTSIDE
|
|
164
|
+
* closes the menu. */
|
|
165
|
+
export function pointInMenu(m: MenuGeom, x: number, y: number): boolean {
|
|
166
|
+
return x >= m.left && x < m.left + m.width && y >= m.top && y < m.top + m.height;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** PURE — the on-screen top-left for the SUBMENU column that opens beside the
|
|
170
|
+
* `parent` item at `parentItemIndex`. It opens to the RIGHT of the parent, its
|
|
171
|
+
* top border aligned with the parent item's row, then clamps fully on-screen
|
|
172
|
+
* (sliding left over the parent if the right edge would overflow — acceptable
|
|
173
|
+
* since the keyboard/coordinate router owns whichever column is focused). */
|
|
174
|
+
export function submenuPos(
|
|
175
|
+
parent: MenuGeom,
|
|
176
|
+
parentItemIndex: number,
|
|
177
|
+
width: number,
|
|
178
|
+
height: number,
|
|
179
|
+
screenW: number,
|
|
180
|
+
screenH: number,
|
|
181
|
+
): { left: number; top: number } {
|
|
182
|
+
const x = parent.left + parent.width;
|
|
183
|
+
// The parent item renders at y = parent.top + BORDER + header(1) + index; put
|
|
184
|
+
// the submenu's top border on that same row so the columns read as connected.
|
|
185
|
+
const y = parent.top + BORDER + 1 + parentItemIndex;
|
|
186
|
+
return clampMenuPos(x, y, width, height, screenW, screenH);
|
|
187
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command palette's action model (M18.4) — PURE so it unit-tests without
|
|
3
|
+
* OpenTUI. app.tsx opens a centered overlay (F5), feeds keystrokes to a query,
|
|
4
|
+
* and runs the selected {@link PaletteAction}; the DESCRIPTORS and the fuzzy
|
|
5
|
+
* filtering live here, the closures that actually attach/save/quit stay in the
|
|
6
|
+
* app (a `dispatch` switch over `action.kind`).
|
|
7
|
+
*
|
|
8
|
+
* Reuses {@link ../team/fuzzy.ts} for the same subsequence match the fleet
|
|
9
|
+
* quick-jump uses, so typing "ses" ranks "Attach session: …" the way a human
|
|
10
|
+
* expects. The dynamic "Open file: <query>" action is always offered when the
|
|
11
|
+
* query is non-empty (you can't fuzzy-match a path you're still typing), pinned
|
|
12
|
+
* to the top when the query looks like a path.
|
|
13
|
+
*/
|
|
14
|
+
import { fuzzyFilter } from "../team/fuzzy.ts";
|
|
15
|
+
import type { Tab } from "./app-state.ts";
|
|
16
|
+
import type { AgentRowInput } from "./agent-rows.ts";
|
|
17
|
+
import { SETTINGS_PALETTE_COMMANDS, type SettingsCommandId } from "./settings-model.ts";
|
|
18
|
+
|
|
19
|
+
/** One runnable palette entry. `label` is what the list shows and what the
|
|
20
|
+
* fuzzy filter scores; `kind` (+ payload) is what the app dispatches on. */
|
|
21
|
+
export type PaletteAction =
|
|
22
|
+
| { kind: "tab"; tab: Tab; label: string }
|
|
23
|
+
| { kind: "open-folder"; label: string }
|
|
24
|
+
| { kind: "attach"; session: string; label: string }
|
|
25
|
+
| { kind: "jump-agent"; paneId: string; session: string; windowIndex: number; label: string }
|
|
26
|
+
| { kind: "open-file"; path: string; label: string }
|
|
27
|
+
| { kind: "save"; label: string }
|
|
28
|
+
| { kind: "refresh-diff"; label: string }
|
|
29
|
+
| { kind: "paste-buffer"; label: string }
|
|
30
|
+
| { kind: "new-window"; label: string }
|
|
31
|
+
| { kind: "rename-window"; name: string; label: string }
|
|
32
|
+
| { kind: "kill-window"; label: string }
|
|
33
|
+
| { kind: "zoom-pane"; label: string }
|
|
34
|
+
| { kind: "swap-pane"; label: string }
|
|
35
|
+
| { kind: "break-pane"; label: string }
|
|
36
|
+
| { kind: "rotate-window"; label: string }
|
|
37
|
+
| { kind: "select-layout"; layout: string; label: string }
|
|
38
|
+
| { kind: "sync-toggle"; label: string }
|
|
39
|
+
| { kind: "select-text"; label: string }
|
|
40
|
+
| { kind: "resize-window"; label: string }
|
|
41
|
+
| { kind: "settings"; id: SettingsCommandId; label: string }
|
|
42
|
+
| { kind: "quit"; label: string };
|
|
43
|
+
|
|
44
|
+
/** The five tmux `select-layout` presets, offered one palette action each so the
|
|
45
|
+
* fuzzy filter finds "tiled" / "main-vertical" directly. Shared with the pane
|
|
46
|
+
* context menu's Layouts submenu (menu-model). */
|
|
47
|
+
export const LAYOUT_PRESETS = [
|
|
48
|
+
"even-horizontal",
|
|
49
|
+
"even-vertical",
|
|
50
|
+
"main-horizontal",
|
|
51
|
+
"main-vertical",
|
|
52
|
+
"tiled",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/** Context the palette needs beyond the fuzzy query. `terminal` gates the
|
|
56
|
+
* window/pane verbs (New/Rename/Kill window, Zoom pane) to the Terminal surface
|
|
57
|
+
* — they are no-ops on Home/Files/Diff, so they only clutter the list there. */
|
|
58
|
+
export interface PaletteContext {
|
|
59
|
+
terminal?: boolean;
|
|
60
|
+
/** The fleet's agents (M22.2) — the palette offers one "Agent: …" jump action
|
|
61
|
+
* each, the keyboard twin of the sidebar's clickable agent rows. Already
|
|
62
|
+
* sorted attention-first by the caller (fleetAgents). */
|
|
63
|
+
agents?: AgentRowInput[];
|
|
64
|
+
/** A co-attached terminal has sized the tmux window away from our canvas
|
|
65
|
+
* (M22.8). Only then is the "Resize to fit this window" reclaim action worth
|
|
66
|
+
* offering — otherwise it is a no-op that clutters the list. */
|
|
67
|
+
sizeMismatch?: boolean;
|
|
68
|
+
/** The focused pane's app turned mouse reporting on (M22.9) — only then is
|
|
69
|
+
* "Select text in pane" offered (ordinary panes drag-select directly, so the
|
|
70
|
+
* action would be a no-op that clutters the list). */
|
|
71
|
+
appMousePane?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const TAB_LABELS: { tab: Tab; label: string }[] = [
|
|
75
|
+
{ tab: "home", label: "Switch tab: Home" },
|
|
76
|
+
{ tab: "terminal", label: "Switch tab: Terminal" },
|
|
77
|
+
{ tab: "files", label: "Switch tab: Files" },
|
|
78
|
+
{ tab: "diff", label: "Switch tab: Diff" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/** PURE — the always-available static actions: the four tab switches, one
|
|
82
|
+
* attach-session per fleet session, then Save / Refresh diff, the Terminal-only
|
|
83
|
+
* window/pane verbs, and Quit. */
|
|
84
|
+
export function staticPaletteActions(
|
|
85
|
+
sessions: string[],
|
|
86
|
+
ctx: PaletteContext = {},
|
|
87
|
+
): PaletteAction[] {
|
|
88
|
+
const actions: PaletteAction[] = TAB_LABELS.map((t) => ({
|
|
89
|
+
kind: "tab" as const,
|
|
90
|
+
tab: t.tab,
|
|
91
|
+
label: t.label,
|
|
92
|
+
}));
|
|
93
|
+
// The non-technicals' front door (M22.5): a filesystem picker → create-or-
|
|
94
|
+
// attach a session in the chosen folder. Offered on every surface.
|
|
95
|
+
actions.push({ kind: "open-folder", label: "Open folder…" });
|
|
96
|
+
for (const s of sessions) {
|
|
97
|
+
actions.push({ kind: "attach", session: s, label: `Attach session: ${s}` });
|
|
98
|
+
}
|
|
99
|
+
// One jump per fleet agent (M22.2) — the sidebar rows' keyboard twin. Labeled
|
|
100
|
+
// "Agent: <kind> · <session> (<state>)" so the fuzzy filter finds it by kind,
|
|
101
|
+
// session, or state (typing "agent", "claude", or "blocked" all narrow to it).
|
|
102
|
+
for (const a of ctx.agents ?? []) {
|
|
103
|
+
actions.push({
|
|
104
|
+
kind: "jump-agent",
|
|
105
|
+
paneId: a.paneId,
|
|
106
|
+
session: a.session,
|
|
107
|
+
windowIndex: a.windowIndex,
|
|
108
|
+
label: `Agent: ${a.kind} · ${a.session} (${a.state})`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
actions.push({ kind: "save", label: "Save file" });
|
|
112
|
+
actions.push({ kind: "refresh-diff", label: "Refresh diff" });
|
|
113
|
+
// Paste target is the focused surface (a pane, or the editor buffer), so this
|
|
114
|
+
// is offered on every surface — selecting it opens a second-level list of the
|
|
115
|
+
// tmux paste buffers rather than dispatching directly.
|
|
116
|
+
actions.push({ kind: "paste-buffer", label: "Paste buffer…" });
|
|
117
|
+
// The SETTINGS category (M22.4) — every setting is a command; "Settings…" is
|
|
118
|
+
// the categorized umbrella over the same dialogs. Offered on every surface.
|
|
119
|
+
for (const c of SETTINGS_PALETTE_COMMANDS) {
|
|
120
|
+
actions.push({ kind: "settings", id: c.id, label: c.label });
|
|
121
|
+
}
|
|
122
|
+
if (ctx.terminal) {
|
|
123
|
+
actions.push({ kind: "new-window", label: "New window" });
|
|
124
|
+
actions.push({ kind: "kill-window", label: "Kill window" });
|
|
125
|
+
actions.push({ kind: "zoom-pane", label: "Zoom pane" });
|
|
126
|
+
actions.push({ kind: "swap-pane", label: "Swap pane with next" });
|
|
127
|
+
actions.push({ kind: "break-pane", label: "Break pane to window" });
|
|
128
|
+
actions.push({ kind: "rotate-window", label: "Rotate panes" });
|
|
129
|
+
actions.push({ kind: "sync-toggle", label: "Synchronize panes (toggle)" });
|
|
130
|
+
// Selection entry on app-mouse panes (M22.9) — the pane menu verb's
|
|
131
|
+
// keyboard twin, offered only where forwarding blocks a direct drag.
|
|
132
|
+
if (ctx.appMousePane) {
|
|
133
|
+
actions.push({ kind: "select-text", label: "Select text in pane" });
|
|
134
|
+
}
|
|
135
|
+
for (const layout of LAYOUT_PRESETS) {
|
|
136
|
+
actions.push({ kind: "select-layout", layout, label: `Layout: ${layout}` });
|
|
137
|
+
}
|
|
138
|
+
// Only when a co-attached terminal is dictating the window size — the reclaim
|
|
139
|
+
// is a no-op otherwise, and we never fight the other terminal unasked.
|
|
140
|
+
if (ctx.sizeMismatch) {
|
|
141
|
+
actions.push({ kind: "resize-window", label: "Resize to fit this window" });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
actions.push({ kind: "quit", label: "Quit" });
|
|
145
|
+
return actions;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** One tmux paste buffer for the picker: its `name` (e.g. `buffer0`) and a
|
|
149
|
+
* short, control-char-sanitized `preview` of its content. */
|
|
150
|
+
export interface TmuxBuffer {
|
|
151
|
+
name: string;
|
|
152
|
+
preview: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* PURE — parse `list-buffers -F "#{buffer_name}\t#{buffer_sample}"` reply lines
|
|
157
|
+
* into pickable buffers. The sample is truncated to `previewLen` chars and any
|
|
158
|
+
* control characters (tabs/newlines that survived a sample, C0 bytes) collapse
|
|
159
|
+
* to a middle-dot so one buffer stays one clean row. Nameless/blank lines drop.
|
|
160
|
+
*/
|
|
161
|
+
export function parseBufferList(lines: readonly string[], previewLen = 40): TmuxBuffer[] {
|
|
162
|
+
const out: TmuxBuffer[] = [];
|
|
163
|
+
for (const raw of lines) {
|
|
164
|
+
if (!raw) continue;
|
|
165
|
+
const tab = raw.indexOf("\t");
|
|
166
|
+
const name = (tab === -1 ? raw : raw.slice(0, tab)).trim();
|
|
167
|
+
if (!name) continue;
|
|
168
|
+
const sample = tab === -1 ? "" : raw.slice(tab + 1);
|
|
169
|
+
const preview = sample
|
|
170
|
+
.slice(0, previewLen)
|
|
171
|
+
// eslint-disable-next-line no-control-regex
|
|
172
|
+
.replace(/[\x00-\x1f\x7f]/g, "·");
|
|
173
|
+
out.push({ name, preview });
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Overlay geometry (M21.9 — palette mouse support) ────────────────────────
|
|
179
|
+
// The palette overlay is app-rendered (a centered <box border paddingLeft=1>),
|
|
180
|
+
// so its full geometry is knowable by pure math. These helpers are shared by
|
|
181
|
+
// the RENDER (placement) and the central mouse ROUTER (row hit-test / inside-
|
|
182
|
+
// vs-outside), so a click can never land where a row isn't drawn. Layout, in
|
|
183
|
+
// screen rows from `top`: border (top+0) · input/header line (top+1) · rule
|
|
184
|
+
// (top+2) · result rows (top+3 …) · an optional "no matches" row when empty ·
|
|
185
|
+
// border. Rows span the box interior in x: [left+1, left+width-1).
|
|
186
|
+
|
|
187
|
+
/** Rows of chrome above the first result row: top border + input line + rule. */
|
|
188
|
+
export const PALETTE_HEADER_ROWS = 3;
|
|
189
|
+
|
|
190
|
+
/** The palette box geometry the router hit-tests: the placed box plus how many
|
|
191
|
+
* result rows are currently visible (min(count - top, pageRows)). */
|
|
192
|
+
export interface PaletteGeom {
|
|
193
|
+
left: number;
|
|
194
|
+
top: number;
|
|
195
|
+
width: number;
|
|
196
|
+
/** Result rows on screen right now (0 when the list is empty). */
|
|
197
|
+
visibleRows: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** PURE — the overlay's placement for a terminal size: horizontally centered,
|
|
201
|
+
* top at a sixth of the height (min 1 — below the surface tab bar). MUST match
|
|
202
|
+
* the render's `left`/`top` props (the render calls this). */
|
|
203
|
+
export function palettePos(
|
|
204
|
+
termW: number,
|
|
205
|
+
termH: number,
|
|
206
|
+
width: number,
|
|
207
|
+
): { left: number; top: number } {
|
|
208
|
+
return {
|
|
209
|
+
left: Math.max(0, Math.floor((termW - width) / 2)),
|
|
210
|
+
top: Math.max(1, Math.floor(termH / 6)),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** PURE — total box height for `visibleRows` results: header chrome + the rows
|
|
215
|
+
* (an empty list still shows one "no matches"/"no buffers" row) + the bottom
|
|
216
|
+
* border. Used for inside/outside containment. */
|
|
217
|
+
export function paletteHeight(visibleRows: number): number {
|
|
218
|
+
return PALETTE_HEADER_ROWS + Math.max(1, visibleRows) + 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** PURE — the VISIBLE result-row index under (x, y), or -1. Only real rows hit
|
|
222
|
+
* (the "no matches" placeholder row is not clickable); x must be inside the
|
|
223
|
+
* box interior (borders excluded). */
|
|
224
|
+
export function paletteRowAt(g: PaletteGeom, x: number, y: number): number {
|
|
225
|
+
if (x < g.left + 1 || x >= g.left + g.width - 1) return -1;
|
|
226
|
+
const row = y - (g.top + PALETTE_HEADER_ROWS);
|
|
227
|
+
return row >= 0 && row < g.visibleRows ? row : -1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** PURE — whether (x, y) falls anywhere on the palette box (border included).
|
|
231
|
+
* A press outside dismisses the overlay; inside-but-not-a-row is a no-op. */
|
|
232
|
+
export function paletteContains(g: PaletteGeom, x: number, y: number): boolean {
|
|
233
|
+
return (
|
|
234
|
+
x >= g.left && x < g.left + g.width && y >= g.top && y < g.top + paletteHeight(g.visibleRows)
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** PURE — clamp a wheel-scrolled list top into [0, count - pageRows]. */
|
|
239
|
+
export function clampPaletteTop(top: number, count: number, pageRows: number): number {
|
|
240
|
+
return Math.max(0, Math.min(top, count - pageRows));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A typed query "looks like a path" when it carries a separator or dot — then
|
|
244
|
+
* the Open-file action is worth pinning to the top of the results. */
|
|
245
|
+
function looksLikePath(q: string): boolean {
|
|
246
|
+
return /[/.~]/.test(q);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* PURE — the palette result list for `query`: the static actions fuzzy-filtered
|
|
251
|
+
* and score-sorted, plus (when the query is non-empty) a dynamic
|
|
252
|
+
* "Open file: <query>" action. That open-file entry is pinned FIRST when the
|
|
253
|
+
* query looks like a path, otherwise appended LAST so it never buries a real
|
|
254
|
+
* match. An empty query returns every static action in natural order.
|
|
255
|
+
*/
|
|
256
|
+
export function filterPaletteActions(
|
|
257
|
+
query: string,
|
|
258
|
+
sessions: string[],
|
|
259
|
+
ctx: PaletteContext = {},
|
|
260
|
+
): PaletteAction[] {
|
|
261
|
+
const statics = staticPaletteActions(sessions, ctx);
|
|
262
|
+
const q = query.trim();
|
|
263
|
+
const matched =
|
|
264
|
+
q.length === 0 ? statics : fuzzyFilter(query, statics, (a) => a.label).map((m) => m.item);
|
|
265
|
+
if (q.length === 0) return matched;
|
|
266
|
+
const dynamic: PaletteAction[] = [{ kind: "open-file", path: q, label: `Open file: ${q}` }];
|
|
267
|
+
// On the Terminal surface a non-empty query also offers a rename-to-<query>
|
|
268
|
+
// window verb (there is no way to fuzzy-match a name you are still typing —
|
|
269
|
+
// same reasoning as the open-file entry).
|
|
270
|
+
if (ctx.terminal) {
|
|
271
|
+
dynamic.push({ kind: "rename-window", name: q, label: `Rename window: ${q}` });
|
|
272
|
+
}
|
|
273
|
+
return looksLikePath(q) ? [...dynamic, ...matched] : [...matched, ...dynamic];
|
|
274
|
+
}
|