tmux-ide 2.6.1 → 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 +1013 -516
- package/bin/cli.ts +62 -5
- package/package.json +9 -6
- 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 +34 -6
- package/packages/daemon/src/lib/app-config.ts +93 -2
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/tui/chrome/notify.ts +221 -34
- package/packages/daemon/src/tui/chrome/updater.ts +89 -7
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +15 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
- package/packages/daemon/src/tui/detect/manifest.ts +15 -0
- package/packages/daemon/src/tui/detect/manifests.ts +161 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +20 -0
- package/packages/daemon/src/tui/main.ts +13 -1
- 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 +80 -9
- 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 +499 -19
- 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/entry.ts +34 -7
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +102 -13
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- 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
- package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure cell → framebuffer writers for the framebuffer pane-blit path (M21.3).
|
|
3
|
+
*
|
|
4
|
+
* These map a mirror grid straight into an OpenTUI `OptimizedBuffer`'s typed
|
|
5
|
+
* arrays — no `StyledRun[]` intermediary, no per-run `RGBA` allocation, no Solid
|
|
6
|
+
* subtree. One packed cell is `char[i]` (a codepoint), four `fg`/`bg` channels
|
|
7
|
+
* (`r,g,b,a`, 0–255) at `i*4`, and one `attributes[i]` bitmask — the exact layout
|
|
8
|
+
* `OptimizedBuffer.buffers` exposes.
|
|
9
|
+
*
|
|
10
|
+
* Kept dependency-free (plain typed arrays) so the packing is unit-testable
|
|
11
|
+
* without the native render lib; {@link PaneMirror.blit} does the xterm walk and
|
|
12
|
+
* feeds these.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The spacer (second) half of a wide glyph: char `0`. The native renderer knows
|
|
16
|
+
* a cell is wide by MEASURING the lead cell's codepoint width and skips the
|
|
17
|
+
* spacer, so the spacer's char just needs to be empty — this is exactly what
|
|
18
|
+
* `OptimizedBuffer.setCell` writes for the trailing half (verified: a wide
|
|
19
|
+
* glyph → `char[i]=codepoint, char[i+1]=0`). NB the `0xC0000000`
|
|
20
|
+
* `CHAR_FLAG_CONTINUATION` seen in OptimizedBuffer is a DIFFERENT consumer (the
|
|
21
|
+
* JS `getSpanLines`/`getRealCharBytes` text reader, off our render path);
|
|
22
|
+
* writing it here blanks wide glyphs on screen (measured). */
|
|
23
|
+
export const CHAR_CONTINUATION = 0;
|
|
24
|
+
|
|
25
|
+
/** The space codepoint — the fill for blank / out-of-range cells. */
|
|
26
|
+
export const SPACE_CODE = 0x20;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* INVERSE is rendered by SWAPPING fg/bg, not by the OpenTUI INVERSE attribute
|
|
30
|
+
* bit: a framebuffer cell carrying that bit composites (drawFrameBuffer preserves
|
|
31
|
+
* it) but does NOT flush as reverse-video — the direct `<text>` path does, the
|
|
32
|
+
* framebuffer path doesn't (measured). Swapping colors always renders (truecolor
|
|
33
|
+
* channels propagate cleanly), and covers app-driven reverse (vim status line),
|
|
34
|
+
* the cursor cell, and the drag selection uniformly. Swaps compose like the old
|
|
35
|
+
* XOR: two swaps on a cell (selection over the cursor) return it to normal.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The four packed arrays behind an `OptimizedBuffer` (or a plain-array stand-in
|
|
39
|
+
* for tests). `fg`/`bg` hold four `u16` channels (0–255) per cell at `i*4`. */
|
|
40
|
+
export interface CellArrays {
|
|
41
|
+
char: Uint32Array;
|
|
42
|
+
fg: Uint16Array;
|
|
43
|
+
bg: Uint16Array;
|
|
44
|
+
attributes: Uint32Array;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A cell whose grapheme is more than one codepoint (ZWJ/flag emoji, combining
|
|
49
|
+
* marks) — a single `char[i]` u32 can't hold it. The fast blit writes its base
|
|
50
|
+
* codepoint; the caller (which owns the native `OptimizedBuffer`) re-writes these
|
|
51
|
+
* few cells with the full string via `setCell`, keeping full parity for the rare
|
|
52
|
+
* case without an allocation on the common path.
|
|
53
|
+
*/
|
|
54
|
+
export interface GraphemeOverride {
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
chars: string;
|
|
58
|
+
/** Packed `0xRRGGBB` or null (terminal default). */
|
|
59
|
+
fg: number | null;
|
|
60
|
+
bg: number | null;
|
|
61
|
+
attrs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write one styled cell at flat index `idx`. `fg`/`bg` are packed `0xRRGGBB` or
|
|
66
|
+
* `null` for the terminal default (→ the supplied `d*` channels); alpha is always
|
|
67
|
+
* opaque (panes never blend). No allocation — just eight typed-array stores.
|
|
68
|
+
*/
|
|
69
|
+
export function writeCell(
|
|
70
|
+
buf: CellArrays,
|
|
71
|
+
idx: number,
|
|
72
|
+
codepoint: number,
|
|
73
|
+
fg: number | null,
|
|
74
|
+
bg: number | null,
|
|
75
|
+
attrs: number,
|
|
76
|
+
dfR: number,
|
|
77
|
+
dfG: number,
|
|
78
|
+
dfB: number,
|
|
79
|
+
dbR: number,
|
|
80
|
+
dbG: number,
|
|
81
|
+
dbB: number,
|
|
82
|
+
): void {
|
|
83
|
+
buf.char[idx] = codepoint;
|
|
84
|
+
const o = idx * 4;
|
|
85
|
+
if (fg === null) {
|
|
86
|
+
buf.fg[o] = dfR;
|
|
87
|
+
buf.fg[o + 1] = dfG;
|
|
88
|
+
buf.fg[o + 2] = dfB;
|
|
89
|
+
} else {
|
|
90
|
+
buf.fg[o] = (fg >> 16) & 0xff;
|
|
91
|
+
buf.fg[o + 1] = (fg >> 8) & 0xff;
|
|
92
|
+
buf.fg[o + 2] = fg & 0xff;
|
|
93
|
+
}
|
|
94
|
+
buf.fg[o + 3] = 255;
|
|
95
|
+
if (bg === null) {
|
|
96
|
+
buf.bg[o] = dbR;
|
|
97
|
+
buf.bg[o + 1] = dbG;
|
|
98
|
+
buf.bg[o + 2] = dbB;
|
|
99
|
+
} else {
|
|
100
|
+
buf.bg[o] = (bg >> 16) & 0xff;
|
|
101
|
+
buf.bg[o + 1] = (bg >> 8) & 0xff;
|
|
102
|
+
buf.bg[o + 2] = bg & 0xff;
|
|
103
|
+
}
|
|
104
|
+
buf.bg[o + 3] = 255;
|
|
105
|
+
buf.attributes[idx] = attrs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Mark cell `idx` as the continuation (spacer) half of the wide glyph at `idx-1`,
|
|
110
|
+
* inheriting the glyph's colors so a colored wide glyph's background spans both
|
|
111
|
+
* cells. The renderer emits nothing for a continuation cell but still paints its
|
|
112
|
+
* background.
|
|
113
|
+
*/
|
|
114
|
+
export function writeContinuation(buf: CellArrays, idx: number): void {
|
|
115
|
+
buf.char[idx] = CHAR_CONTINUATION;
|
|
116
|
+
const o = idx * 4;
|
|
117
|
+
const p = (idx - 1) * 4;
|
|
118
|
+
buf.fg[o] = buf.fg[p]!;
|
|
119
|
+
buf.fg[o + 1] = buf.fg[p + 1]!;
|
|
120
|
+
buf.fg[o + 2] = buf.fg[p + 2]!;
|
|
121
|
+
buf.fg[o + 3] = buf.fg[p + 3]!;
|
|
122
|
+
buf.bg[o] = buf.bg[p]!;
|
|
123
|
+
buf.bg[o + 1] = buf.bg[p + 1]!;
|
|
124
|
+
buf.bg[o + 2] = buf.bg[p + 2]!;
|
|
125
|
+
buf.bg[o + 3] = buf.bg[p + 3]!;
|
|
126
|
+
buf.attributes[idx] = buf.attributes[idx - 1]!;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Swap fg/bg across cells `[from,to]` (inclusive, clamped to the row) of row `y`
|
|
131
|
+
* — the drag-selection reverse-video post-pass. A swap over the cursor cell (also
|
|
132
|
+
* swapped in the blit) returns it to normal, matching the run path's XOR.
|
|
133
|
+
*/
|
|
134
|
+
export function swapCells(
|
|
135
|
+
buf: CellArrays,
|
|
136
|
+
width: number,
|
|
137
|
+
y: number,
|
|
138
|
+
from: number,
|
|
139
|
+
to: number,
|
|
140
|
+
): void {
|
|
141
|
+
const lo = Math.max(0, from);
|
|
142
|
+
const hi = Math.min(width - 1, to);
|
|
143
|
+
const base = y * width;
|
|
144
|
+
for (let x = lo; x <= hi; x++) {
|
|
145
|
+
const o = (base + x) * 4;
|
|
146
|
+
const r = buf.fg[o]!;
|
|
147
|
+
const g = buf.fg[o + 1]!;
|
|
148
|
+
const b = buf.fg[o + 2]!;
|
|
149
|
+
const a = buf.fg[o + 3]!;
|
|
150
|
+
buf.fg[o] = buf.bg[o]!;
|
|
151
|
+
buf.fg[o + 1] = buf.bg[o + 1]!;
|
|
152
|
+
buf.fg[o + 2] = buf.bg[o + 2]!;
|
|
153
|
+
buf.fg[o + 3] = buf.bg[o + 3]!;
|
|
154
|
+
buf.bg[o] = r;
|
|
155
|
+
buf.bg[o + 1] = g;
|
|
156
|
+
buf.bg[o + 2] = b;
|
|
157
|
+
buf.bg[o + 3] = a;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Paint the background of cells `[from,to]` (inclusive, clamped) of row `y` to
|
|
163
|
+
* packed `bg` — the scrollback-search highlight post-pass (the twin of
|
|
164
|
+
* {@link invertCells}; distinct treatment so a match and the selection coexist).
|
|
165
|
+
*/
|
|
166
|
+
export function paintBg(
|
|
167
|
+
buf: CellArrays,
|
|
168
|
+
width: number,
|
|
169
|
+
y: number,
|
|
170
|
+
from: number,
|
|
171
|
+
to: number,
|
|
172
|
+
bg: number,
|
|
173
|
+
): void {
|
|
174
|
+
const lo = Math.max(0, from);
|
|
175
|
+
const hi = Math.min(width - 1, to);
|
|
176
|
+
const r = (bg >> 16) & 0xff;
|
|
177
|
+
const g = (bg >> 8) & 0xff;
|
|
178
|
+
const b = bg & 0xff;
|
|
179
|
+
for (let x = lo; x <= hi; x++) {
|
|
180
|
+
const o = (y * width + x) * 4;
|
|
181
|
+
buf.bg[o] = r;
|
|
182
|
+
buf.bg[o + 1] = g;
|
|
183
|
+
buf.bg[o + 2] = b;
|
|
184
|
+
buf.bg[o + 3] = 255;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -12,14 +12,29 @@
|
|
|
12
12
|
* intact; `parseControlLine`/`decodeControlBytes` do the pure protocol work.
|
|
13
13
|
*/
|
|
14
14
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
15
|
+
import { appendFileSync } from "node:fs";
|
|
15
16
|
import { parseControlLine, textToHexKeys } from "./control.ts";
|
|
16
17
|
|
|
17
18
|
interface PendingReply {
|
|
19
|
+
discard?: false;
|
|
18
20
|
resolve: (lines: string[]) => void;
|
|
19
21
|
reject: (err: Error) => void;
|
|
20
22
|
lines: string[];
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/** A fire-and-forget command's FIFO placeholder: its reply block is consumed
|
|
26
|
+
* and dropped (errors counted). `lines` collects the error body only when
|
|
27
|
+
* TMUX_IDE_MIRROR_DEBUG asks for it. */
|
|
28
|
+
interface DiscardedReply {
|
|
29
|
+
discard: true;
|
|
30
|
+
lines?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Pending = PendingReply | DiscardedReply;
|
|
34
|
+
|
|
35
|
+
/** The shared no-debug placeholder — fire-and-forget input allocates NOTHING. */
|
|
36
|
+
const DISCARDED: DiscardedReply = { discard: true };
|
|
37
|
+
|
|
23
38
|
export interface ControlClientOptions {
|
|
24
39
|
/** Session (or other tmux target) to attach the control client to. */
|
|
25
40
|
attachTarget: string;
|
|
@@ -33,9 +48,10 @@ export interface ControlClientOptions {
|
|
|
33
48
|
|
|
34
49
|
export class ControlModeClient {
|
|
35
50
|
private proc: ChildProcess | null = null;
|
|
36
|
-
private readonly pending:
|
|
51
|
+
private readonly pending: Pending[] = [];
|
|
37
52
|
private inReply = false;
|
|
38
53
|
private buffer = "";
|
|
54
|
+
private discardedErrors = 0;
|
|
39
55
|
private readonly opts: ControlClientOptions;
|
|
40
56
|
|
|
41
57
|
constructor(opts: ControlClientOptions) {
|
|
@@ -72,14 +88,38 @@ export class ControlModeClient {
|
|
|
72
88
|
});
|
|
73
89
|
}
|
|
74
90
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
/**
|
|
92
|
+
* The INPUT FAST PATH (M21.5): write a command fire-and-forget. The bytes
|
|
93
|
+
* hit tmux's stdin exactly as immediately as `command()`'s do, but no
|
|
94
|
+
* Promise/resolver is allocated and nothing ever waits on the reply. Every
|
|
95
|
+
* control-mode command still produces exactly one `%begin/%end` block, so a
|
|
96
|
+
* placeholder is pushed onto the SAME pending FIFO — reply matching for
|
|
97
|
+
* reply-carrying commands (list-panes, capture-pane, …) stays aligned by
|
|
98
|
+
* construction. Errors are swallowed but counted ({@link inputErrorCount});
|
|
99
|
+
* with TMUX_IDE_MIRROR_DEBUG set the error body is appended to
|
|
100
|
+
* /tmp/zz-input-errors.log.
|
|
101
|
+
*/
|
|
102
|
+
send(cmd: string): void {
|
|
103
|
+
const proc = this.proc;
|
|
104
|
+
if (!proc?.stdin?.writable) return;
|
|
105
|
+
this.pending.push(process.env.TMUX_IDE_MIRROR_DEBUG ? { discard: true, lines: [] } : DISCARDED);
|
|
106
|
+
proc.stdin.write(`${cmd}\n`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Type literal text into a pane (UTF-8, sent as hex bytes — quote-proof).
|
|
110
|
+
* Fire-and-forget: input never queues behind a slow structural reply. */
|
|
111
|
+
sendText(pane: string, text: string): void {
|
|
112
|
+
this.send(`send-keys -t ${pane} -H ${textToHexKeys(text).join(" ")}`);
|
|
78
113
|
}
|
|
79
114
|
|
|
80
|
-
/** Send a named tmux key (Enter, Escape, Up, C-c, …) to a pane. */
|
|
81
|
-
sendKey(pane: string, key: string):
|
|
82
|
-
|
|
115
|
+
/** Send a named tmux key (Enter, Escape, Up, C-c, …) to a pane. Fire-and-forget. */
|
|
116
|
+
sendKey(pane: string, key: string): void {
|
|
117
|
+
this.send(`send-keys -t ${pane} ${key}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** How many fire-and-forget commands came back `%error` (debug/tests). */
|
|
121
|
+
get inputErrorCount(): number {
|
|
122
|
+
return this.discardedErrors;
|
|
83
123
|
}
|
|
84
124
|
|
|
85
125
|
dispose(): void {
|
|
@@ -97,6 +137,7 @@ export class ControlModeClient {
|
|
|
97
137
|
}
|
|
98
138
|
|
|
99
139
|
private feed(chunk: string): void {
|
|
140
|
+
const t0 = process.env.TMUX_IDE_ZZ_PERF ? performance.now() : 0;
|
|
100
141
|
this.buffer += chunk;
|
|
101
142
|
let nl: number;
|
|
102
143
|
while ((nl = this.buffer.indexOf("\n")) !== -1) {
|
|
@@ -106,6 +147,16 @@ export class ControlModeClient {
|
|
|
106
147
|
this.buffer = this.buffer.slice(nl + 1);
|
|
107
148
|
this.handleLine(line);
|
|
108
149
|
}
|
|
150
|
+
if (t0) {
|
|
151
|
+
try {
|
|
152
|
+
appendFileSync(
|
|
153
|
+
"/tmp/zz-feed.log",
|
|
154
|
+
`${chunk.length} ${(performance.now() - t0).toFixed(2)}\n`,
|
|
155
|
+
);
|
|
156
|
+
} catch {
|
|
157
|
+
/* perf tap */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
109
160
|
}
|
|
110
161
|
|
|
111
162
|
private handleLine(line: string): void {
|
|
@@ -114,14 +165,34 @@ export class ControlModeClient {
|
|
|
114
165
|
case "begin":
|
|
115
166
|
this.inReply = true;
|
|
116
167
|
break;
|
|
117
|
-
case "reply-line":
|
|
118
|
-
this.pending[0]
|
|
168
|
+
case "reply-line": {
|
|
169
|
+
const head = this.pending[0];
|
|
170
|
+
// Discarded (fire-and-forget) replies skip body collection entirely —
|
|
171
|
+
// unless the debug placeholder brought its own lines buffer.
|
|
172
|
+
if (head) head.lines?.push(event.line);
|
|
119
173
|
break;
|
|
174
|
+
}
|
|
120
175
|
case "end":
|
|
121
176
|
case "error": {
|
|
122
177
|
this.inReply = false;
|
|
123
178
|
const reply = this.pending.shift();
|
|
124
179
|
if (!reply) break; // unsolicited block (e.g. greeting after a race)
|
|
180
|
+
if (reply.discard) {
|
|
181
|
+
if (event.kind === "error") {
|
|
182
|
+
this.discardedErrors++;
|
|
183
|
+
if (process.env.TMUX_IDE_MIRROR_DEBUG) {
|
|
184
|
+
try {
|
|
185
|
+
appendFileSync(
|
|
186
|
+
"/tmp/zz-input-errors.log",
|
|
187
|
+
`#${this.discardedErrors} ${reply.lines?.join(" | ") ?? ""}\n`,
|
|
188
|
+
);
|
|
189
|
+
} catch {
|
|
190
|
+
// debug tap only
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
125
196
|
if (event.kind === "error") {
|
|
126
197
|
reply.reject(new Error(reply.lines.join("\n") || "tmux command failed"));
|
|
127
198
|
} else {
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialog primitives — the PURE model (M22.4). Types + geometry + filtering for
|
|
3
|
+
* the three dialogs every setting is built from: {@link DialogSelectSpec} (a
|
|
4
|
+
* filterable list with a ● current-value marker and per-row key actions),
|
|
5
|
+
* {@link DialogPromptSpec} (one text input with validation), and
|
|
6
|
+
* {@link DialogConfirmSpec} (two options). The STACK that runs them lives in
|
|
7
|
+
* {@link ./dialog-stack.ts}; app.tsx mounts ONE overlay for whatever is on top.
|
|
8
|
+
*
|
|
9
|
+
* Geometry follows the palette's law (M21.9): the overlay is app-rendered, so
|
|
10
|
+
* its full geometry is knowable by pure math shared between the RENDER
|
|
11
|
+
* (placement) and the central mouse ROUTER (row hit-test / inside-vs-outside)
|
|
12
|
+
* — a click can never land where a row isn't drawn. Layout, in screen rows
|
|
13
|
+
* from `top`:
|
|
14
|
+
* border (top+0) · title (top+1) · [filter input, select only] · rule ·
|
|
15
|
+
* body rows (confirm only) · list/content rows … · footer hints · border.
|
|
16
|
+
* Rows span the box interior in x: [left+1, left+width-1).
|
|
17
|
+
*/
|
|
18
|
+
import { fuzzyFilter } from "../team/fuzzy.ts";
|
|
19
|
+
|
|
20
|
+
// ── Specs ────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** One row of a select dialog. */
|
|
23
|
+
export interface DialogSelectItem {
|
|
24
|
+
/** Stable id the caller dispatches on. */
|
|
25
|
+
id: string;
|
|
26
|
+
/** What the list shows and the fuzzy filter scores. */
|
|
27
|
+
label: string;
|
|
28
|
+
/** Dim, right-aligned annotation (current value, key name, …). */
|
|
29
|
+
detail?: string;
|
|
30
|
+
/** The current value — rendered with the ● marker. */
|
|
31
|
+
current?: boolean;
|
|
32
|
+
/** Destructive — enter/click arms an inline "press again to confirm" first. */
|
|
33
|
+
danger?: boolean;
|
|
34
|
+
/** Optional RGB swatch rendered as a colored ● before the label (theme rows). */
|
|
35
|
+
swatch?: [number, number, number];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A per-row action bound to a ctrl+key chord, shown in the footer. */
|
|
39
|
+
export interface DialogRowAction {
|
|
40
|
+
/** The letter — triggered as ctrl+<key> so it never collides with filter typing. */
|
|
41
|
+
key: string;
|
|
42
|
+
/** Footer label, e.g. `^d delete`. */
|
|
43
|
+
label: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DialogSelectSpec {
|
|
47
|
+
kind: "select";
|
|
48
|
+
title: string;
|
|
49
|
+
items: DialogSelectItem[];
|
|
50
|
+
/** Type-to-filter (default true). Read-only viewers keep it on for search. */
|
|
51
|
+
filterable?: boolean;
|
|
52
|
+
/** Plain-language footer note (e.g. "applies after re-adopt — run tmux-ide adopt <session>"). */
|
|
53
|
+
footerHint?: string;
|
|
54
|
+
/** Per-row key-bound actions listed in the footer. */
|
|
55
|
+
actions?: DialogRowAction[];
|
|
56
|
+
/** Live-preview hook — fired by the stack whenever the SELECTION lands on a
|
|
57
|
+
* different item (keyboard and mouse both), never on open. */
|
|
58
|
+
onMove?: (item: DialogSelectItem) => void;
|
|
59
|
+
/** Start the selection here (defaults to the `current` item, else 0). */
|
|
60
|
+
initialSel?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DialogPromptSpec {
|
|
64
|
+
kind: "prompt";
|
|
65
|
+
title: string;
|
|
66
|
+
/** Dim example shown while the input is empty. */
|
|
67
|
+
placeholder?: string;
|
|
68
|
+
/** Pre-filled input. */
|
|
69
|
+
initial?: string;
|
|
70
|
+
/** Plain-language footer note shown while there is no error. */
|
|
71
|
+
footerHint?: string;
|
|
72
|
+
/** Return an error message to reject, null to accept. */
|
|
73
|
+
validate?: (value: string) => string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DialogConfirmSpec {
|
|
77
|
+
kind: "confirm";
|
|
78
|
+
title: string;
|
|
79
|
+
/** Optional explanation line(s) — wrapped to the box width. */
|
|
80
|
+
body?: string;
|
|
81
|
+
/** First option (the affirmative). Default "Yes". */
|
|
82
|
+
yesLabel?: string;
|
|
83
|
+
/** Second option. Default "No". */
|
|
84
|
+
noLabel?: string;
|
|
85
|
+
/** Start on the second option (the safe default for destructive asks). */
|
|
86
|
+
defaultNo?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type DialogSpec = DialogSelectSpec | DialogPromptSpec | DialogConfirmSpec;
|
|
90
|
+
|
|
91
|
+
/** What a select resolves with: the chosen item, plus the action key when a
|
|
92
|
+
* per-row ctrl+key action (not enter/click) triggered it. */
|
|
93
|
+
export interface DialogSelectResult {
|
|
94
|
+
item: DialogSelectItem;
|
|
95
|
+
action?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** Box width — matches the palette so the two overlays read as one family. */
|
|
101
|
+
export const DIALOG_W = 60;
|
|
102
|
+
/** Visible list rows before the wheel/follow-scroll windows the slice. */
|
|
103
|
+
export const DIALOG_ROWS = 10;
|
|
104
|
+
/** The inline destructive re-arm suffix (plain language, no modal). */
|
|
105
|
+
export const DIALOG_CONFIRM_SUFFIX = " — press again to confirm";
|
|
106
|
+
/** The current-value marker. */
|
|
107
|
+
export const DIALOG_CURRENT_MARK = "●";
|
|
108
|
+
|
|
109
|
+
// ── Filtering / selection ────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/** PURE — the visible rows for a query: fuzzy-filtered + score-sorted (the
|
|
112
|
+
* palette's idiom); an empty query returns every item in natural order. */
|
|
113
|
+
export function filterDialogItems(
|
|
114
|
+
query: string,
|
|
115
|
+
items: readonly DialogSelectItem[],
|
|
116
|
+
): DialogSelectItem[] {
|
|
117
|
+
const q = query.trim();
|
|
118
|
+
if (q.length === 0) return [...items];
|
|
119
|
+
return fuzzyFilter(q, [...items], (i) => i.label).map((m) => m.item);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** PURE — where the selection starts: `initialSel` when valid, else the
|
|
123
|
+
* `current` item, else 0. */
|
|
124
|
+
export function initialSelIndex(spec: DialogSelectSpec): number {
|
|
125
|
+
if (
|
|
126
|
+
spec.initialSel !== undefined &&
|
|
127
|
+
spec.initialSel >= 0 &&
|
|
128
|
+
spec.initialSel < spec.items.length
|
|
129
|
+
) {
|
|
130
|
+
return spec.initialSel;
|
|
131
|
+
}
|
|
132
|
+
const cur = spec.items.findIndex((i) => i.current);
|
|
133
|
+
return cur >= 0 ? cur : 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** PURE — keep `sel` visible: the window `top` that contains it, moving as
|
|
137
|
+
* little as possible (keyboard follow-scroll; the wheel moves `top` alone). */
|
|
138
|
+
export function followTop(sel: number, top: number, pageRows: number): number {
|
|
139
|
+
if (sel < top) return sel;
|
|
140
|
+
if (sel > top + pageRows - 1) return sel - pageRows + 1;
|
|
141
|
+
return top;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** PURE — clamp a wheel-scrolled list top into [0, count - pageRows]. */
|
|
145
|
+
export function clampDialogTop(top: number, count: number, pageRows: number): number {
|
|
146
|
+
return Math.max(0, Math.min(top, count - pageRows));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Geometry ─────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/** The placed box + the row bands the router hit-tests. */
|
|
152
|
+
export interface DialogGeom {
|
|
153
|
+
left: number;
|
|
154
|
+
top: number;
|
|
155
|
+
width: number;
|
|
156
|
+
/** Chrome rows above the first hit-testable row: top border + title +
|
|
157
|
+
* (filter input) + rule + (confirm body). */
|
|
158
|
+
headerRows: number;
|
|
159
|
+
/** Hit-testable rows on screen right now (select window / confirm options /
|
|
160
|
+
* the prompt's input row). 0 renders one non-hittable placeholder row. */
|
|
161
|
+
visibleRows: number;
|
|
162
|
+
/** Footer hint rows (always 1 — hints/errors live there). */
|
|
163
|
+
footerRows: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** PURE — placement: horizontally centered, top at a sixth of the height
|
|
167
|
+
* (min 1 — below the surface tab bar). Same law as the palette. */
|
|
168
|
+
export function dialogPos(
|
|
169
|
+
termW: number,
|
|
170
|
+
termH: number,
|
|
171
|
+
width: number,
|
|
172
|
+
): { left: number; top: number } {
|
|
173
|
+
return {
|
|
174
|
+
left: Math.max(0, Math.floor((termW - width) / 2)),
|
|
175
|
+
top: Math.max(1, Math.floor(termH / 6)),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** PURE — greedy word-wrap for the confirm body (never returns empty for
|
|
180
|
+
* non-empty text; words longer than the width hard-break). */
|
|
181
|
+
export function wrapText(text: string, width: number): string[] {
|
|
182
|
+
if (width <= 0) return [text];
|
|
183
|
+
const out: string[] = [];
|
|
184
|
+
for (const para of text.split("\n")) {
|
|
185
|
+
let line = "";
|
|
186
|
+
for (const word of para.split(/\s+/).filter(Boolean)) {
|
|
187
|
+
if (line.length === 0) {
|
|
188
|
+
let w = word;
|
|
189
|
+
while (w.length > width) {
|
|
190
|
+
out.push(w.slice(0, width));
|
|
191
|
+
w = w.slice(width);
|
|
192
|
+
}
|
|
193
|
+
line = w;
|
|
194
|
+
} else if (line.length + 1 + word.length <= width) {
|
|
195
|
+
line += ` ${word}`;
|
|
196
|
+
} else {
|
|
197
|
+
out.push(line);
|
|
198
|
+
let w = word;
|
|
199
|
+
while (w.length > width) {
|
|
200
|
+
out.push(w.slice(0, width));
|
|
201
|
+
w = w.slice(width);
|
|
202
|
+
}
|
|
203
|
+
line = w;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (line.length > 0 || para.length === 0) out.push(line);
|
|
207
|
+
}
|
|
208
|
+
return out.length > 0 ? out : [""];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** PURE — the interior text width of the box (borders + 1-cell padding each side). */
|
|
212
|
+
export function dialogInnerW(width: number): number {
|
|
213
|
+
return width - 4;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** PURE — chrome rows above the first hit-testable row, per kind. */
|
|
217
|
+
export function dialogHeaderRows(spec: DialogSpec): number {
|
|
218
|
+
// top border + title + rule = 3 …
|
|
219
|
+
if (spec.kind === "select") return spec.filterable === false ? 3 : 4; // … + filter input
|
|
220
|
+
if (spec.kind === "prompt") return 3;
|
|
221
|
+
return 3 + (spec.body ? wrapText(spec.body, dialogInnerW(DIALOG_W)).length : 0);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** PURE — total box height for a geometry (empty lists still show one
|
|
225
|
+
* placeholder row) — used for inside/outside containment. */
|
|
226
|
+
export function dialogHeight(g: DialogGeom): number {
|
|
227
|
+
return g.headerRows + Math.max(1, g.visibleRows) + g.footerRows + 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** PURE — the VISIBLE hit-testable row index under (x, y), or -1. x must be
|
|
231
|
+
* inside the box interior (borders excluded); only real rows hit. */
|
|
232
|
+
export function dialogRowAt(g: DialogGeom, x: number, y: number): number {
|
|
233
|
+
if (x < g.left + 1 || x >= g.left + g.width - 1) return -1;
|
|
234
|
+
const row = y - (g.top + g.headerRows);
|
|
235
|
+
return row >= 0 && row < g.visibleRows ? row : -1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** PURE — whether (x, y) falls anywhere on the box (border included). A press
|
|
239
|
+
* outside pops ONE stack level; inside-but-not-a-row is a no-op. */
|
|
240
|
+
export function dialogContains(g: DialogGeom, x: number, y: number): boolean {
|
|
241
|
+
return x >= g.left && x < g.left + g.width && y >= g.top && y < g.top + dialogHeight(g);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Row / footer text ────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/** PURE — a select row's leading marker: `● ` on the current value (kept even
|
|
247
|
+
* while selected — the value marker is the point), else `› `/2 spaces. */
|
|
248
|
+
export function dialogMarker(item: { current?: boolean }, selected: boolean): string {
|
|
249
|
+
if (item.current) return `${DIALOG_CURRENT_MARK} `;
|
|
250
|
+
return selected ? "› " : " ";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** PURE — a select row's body text: marker + label (+ the inline destructive
|
|
254
|
+
* re-arm suffix when armed), with `detail` right-aligned in the interior
|
|
255
|
+
* width; overlong labels truncate before the detail. */
|
|
256
|
+
export function dialogRowText(
|
|
257
|
+
item: DialogSelectItem,
|
|
258
|
+
opts: { selected: boolean; armed: boolean; innerW: number },
|
|
259
|
+
): string {
|
|
260
|
+
const marker = dialogMarker(item, opts.selected);
|
|
261
|
+
const label = opts.armed ? `${item.label}${DIALOG_CONFIRM_SUFFIX}` : item.label;
|
|
262
|
+
const detail = opts.armed ? "" : (item.detail ?? "");
|
|
263
|
+
const bodyW = opts.innerW - marker.length;
|
|
264
|
+
if (detail.length === 0) return marker + label.slice(0, bodyW).padEnd(bodyW);
|
|
265
|
+
const labelW = Math.max(1, bodyW - detail.length - 2);
|
|
266
|
+
return `${marker}${label.slice(0, labelW).padEnd(labelW)} ${detail}`.slice(0, opts.innerW);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** PURE — the select footer: key hints first, then the per-row actions, then
|
|
270
|
+
* the caller's plain-language note. */
|
|
271
|
+
export function selectFooter(spec: DialogSelectSpec): string {
|
|
272
|
+
const parts = ["enter select", "esc cancel"];
|
|
273
|
+
for (const a of spec.actions ?? []) parts.push(`^${a.key} ${a.label}`);
|
|
274
|
+
if (spec.footerHint) parts.push(spec.footerHint);
|
|
275
|
+
return parts.join(" · ");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** PURE — the prompt footer: a busy note wins, then the validation error, then
|
|
279
|
+
* the hint, then the default keys line. The `error` flag tells the render to
|
|
280
|
+
* tint it as a problem. */
|
|
281
|
+
export function promptFooter(
|
|
282
|
+
spec: DialogPromptSpec,
|
|
283
|
+
state: { error: string | null; busy: boolean },
|
|
284
|
+
): { text: string; error: boolean } {
|
|
285
|
+
if (state.busy) return { text: "saving…", error: false };
|
|
286
|
+
if (state.error) return { text: state.error, error: true };
|
|
287
|
+
return { text: spec.footerHint ?? "enter save · esc cancel", error: false };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** PURE — the confirm footer. */
|
|
291
|
+
export function confirmFooter(): string {
|
|
292
|
+
return "enter choose · y/n · esc cancel";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** PURE — the confirm option labels in row order (affirmative first). */
|
|
296
|
+
export function confirmOptions(spec: DialogConfirmSpec): [string, string] {
|
|
297
|
+
return [spec.yesLabel ?? "Yes", spec.noLabel ?? "No"];
|
|
298
|
+
}
|