pi-odd-worktree 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +223 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. package/tools/worktree-tools.test.js +434 -0
@@ -0,0 +1,1265 @@
1
+ 'use strict';
2
+ // Warp selector, redesigned as ONE component with TWO views (warp-popup
3
+ // S1,S2,S5,S6,S9): the warp PANEL and the extended list. The panel is the
4
+ // geometry the host mounts at the bottom of the terminal (S3, L6), so its
5
+ // height is decided HERE, from its own content, and `rowsAvailable` bounds
6
+ // it. PURE: no fs, no git, no registry — every external read arrives as an
7
+ // injected option, so the wiring unit only has to hand over data and a
8
+ // `done`.
9
+ //
10
+ // Host component contract (the shape the customize panel and the pickers
11
+ // already use): the factory returns
12
+ // `{ render(width), handleInput(data), invalidate(), state }`.
13
+ //
14
+ // Options
15
+ // checkouts array of `{ name, path, branch, isPrincipal }`; `name`
16
+ // defaults to the path's last segment, `branch` may be
17
+ // omitted, and an entry without a `path` is dropped. An
18
+ // entry may also carry `lastActivity` — an epoch-ms number
19
+ // or an ISO string, or the `{available, value, reason}`
20
+ // envelope `checkout-facts` returns — plus
21
+ // `lastActivityReason`. Rows are ordered by that value
22
+ // descending, and a checkout whose value is unreadable
23
+ // (`null`, missing, `'unavailable'`, unparsable) sorts last
24
+ // with its reason kept for the facts line (S12).
25
+ // current `{ name, path }` (or just the path string) of the
26
+ // checkout this session is in: it owns the panel's header
27
+ // and is never listed again, in EITHER view (S4b) — warping
28
+ // to where the session already is moves it nowhere.
29
+ // canonicalPath(path)
30
+ // OPTIONAL identity reader. Two spellings of one checkout
31
+ // (a symlink, a `..` segment) are the SAME checkout when
32
+ // this resolves them to the same string, so neither view
33
+ // offers the current one under a second name. Compared for
34
+ // equality, never as a prefix: `feat-x` is not `feat-x2`.
35
+ // Read when the list is normalized (mount, reload,
36
+ // enrichment), never inside render(), so a frame stays free
37
+ // of I/O. Defaults to the path itself, for a caller that
38
+ // already hands over canonical paths.
39
+ // facts(path) reader for one checkout — the CURRENT one (its header
40
+ // data line, S4a) and the FOCUSED one (S4d) — called at
41
+ // most once per checkout until `r`. Resolves (or returns)
42
+ // either
43
+ // `null`/`undefined` or an object
44
+ // {
45
+ // branch: string,
46
+ // changes: the change size as `{ added, deleted }`
47
+ // line counts (S16); a caller that can only
48
+ // supply `{ files }` proves a zero when it
49
+ // is 0 and renders `unavailable` otherwise,
50
+ // ahead: commits ahead of the base (integer),
51
+ // behind: commits behind the base (integer),
52
+ // createdAt: a timestamp (epoch-ms number or ISO
53
+ // string) or the `{available, value, reason}`
54
+ // envelope; rendered as `created <age>`,
55
+ // lastActivity: the last-activity timestamp in the same
56
+ // forms; rendered as `modified <age>`,
57
+ // createdAtReason / lastActivityReason: optional
58
+ // diagnostic strings; S15 keeps them in the
59
+ // row's DATA and never prints them (an
60
+ // unreadable age renders the brief `—`),
61
+ // }
62
+ // Each field is independently optional: a missing, `null`
63
+ // or `'unavailable'` value renders `unavailable` (S9) and
64
+ // never `0`, and a missing age renders the brief `—` (S15).
65
+ // S1: a worktree carries no issue or ODD association any
66
+ // more, so an `issues`/`odd`/`assignedOdd` the reader still
67
+ // returns (legacy registry data is preserved, never
68
+ // migrated) is IGNORED: no association reaches a rendered
69
+ // line, a row or a legend. A measured zero for the
70
+ // change size or for one of `↑`/`↓` still disappears from the
71
+ // line entirely instead of printing `0`. A rejected read
72
+ // degrades to every fact `unavailable`. The read is
73
+ // asynchronous and never blocks a frame: a pending read
74
+ // renders `reading …`.
75
+ // reload() called on `r`; may return a new checkouts array (or a
76
+ // promise of one). Every cached fact is dropped either way.
77
+ // onToggle(path) OPTIONAL. When supplied the extended list has a toggle,
78
+ // so `space` toggles the focused checkout while the filter
79
+ // is closed (S6). Without it `space` is an ordinary
80
+ // printable and opens the filter.
81
+ // helpers `{ visibleWidth, truncateToWidth, matchesKey, isKeyRelease }`.
82
+ // theme `{ fg(role, text), bg(role, text), bold(text) }`; S11:
83
+ // every frame rule, title, key, description, count, label
84
+ // and value paints through lib/tui-paint.js, so a shortcut KEY
85
+ // is accent+bold while its words are muted and no colour is
86
+ // named here. A missing, partial or throwing theme keeps plain
87
+ // text, and the focus cue stays the monochrome `▸` mark.
88
+ // rowsAvailable the MAXIMUM number of rows the host offers (number or
89
+ // function): the panel renders exactly its content and
90
+ // never more than this.
91
+ // requestRender repaint request, called after every state change.
92
+ // done(value) the ONLY way out, called at most once:
93
+ // `null` closed with `esc`/`ctrl+c` (panel)
94
+ // `{ warp: path }` warp to that checkout (`enter`)
95
+ // `{ create: true }` open the create panel (`c`, S6)
96
+ // `{ delete: path }` ask to DELETE that checkout
97
+ // (`ctrl+d`, both views)
98
+ // S1/S4: there is NO edit marker — the worktree edit panel
99
+ // was removed together with the issue/ODD association — but
100
+ // `ctrl+d` DOES name the focused checkout's own path. The
101
+ // component still decides nothing: the path is a request,
102
+ // and the extension owns the confirmation, the evidence it
103
+ // discloses, the refusals and the removal. So a keypress
104
+ // can never remove anything on its own, and this view needs
105
+ // no permission mode.
106
+ // `view all worktrees` (the row or `v`) never resolves: it
107
+ // opens the extended list of the same component, and `esc`
108
+ // there steps back one level to the panel.
109
+ //
110
+ // Keys, exactly (S5 + docs/keys.md): `↑`/`↓` and `j`/`k` move, `enter`
111
+ // chooses (and opens the extended list on `view all worktrees`), `c` opens
112
+ // the create flow, `ctrl+d` asks to delete the focused checkout, `v` opens the
113
+ // extended
114
+ // list, `r` reloads, `esc`/`ctrl+c` close from the panel
115
+ // or step back from the list. S1/S4: `e` (edit) is
116
+ // deliberately NOT a binding and no legend names it; `ctrl+d` is offered on
117
+ // the focused checkout row and never on the offer row (which is not a
118
+ // checkout). S9 keeps the footer
119
+ // shortcuts and binds no
120
+ // `?` help overlay: the footer already names every key. `↑`/`↓` are
121
+ // never repurposed for ahead/behind: both numbers ride the facts line. In the
122
+ // extended list the filter is always active (S6): `↑`/`↓`, `enter`, `esc`
123
+ // and `pgUp`/`pgDn` stay bindings, `T` or `/` open an empty filter, any
124
+ // other printable opens it with that character, and once open every
125
+ // printable — `space` included — goes to the text, `backspace` deletes one
126
+ // character and `delete` clears the whole query (S4/S12, and the footer names
127
+ // it while the search is active). `esc` closes the filter
128
+ // keeping the query; the next `esc` steps back to the panel. `ctrl+d` asks to
129
+ // delete only while the filter is closed: while the human is typing, the
130
+ // search footer owns `delete` and no destructive key fires.
131
+ //
132
+ // S5: a selector row is the checkout NAME alone, taking the width the host
133
+ // offers; the branch is not repeated there because the focused checkout's
134
+ // facts line already names it. S4b: the principal is the first row whenever
135
+ // it is not the current one, then the rows follow most-recently-modified
136
+ // first. S1: the facts line names no association at all — worktrees stopped
137
+ // receiving issues and ODD documents, so neither an id, a name nor a count of
138
+ // them is rendered anywhere in this component.
139
+ //
140
+ // Layout (S3/S4, pinned top to bottom):
141
+ // panel 7 fixed rows — top rule, the CURRENT worktree's name, its data
142
+ // line, a blank separator, the FOCUSED worktree's data line, the
143
+ // one-row key legend, the bottom rule — plus up to five worktree
144
+ // rows (the current is never one of them: it owns the header) and a
145
+ // final `view all worktrees` row only when at least one worktree is
146
+ // left out (S4c, reconciled in L2). With nothing to list, one
147
+ // `(no other worktrees)` row takes that place. The height is the
148
+ // content's own height, clamped by `rowsAvailable`, so the frame
149
+ // always fills it exactly.
150
+ // list 5 fixed rows — top rule, the filter row (never selectable), a
151
+ // page of every OTHER checkout (the current one is never a
152
+ // destination, so it is not a row here either), the page indicator
153
+ // and the key footer, the bottom rule. When nothing is left to
154
+ // list, the page says `(no other worktrees)` instead of offering a
155
+ // checkout the session is already in.
156
+
157
+ const { stripAnsi } = require('./odd-markdown.js');
158
+ // S11: the ONE palette the readers share decides which theme role paints each
159
+ // kind of token (key, title, description, count, label, value, border, error),
160
+ // so a footer KEY is always distinguishable from the words beside it and this
161
+ // view keeps no second copy of the hostile-theme guards.
162
+ const palette = require('./tui-paint.js');
163
+ // Reused, not re-implemented: the pickers' page clamps and their
164
+ // independent-fact row shape are the invariants this view shares.
165
+ const { paginate } = require('./warp-create-picker.js');
166
+
167
+ // The role per token kind lives in lib/tui-paint.js (S11); this reads it
168
+ // instead of restating it. `key` and `title` resolve to the palette's own
169
+ // emphasizing painter, everything else to its colour-only one.
170
+ function paintKind(theme, kind, text) {
171
+ const painter = palette[kind];
172
+ if (typeof painter === 'function') return painter(theme, text);
173
+ const role = palette.ROLES[kind];
174
+ return typeof role === 'string' ? palette.paint(theme, role, text) : String(text);
175
+ }
176
+
177
+ const MIN_WIDTH = 24;
178
+ // S4b: at most five worktree rows; S4d adds the focused data line and the
179
+ // legend below them.
180
+ const MAX_LIST_ROWS = 5;
181
+ // top rule + current name + current data + separator + focused data +
182
+ // legend + bottom rule (S4a/S4d). The list rows and the optional
183
+ // `view all worktrees` row come on top of these.
184
+ const POPUP_FIXED_ROWS = 7;
185
+ // The panel needs its fixed rows plus one list row to be readable at all.
186
+ const POPUP_MIN_HEIGHT = POPUP_FIXED_ROWS + 1;
187
+ const LIST_MIN_HEIGHT = 8;
188
+ // top rule + filter row + page indicator + footer + bottom rule.
189
+ const LIST_FIXED_ROWS = 5;
190
+ const UNAVAILABLE = 'unavailable';
191
+ // S2: ONE legend, ONE terminal row. `text` is the full token, `short` the
192
+ // compact one used when the full form does not fit, and `priority` decides
193
+ // what a still-too-wide legend drops first (the lowest goes first, ties drop
194
+ // the later token), so `esc` outlives every other hint. S11: `key` is the
195
+ // exact key spelling the token starts with, so lib/tui-paint.js paints that
196
+ // prefix with the key role (accent+bold) and the rest as its description —
197
+ // the compact spelling keeps the same prefix, so a key never loses its role
198
+ // when the row compacts. S1/S4: the tokens are the surviving bindings —
199
+ // `e edit` is gone, `ctrl+d delete` is back on both views (the extension owns
200
+ // the confirmation, so the token promises a request, never a removal).
201
+ const POPUP_KEYS = [
202
+ { key: '↑↓', text: '↑↓ move', short: '↑↓', priority: 70 },
203
+ { key: 'enter', text: 'enter choose', short: 'enter', priority: 60 },
204
+ { key: 'c', text: 'c create', short: 'c create', priority: 50 },
205
+ { key: 'ctrl+d', text: 'ctrl+d delete', short: 'ctrl+d', priority: 40 },
206
+ { key: 'v', text: 'v all', short: 'v all', priority: 30 },
207
+ { key: 'r', text: 'r reload', short: 'r reload', priority: 20 },
208
+ { key: 'esc', text: 'esc close', short: 'esc close', priority: 80 },
209
+ ];
210
+ const LIST_KEYS = [
211
+ { key: '↑↓', text: '↑↓ move', short: '↑↓', priority: 60 },
212
+ { key: 'pgUp/pgDn', text: 'pgUp/pgDn page', short: 'pgUp/pgDn', priority: 40 },
213
+ { key: 'enter', text: 'enter warp', short: 'enter', priority: 50 },
214
+ { key: 'ctrl+d', text: 'ctrl+d delete', short: 'ctrl+d', priority: 45 },
215
+ { key: 'r', text: 'r reload', short: 'r reload', priority: 20 },
216
+ { key: 'esc', text: 'esc back', short: 'esc back', priority: 70 },
217
+ ];
218
+ // S12/S4: while the search field is active, Delete clears the WHOLE query and
219
+ // the footer says so (typing it out character by character was the reported
220
+ // annoyance); Backspace keeps its one-character meaning. The hints stay on
221
+ // the one footer row like every other legend.
222
+ const LIST_FILTER_KEYS = [
223
+ { key: '↑↓', text: '↑↓ move', short: '↑↓', priority: 60 },
224
+ { key: 'enter', text: 'enter warp', short: 'enter', priority: 50 },
225
+ { key: 'backspace', text: 'backspace del char', short: 'backspace', priority: 45 },
226
+ { key: 'delete', text: 'delete clear', short: 'delete clear', priority: 65 },
227
+ { key: 'esc', text: 'esc keep filter', short: 'esc keep', priority: 100 },
228
+ ];
229
+
230
+ function measure(helpers, text) {
231
+ try {
232
+ if (helpers && typeof helpers.visibleWidth === 'function') {
233
+ const size = Number(helpers.visibleWidth(text));
234
+ if (Number.isFinite(size)) return size;
235
+ }
236
+ } catch {
237
+ // Fall through to the ANSI-stripped codepoint count.
238
+ }
239
+ return [...stripAnsi(text)].length;
240
+ }
241
+
242
+ function fit(helpers, text, width) {
243
+ const clean = String(text).split('\n')[0] ?? '';
244
+ if (measure(helpers, clean) <= width) return clean;
245
+ try {
246
+ if (helpers && typeof helpers.truncateToWidth === 'function') {
247
+ const cut = helpers.truncateToWidth(clean, width, '…');
248
+ if (typeof cut === 'string') return cut;
249
+ }
250
+ } catch {
251
+ // Fall through to the plain slice.
252
+ }
253
+ const chars = [...clean];
254
+ return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : clean;
255
+ }
256
+
257
+ function pad(helpers, text, width) {
258
+ const clipped = fit(helpers, text, width);
259
+ return clipped + ' '.repeat(Math.max(0, width - measure(helpers, clipped)));
260
+ }
261
+
262
+ // S2/S11: a key legend stays on ONE row and is measured on its PLAIN text, so
263
+ // painting can never change what fits. The compaction (full token, then the
264
+ // compact one, then the lowest-priority tokens dropped) and the per-token role
265
+ // painting are lib/tui-paint.js `legendRow` — one implementation shared with
266
+ // the other readers instead of a second copy here.
267
+
268
+ // Raw fallbacks for the keys this view binds, mirroring the sequence the
269
+ // installed matcher accepts in legacy mode, so the panel stays drivable
270
+ // when the helper module failed to load (a panel that ignores Esc reads as
271
+ // frozen).
272
+ const RAW_KEY_BYTES = {
273
+ escape: ['\x1b'],
274
+ 'ctrl+c': ['\x03'],
275
+ enter: ['\r', '\n'],
276
+ return: ['\r', '\n'],
277
+ space: [' '],
278
+ backspace: ['\x7f', '\b'],
279
+ delete: ['\x1b[3~', '\x7f'],
280
+ 'ctrl+d': ['\x04'],
281
+ up: ['\x1b[A'],
282
+ down: ['\x1b[B'],
283
+ right: ['\x1b[C'],
284
+ left: ['\x1b[D'],
285
+ pageup: ['\x1b[5~'],
286
+ pagedown: ['\x1b[6~'],
287
+ };
288
+
289
+ function keyText(helpers, data, names) {
290
+ for (const name of names) {
291
+ try {
292
+ if (helpers && typeof helpers.matchesKey === 'function' && helpers.matchesKey(data, name)) return true;
293
+ } catch {
294
+ // A throwing matcher never matches; the fallbacks below decide.
295
+ }
296
+ }
297
+ if (typeof data === 'string' && data.length > 0) {
298
+ for (const name of names) {
299
+ const raw = RAW_KEY_BYTES[String(name).toLowerCase()];
300
+ if (raw && raw.includes(data)) return true;
301
+ }
302
+ }
303
+ const text = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
304
+ const lower = String(text).toLowerCase();
305
+ return names.some((name) => lower === String(name).toLowerCase());
306
+ }
307
+
308
+ function isKeyRelease(helpers, data) {
309
+ try {
310
+ if (helpers && typeof helpers.isKeyRelease === 'function') return helpers.isKeyRelease(data) === true;
311
+ } catch {
312
+ // A throwing classifier never releases.
313
+ }
314
+ return false;
315
+ }
316
+
317
+ // The single printable codepoint a keystroke carries, or null. Space is
318
+ // printable: it only reaches this helper where no toggle claims it.
319
+ function printableChar(data) {
320
+ if (typeof data !== 'string' || data.length === 0 || data === 'space') return null;
321
+ const chars = [...data];
322
+ if (chars.length !== 1) return null;
323
+ const code = chars[0].codePointAt(0);
324
+ if (!Number.isFinite(code) || code < 0x20 || code === 0x7f) return null;
325
+ return chars[0];
326
+ }
327
+
328
+ function isSpaceKey(helpers, data) {
329
+ // `space` (the kitty name) and the raw byte both mean space.
330
+ if (data === ' ') return true;
331
+ return keyText(helpers, data, ['space']);
332
+ }
333
+
334
+ function basename(target) {
335
+ const parts = String(target).split(/[\\/]/).filter((part) => part.length > 0);
336
+ return parts.length > 0 ? parts[parts.length - 1] : String(target);
337
+ }
338
+
339
+ // Epoch ms for a timestamp in any of the forms a caller may supply: a finite
340
+ // number, a Date, or an ISO string. Null when it is not a readable timestamp
341
+ // (S9: an unreadable value is never a zero).
342
+ function toTimestampMs(value) {
343
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
344
+ if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.getTime() : null;
345
+ if (typeof value === 'string') {
346
+ const text = value.trim();
347
+ if (text === '' || text === UNAVAILABLE) return null;
348
+ const parsed = Date.parse(text);
349
+ return Number.isFinite(parsed) ? parsed : null;
350
+ }
351
+ return null;
352
+ }
353
+
354
+ // The ordering timestamp a checkout entry reports, in the forms the options
355
+ // doc names. `at: null` means unreadable, and `reason` keeps what the caller
356
+ // said about it so the facts line can show that instead of a fake date.
357
+ function lastActivityOf(entry) {
358
+ let value = entry.lastActivity;
359
+ let reason = typeof entry.lastActivityReason === 'string' && entry.lastActivityReason.length > 0
360
+ ? entry.lastActivityReason
361
+ : null;
362
+ if (value && typeof value === 'object' && !(value instanceof Date)) {
363
+ if (value.available === false) {
364
+ return {
365
+ at: null,
366
+ reason: typeof value.reason === 'string' && value.reason.length > 0 ? value.reason : reason,
367
+ };
368
+ }
369
+ if (typeof value.reason === 'string' && value.reason.length > 0) reason = value.reason;
370
+ value = value.value;
371
+ }
372
+ const at = toTimestampMs(value);
373
+ return { at, reason: at === null ? reason : null };
374
+ }
375
+
376
+ function normalizeCheckouts(list) {
377
+ const out = [];
378
+ for (const entry of Array.isArray(list) ? list : []) {
379
+ if (!entry || typeof entry !== 'object') continue;
380
+ if (typeof entry.path !== 'string' || entry.path.length === 0) continue;
381
+ const name = typeof entry.name === 'string' && entry.name.length > 0 ? entry.name : basename(entry.path);
382
+ const branch = typeof entry.branch === 'string' && entry.branch.length > 0 ? entry.branch : null;
383
+ const activity = lastActivityOf(entry);
384
+ out.push({
385
+ name,
386
+ path: entry.path,
387
+ branch,
388
+ isPrincipal: entry.isPrincipal === true,
389
+ lastActivity: activity.at,
390
+ lastActivityReason: activity.reason,
391
+ });
392
+ }
393
+ // S6: the principal leads whenever it is present and not the current
394
+ // checkout, whatever its activity: the comparator below puts a principal
395
+ // row ahead of every other row regardless of timestamps. Every other row
396
+ // follows most-recent-first; an unreadable value sorts last, and a stable
397
+ // sort keeps the caller's order among equals (S12).
398
+ out.sort((a, b) => {
399
+ if (a.isPrincipal !== b.isPrincipal) return a.isPrincipal ? -1 : 1;
400
+ if (a.lastActivity === null && b.lastActivity === null) return 0;
401
+ if (a.lastActivity === null) return 1;
402
+ if (b.lastActivity === null) return -1;
403
+ return b.lastActivity - a.lastActivity;
404
+ });
405
+ return out;
406
+ }
407
+
408
+ function normalizeCurrent(current, checkouts) {
409
+ if (typeof current === 'string' && current.length > 0) {
410
+ const hit = checkouts.find((entry) => entry.path === current);
411
+ return hit ? { name: hit.name, path: hit.path } : { name: basename(current), path: current };
412
+ }
413
+ if (current && typeof current === 'object' && typeof current.path === 'string' && current.path.length > 0) {
414
+ const name = typeof current.name === 'string' && current.name.length > 0 ? current.name : basename(current.path);
415
+ return { name, path: current.path };
416
+ }
417
+ return null;
418
+ }
419
+
420
+ function missing(value) {
421
+ if (value === null || value === undefined) return true;
422
+ return typeof value === 'string' && (value.trim() === '' || value.trim() === UNAVAILABLE);
423
+ }
424
+
425
+ function textFact(value) {
426
+ return missing(value) ? UNAVAILABLE : String(value);
427
+ }
428
+
429
+ // S1: a worktree no longer carries an issue or ODD association, so the facts
430
+ // line has no association fact at all — not even a placeholder for one. A
431
+ // legacy `issues`/`odd`/`assignedOdd` in the payload is ignored rather than
432
+ // rendered, which is what keeps a stored association from reappearing on
433
+ // screen after the entity stopped owning it.
434
+
435
+ // Line counts for the change size (S16). `{ added, deleted }` is the fact's
436
+ // own shape; a caller that supplies only a file count can prove cleanliness
437
+ // (`files: 0` means no changed file could add or delete a line) but never a
438
+ // size, which stays unreadable rather than being faked or rendered as the
439
+ // retired file count.
440
+ function changeCounts(value) {
441
+ if (value && typeof value === 'object') {
442
+ const added = Number.isInteger(value.added) && value.added >= 0 ? value.added : null;
443
+ const deleted = Number.isInteger(value.deleted) && value.deleted >= 0 ? value.deleted : null;
444
+ if (added !== null && deleted !== null) return { added, deleted };
445
+ if (Number.isInteger(value.files) && value.files >= 0) {
446
+ return value.files === 0 ? { added: 0, deleted: 0 } : null;
447
+ }
448
+ return null;
449
+ }
450
+ if (Number.isInteger(value) && value >= 0) return value === 0 ? { added: 0, deleted: 0 } : null;
451
+ return null;
452
+ }
453
+
454
+ // The rendered change size, or `null` when it is a measured zero and the
455
+ // field must not appear at all (S16). `unavailable` is NOT a zero and stays.
456
+ // A readable size is the bare `+A −D`: the sign already says what the numbers
457
+ // are, so the label in front of it was noise (feedback). The label survives
458
+ // ONLY where the size is unreadable, because a bare `unavailable` would not
459
+ // say which fact is missing.
460
+ function changesFact(value) {
461
+ if (missing(value)) return UNAVAILABLE;
462
+ const counts = changeCounts(value);
463
+ if (counts === null) return UNAVAILABLE;
464
+ if (counts.added === 0 && counts.deleted === 0) return null;
465
+ // A zero side is dropped rather than printed: `−0` and `+0` say nothing a
466
+ // reader needs, so only the nonzero half of the pair appears.
467
+ const parts = [];
468
+ if (counts.added !== 0) parts.push(`+${counts.added}`);
469
+ if (counts.deleted !== 0) parts.push(`−${counts.deleted}`);
470
+ return parts.join(' ');
471
+ }
472
+
473
+ // One direction of ahead/behind (S16): a measured zero is omitted (`null`),
474
+ // while an unreadable value is not a zero and stays with its glyph.
475
+ function arrowFact(glyph, value) {
476
+ if (missing(value)) return `${glyph}${UNAVAILABLE}`;
477
+ const count = Number.isInteger(value) && value >= 0 ? value : null;
478
+ if (count === null) return `${glyph}${UNAVAILABLE}`;
479
+ if (count === 0) return null;
480
+ return `${glyph}${count}`;
481
+ }
482
+
483
+ // Relative age in this package's style, the same one the issues view uses
484
+ // (including its `ago` suffix) so two surfaces never describe the same clock
485
+ // two different ways: just now, minutes, hours, days, months, years.
486
+ function relativeAge(ms, nowMs) {
487
+ const diff = nowMs - ms;
488
+ if (!Number.isFinite(diff) || diff < 0) return 'just now';
489
+ const minutes = Math.floor(diff / 60000);
490
+ if (minutes < 1) return 'just now';
491
+ if (minutes < 60) return `${minutes}m ago`;
492
+ const hours = Math.floor(minutes / 60);
493
+ if (hours < 24) return `${hours}h ago`;
494
+ const days = Math.floor(hours / 24);
495
+ if (days < 30) return `${days}d ago`;
496
+ if (days < 365) return `${Math.floor(days / 30)}mo ago`;
497
+ return `${Math.floor(days / 365)}y ago`;
498
+ }
499
+
500
+ // The brief marker a compact facts row renders for an age it cannot read
501
+ // (S15). The caller's prose reason ("no activity since the checkout was
502
+ // created") stays in the row's data, never on screen: a preview line is a
503
+ // value, not an explanation.
504
+ const UNAVAILABLE_AGE = '—';
505
+
506
+ // One age fact: a timestamp in any accepted form, or the checkout-facts
507
+ // envelope. Unreadable renders the brief marker — never a fake date and never
508
+ // the caller's long reason.
509
+ function ageFact(raw, nowMs) {
510
+ let value = raw;
511
+ if (value && typeof value === 'object' && !(value instanceof Date)) value = value.value;
512
+ const at = toTimestampMs(value);
513
+ return at === null ? UNAVAILABLE_AGE : relativeAge(at, nowMs);
514
+ }
515
+
516
+ // A panel data line (S4a/S4d): every fact of one checkout, one line, each
517
+ // independently honest (S9). S16 renders the change size as line counts and
518
+ // DROPS a measured zero for the change size and for each of `↑`/`↓`, while
519
+ // `unavailable` always stays. S15 keeps the
520
+ // two ages brief: an unreadable one is `—`, and the diagnostic reason the
521
+ // caller supplied travels with the row's data instead of this line. Exported
522
+ // so the wiring unit and its tests format exactly what the panel renders.
523
+ // S1: no association fact is rendered (a legacy one in the payload is
524
+ // ignored). `opts.now` pins the clock both ages use.
525
+ function formatFactsLine(facts, opts) {
526
+ const value = facts && typeof facts === 'object' ? facts : {};
527
+ const settings = opts && typeof opts === 'object' ? opts : {};
528
+ const nowMs = Number.isFinite(settings.now) ? settings.now : Date.now();
529
+ const parts = [`branch ${textFact(value.branch)}`];
530
+ const changes = changesFact(value.changes);
531
+ // S16: a readable size stands alone; an unreadable one keeps its label so
532
+ // the line still names the fact that could not be read.
533
+ if (changes === UNAVAILABLE) parts.push(`changes ${UNAVAILABLE}`);
534
+ else if (changes !== null) parts.push(changes);
535
+ const arrows = [arrowFact('↑', value.ahead), arrowFact('↓', value.behind)]
536
+ .filter((part) => part !== null)
537
+ .join(' ');
538
+ if (arrows.length > 0) parts.push(arrows);
539
+ parts.push(
540
+ `created ${ageFact(value.createdAt, nowMs)}`,
541
+ `modified ${ageFact(value.lastActivity, nowMs)}`,
542
+ );
543
+ return parts.join(' · ');
544
+ }
545
+
546
+ function filterCheckouts(checkouts, query) {
547
+ const needle = String(query ?? '').trim().toLowerCase();
548
+ if (needle === '') return checkouts.slice();
549
+ return checkouts.filter((entry) => [entry.name, entry.branch || '', entry.path]
550
+ .some((field) => String(field).toLowerCase().includes(needle)));
551
+ }
552
+
553
+ function createWarpSelectorView(options = {}) {
554
+ const opts = options && typeof options === 'object' ? options : {};
555
+ let checkouts = normalizeCheckouts(opts.checkouts);
556
+ const current = normalizeCurrent(opts.current, checkouts);
557
+ const currentPath = current ? current.path : null;
558
+ const canonicalizer = typeof opts.canonicalPath === 'function' ? opts.canonicalPath : null;
559
+ // path -> checkout identity, filled when the list is NORMALIZED (mount,
560
+ // reload, enrichment) and read by the frames: render() performs no I/O.
561
+ const identities = new Map();
562
+ const currentId = identityOf(currentPath);
563
+ rememberIdentities();
564
+ const factsReader = typeof opts.facts === 'function' ? opts.facts : null;
565
+ const reloader = typeof opts.reload === 'function' ? opts.reload : null;
566
+ const toggler = typeof opts.onToggle === 'function' ? opts.onToggle : null;
567
+ const helpers = opts.helpers || null;
568
+ const theme = opts.theme || null;
569
+ const requestRender = typeof opts.requestRender === 'function' ? opts.requestRender : null;
570
+ const rawFinish = typeof opts.done === 'function' ? opts.done : () => {};
571
+ const nowOption = opts.now;
572
+ const nowFn = typeof nowOption === 'function'
573
+ ? () => {
574
+ const value = nowOption();
575
+ return Number.isFinite(value) ? value : Date.now();
576
+ }
577
+ : (Number.isFinite(nowOption) ? () => nowOption : () => Date.now());
578
+ let settled = false;
579
+
580
+ // The state is exposed for the wiring unit and for its tests: `view` is
581
+ // 'popup' | 'list', `cursor` indexes the panel's selectable rows, and
582
+ // `listCursor` the filtered extended list; `filter`/`filterOpen` are the
583
+ // always-active filter, `page` the visible page, `help` the overlay, and
584
+ // `checkouts`/`facts` the last data the component was given.
585
+ const state = {
586
+ view: 'popup',
587
+ cursor: 0,
588
+ listCursor: 0,
589
+ filter: '',
590
+ filterOpen: false,
591
+ page: 0,
592
+ checkouts,
593
+ facts: { path: null, status: 'idle' },
594
+ };
595
+ // path -> { status: 'pending'|'ready'|'error', value }; S9 caches so a
596
+ // frame never re-reads and a move back is instant.
597
+ const factCache = new Map();
598
+ // S4a/S4d: the panel opens with two data lines — the current worktree's
599
+ // and the first focused row's — so both reads start here instead of in
600
+ // render() (S1: a frame performs no I/O).
601
+ startFacts(currentPath);
602
+ startFacts(focusedPopupPath());
603
+
604
+ function finish(value) {
605
+ if (settled) return;
606
+ settled = true;
607
+ rawFinish(value);
608
+ }
609
+
610
+ function requestPaint() {
611
+ if (!requestRender) return;
612
+ try {
613
+ requestRender();
614
+ } catch {
615
+ // A repaint failure never breaks navigation.
616
+ }
617
+ }
618
+
619
+ function availableRows() {
620
+ let raw;
621
+ try {
622
+ raw = typeof opts.rowsAvailable === 'function' ? opts.rowsAvailable() : opts.rowsAvailable;
623
+ } catch {
624
+ return 0;
625
+ }
626
+ const rows = Math.floor(Number(raw));
627
+ return Number.isFinite(rows) && rows > 0 ? rows : 0;
628
+ }
629
+
630
+ function startFacts(path) {
631
+ if (typeof path !== 'string' || path.length === 0) return;
632
+ if (factCache.has(path)) return;
633
+ if (!factsReader) {
634
+ factCache.set(path, { status: 'error', value: null });
635
+ return;
636
+ }
637
+ factCache.set(path, { status: 'pending', value: null });
638
+ state.facts = { path, status: 'pending' };
639
+ let answer;
640
+ try {
641
+ answer = factsReader(path);
642
+ } catch {
643
+ factCache.set(path, { status: 'error', value: null });
644
+ state.facts = { path, status: 'error' };
645
+ return;
646
+ }
647
+ Promise.resolve(answer).then(
648
+ (value) => {
649
+ factCache.set(path, { status: 'ready', value });
650
+ if (state.facts.path === path) state.facts = { path, status: 'ready' };
651
+ if (state.view === 'popup' && (focusedPopupPath() === path || currentPath === path)) requestPaint();
652
+ },
653
+ () => {
654
+ factCache.set(path, { status: 'error', value: null });
655
+ if (state.facts.path === path) state.facts = { path, status: 'error' };
656
+ if (state.view === 'popup' && (focusedPopupPath() === path || currentPath === path)) requestPaint();
657
+ },
658
+ );
659
+ }
660
+
661
+ // The destinations this session can move to: every checkout except the one
662
+ // it already stands in (S4b). Both views read this one list, so neither can
663
+ // offer a route the session is already on.
664
+ function others() {
665
+ return checkouts.filter((entry) => !isCurrent(entry));
666
+ }
667
+
668
+ // The identity of one path: the canonical spelling when the caller supplied
669
+ // a reader, the path itself otherwise. A reader that throws or answers
670
+ // nothing leaves the spelling as the identity instead of breaking a frame.
671
+ function identityOf(value) {
672
+ if (typeof value !== 'string' || value.length === 0) return null;
673
+ if (!canonicalizer) return value;
674
+ try {
675
+ const resolved = canonicalizer(value);
676
+ if (typeof resolved === 'string' && resolved.length > 0) return resolved;
677
+ } catch {
678
+ // A hostile reader leaves the spelling as the identity.
679
+ }
680
+ return value;
681
+ }
682
+
683
+ // Rebuilt from the list that was just normalized, so a checkout that left
684
+ // the listing leaves no identity behind and a moved one is re-read.
685
+ function rememberIdentities() {
686
+ identities.clear();
687
+ for (const entry of checkouts) identities.set(entry.path, identityOf(entry.path));
688
+ }
689
+
690
+ // S4b: the one notion of "the checkout this session is in", shared by the
691
+ // panel and the extended list. Identity, never a string prefix: `feat-x` is
692
+ // not `feat-x2`, and a symlink spelling of the current checkout is still the
693
+ // current checkout. A session standing outside every listed checkout (or no
694
+ // `current` at all) excludes nothing, which is the honest answer.
695
+ function isCurrent(entry) {
696
+ if (currentId === null) return false;
697
+ const id = identities.has(entry.path) ? identities.get(entry.path) : entry.path;
698
+ return id === currentId;
699
+ }
700
+
701
+ // S5: a row is the name alone. `describe` appends only the non-branch role
702
+ // markers (principal); the current checkout is never a row to mark.
703
+ function describe(entry) {
704
+ return entry.isPrincipal ? 'use the local project' : null;
705
+ }
706
+
707
+ function rowText(entry, marker) {
708
+ const desc = describe(entry);
709
+ return `${marker}${entry.name}${desc ? ` · ${desc}` : ''}`;
710
+ }
711
+
712
+ // S4b/S4c: the list area holds at most five worktree rows and, only when
713
+ // at least one worktree is left out, one extra `view all worktrees` row.
714
+ // `maxRows` is the ceiling the host offers; `height` is what this plan
715
+ // actually renders, so the frame fills it exactly.
716
+ function popupPlan(maxRows) {
717
+ const list = others();
718
+ const area = Math.max(0, maxRows - POPUP_FIXED_ROWS);
719
+ if (list.length === 0) {
720
+ // One muted row explains the empty list inside the frame.
721
+ return { list, shown: [], viewAll: false, height: POPUP_FIXED_ROWS + 1 };
722
+ }
723
+ if (list.length <= Math.min(MAX_LIST_ROWS, area)) {
724
+ return { list, shown: list, viewAll: false, height: POPUP_FIXED_ROWS + list.length };
725
+ }
726
+ // Something is hidden, so the offer row costs one of the area's rows.
727
+ const shown = list.slice(0, Math.max(0, Math.min(MAX_LIST_ROWS, area - 1)));
728
+ const viewAll = area >= 1;
729
+ return { list, shown, viewAll, height: POPUP_FIXED_ROWS + shown.length + (viewAll ? 1 : 0) };
730
+ }
731
+
732
+ // S6: creation is a hotkey, never a list row, so the selectable rows are
733
+ // exactly the worktrees shown plus the offer row.
734
+ function popupItems(plan) {
735
+ const items = [];
736
+ for (const entry of plan.shown) items.push({ kind: 'checkout', checkout: entry });
737
+ if (plan.viewAll) items.push({ kind: 'viewall' });
738
+ return items;
739
+ }
740
+
741
+ function popupItemsNow() {
742
+ return popupItems(popupPlan(availableRows()));
743
+ }
744
+
745
+ function focusedPopupItem() {
746
+ const items = popupItemsNow();
747
+ return items[Math.min(Math.max(0, state.cursor), items.length - 1)] || null;
748
+ }
749
+
750
+ function focusedPopupPath() {
751
+ const item = focusedPopupItem();
752
+ return item && item.kind === 'checkout' ? item.checkout.path : null;
753
+ }
754
+
755
+ // S4a/S4d: one formatter for every data line this panel draws. A cached
756
+ // read is rendered as it is, a pending one says so, and a path that was
757
+ // never read stays empty instead of claiming a fact.
758
+ function factsTextFor(path) {
759
+ if (typeof path !== 'string' || path.length === 0) return '';
760
+ const nowMs = nowFn();
761
+ const line = (value) => formatFactsLine(value, { now: nowMs });
762
+ const entry = factCache.get(path);
763
+ if (!entry) return '';
764
+ if (entry.status === 'ready') return line(entry.value);
765
+ if (entry.status === 'pending') return 'reading …';
766
+ return line(null);
767
+ }
768
+
769
+ function currentFactsText() {
770
+ return factsTextFor(currentPath);
771
+ }
772
+
773
+ // S4d: the row the cursor sits on, or an empty line when nothing is
774
+ // listed (the list area already says why).
775
+ function factsRowText() {
776
+ const item = focusedPopupItem();
777
+ if (!item) return '';
778
+ if (item.kind === 'viewall') return 'every worktree, searchable';
779
+ return factsTextFor(item.checkout.path);
780
+ }
781
+
782
+ // S4b: the extended list is the same set of destinations the panel offers —
783
+ // every checkout except the one this session already stands in — filtered
784
+ // by the always-active query and paged.
785
+ function listPlan(height) {
786
+ const rows = filterCheckouts(others(), state.filter);
787
+ const pageSize = Math.max(1, height - LIST_FIXED_ROWS);
788
+ const requested = rows.length === 0 ? 0 : Math.floor(Math.max(0, state.listCursor) / pageSize);
789
+ const page = paginate(rows, requested, pageSize);
790
+ const start = page.page * pageSize;
791
+ const end = Math.min(rows.length, start + page.items.length);
792
+ if (rows.length === 0) state.listCursor = 0;
793
+ else state.listCursor = Math.min(Math.max(start, state.listCursor), end - 1);
794
+ state.page = page.page;
795
+ return { rows, page: page.page, pageCount: page.pageCount, pageSize, items: page.items, cursor: state.listCursor };
796
+ }
797
+
798
+ function focusedListCheckout() {
799
+ const height = availableRows();
800
+ if (!height) return null;
801
+ const plan = listPlan(height);
802
+ return plan.items[plan.cursor - plan.page * plan.pageSize] || null;
803
+ }
804
+
805
+ function movePopup(delta) {
806
+ const height = availableRows();
807
+ if (!height) return;
808
+ const items = popupItems(popupPlan(height));
809
+ if (items.length === 0) return;
810
+ state.cursor = Math.min(items.length - 1, Math.max(0, state.cursor + delta));
811
+ // S1: a move is what starts the focused checkout's read. render() itself
812
+ // never performs I/O.
813
+ if (state.view === 'popup') startFacts(focusedPopupPath());
814
+ requestPaint();
815
+ }
816
+
817
+ function moveList(delta) {
818
+ const height = availableRows();
819
+ if (!height) return;
820
+ const plan = listPlan(height);
821
+ if (plan.rows.length === 0) return;
822
+ state.listCursor = Math.min(plan.rows.length - 1, Math.max(0, plan.cursor + delta));
823
+ requestPaint();
824
+ }
825
+
826
+ function pageList(direction) {
827
+ const height = availableRows();
828
+ if (!height) return;
829
+ const plan = listPlan(height);
830
+ if (plan.rows.length === 0) return;
831
+ const next = direction > 0
832
+ ? Math.min(plan.page + 1, plan.pageCount - 1)
833
+ : Math.max(0, plan.page - 1);
834
+ state.listCursor = Math.min(plan.rows.length - 1, next * plan.pageSize);
835
+ requestPaint();
836
+ }
837
+
838
+ function openList() {
839
+ state.view = 'list';
840
+ state.listCursor = 0;
841
+ const focused = focusedPopupItem();
842
+ if (focused && focused.kind === 'checkout') {
843
+ const rows = filterCheckouts(others(), state.filter);
844
+ const at = rows.findIndex((entry) => entry.path === focused.checkout.path);
845
+ if (at >= 0) state.listCursor = at;
846
+ }
847
+ requestPaint();
848
+ }
849
+
850
+ function openFilter(seed) {
851
+ state.filterOpen = true;
852
+ if (typeof seed === 'string' && seed.length > 0) state.filter += seed;
853
+ state.listCursor = 0;
854
+ requestPaint();
855
+ }
856
+
857
+ function closeFilter() {
858
+ state.filterOpen = false;
859
+ requestPaint();
860
+ }
861
+
862
+ function appendFilter(text) {
863
+ state.filter += text;
864
+ state.listCursor = 0;
865
+ requestPaint();
866
+ }
867
+
868
+ function backspaceFilter() {
869
+ const chars = [...state.filter];
870
+ if (chars.length === 0) return;
871
+ chars.pop();
872
+ state.filter = chars.join('');
873
+ state.listCursor = 0;
874
+ requestPaint();
875
+ }
876
+
877
+ // S12/S4: Delete empties the whole query at once while the search field is
878
+ // active. It is a text-editing key here, never the worktree deletion
879
+ // binding (`ctrl+d` owns that).
880
+ function clearFilter() {
881
+ if (state.filter === '') return;
882
+ state.filter = '';
883
+ state.listCursor = 0;
884
+ requestPaint();
885
+ }
886
+
887
+ function reload() {
888
+ factCache.clear();
889
+ state.facts = { path: null, status: 'idle' };
890
+ // The current worktree's line is not part of the reloaded list, so its
891
+ // read restarts before the reloader's answer can start the focused one.
892
+ if (state.view === 'popup') startFacts(currentPath);
893
+ let next;
894
+ try {
895
+ next = reloader ? reloader() : null;
896
+ } catch {
897
+ next = null;
898
+ }
899
+ const settle = (value) => { if (Array.isArray(value)) apply(value); };
900
+ if (next && typeof next.then === 'function') Promise.resolve(next).then(settle, () => {});
901
+ else settle(next);
902
+ requestPaint();
903
+ if (state.view === 'popup') startFacts(focusedPopupPath());
904
+ }
905
+
906
+ // S1: apply enrichment that arrived AFTER the mount (activity ages, newly
907
+ // readable facts). The rows re-sort, but the focused checkout keeps its
908
+ // identity across the reorder instead of the cursor silently landing on a
909
+ // different row. A settled component ignores late work (only still-alive
910
+ // components repaint).
911
+ function apply(list) {
912
+ if (settled || !Array.isArray(list)) return;
913
+ const focusPath = state.view === 'list'
914
+ ? ((focusedListCheckout() || {}).path || null)
915
+ : focusedPopupPath();
916
+ checkouts = normalizeCheckouts(list);
917
+ state.checkouts = checkouts;
918
+ rememberIdentities();
919
+ refocus(focusPath);
920
+ requestPaint();
921
+ if (state.view === 'popup') startFacts(focusedPopupPath());
922
+ }
923
+
924
+ function refocus(path) {
925
+ if (typeof path !== 'string' || path.length === 0) return;
926
+ if (state.view === 'list') {
927
+ const rows = filterCheckouts(others(), state.filter);
928
+ const at = rows.findIndex((entry) => entry.path === path);
929
+ if (at >= 0) state.listCursor = at;
930
+ else state.listCursor = Math.min(state.listCursor, Math.max(0, rows.length - 1));
931
+ return;
932
+ }
933
+ const items = popupItemsNow();
934
+ const at = items.findIndex((item) => item.kind === 'checkout' && item.checkout.path === path);
935
+ if (at >= 0) state.cursor = at;
936
+ else state.cursor = Math.min(state.cursor, Math.max(0, items.length - 1));
937
+ }
938
+
939
+ function choosePopup() {
940
+ const item = focusedPopupItem();
941
+ if (!item) return;
942
+ if (item.kind === 'viewall') {
943
+ openList();
944
+ return;
945
+ }
946
+ finish({ warp: item.checkout.path });
947
+ }
948
+
949
+ // S4: `ctrl+d` NAMES the checkout to delete and nothing else. The marker
950
+ // carries the focused path, so the extension re-resolves it against a fresh
951
+ // listing and asks its own question; nothing here reads a permission mode,
952
+ // inspects changes or removes anything. The offer row is not a checkout, so
953
+ // it resolves nothing.
954
+ function askDelete(item) {
955
+ if (!item || item.kind !== 'checkout') return;
956
+ finish({ delete: item.checkout.path });
957
+ }
958
+
959
+ function askDeletePopup() {
960
+ askDelete(focusedPopupItem());
961
+ }
962
+
963
+ function askDeleteList() {
964
+ const entry = focusedListCheckout();
965
+ if (!entry) return;
966
+ finish({ delete: entry.path });
967
+ }
968
+
969
+ function chooseList() {
970
+ const entry = focusedListCheckout();
971
+ if (!entry) return;
972
+ finish({ warp: entry.path });
973
+ }
974
+
975
+ function handlePopupInput(data) {
976
+ if (keyText(helpers, data, ['escape', 'ctrl+c'])) {
977
+ finish(null);
978
+ return;
979
+ }
980
+ if (keyText(helpers, data, ['enter', 'return'])) {
981
+ choosePopup();
982
+ return;
983
+ }
984
+ if (keyText(helpers, data, ['up', 'k'])) {
985
+ movePopup(-1);
986
+ return;
987
+ }
988
+ if (keyText(helpers, data, ['down', 'j'])) {
989
+ movePopup(1);
990
+ return;
991
+ }
992
+ if (keyText(helpers, data, ['c'])) {
993
+ // S6: creation is a hotkey, never one of the list rows.
994
+ finish({ create: true });
995
+ return;
996
+ }
997
+ if (keyText(helpers, data, ['ctrl+d'])) {
998
+ askDeletePopup();
999
+ return;
1000
+ }
1001
+ if (keyText(helpers, data, ['v'])) {
1002
+ openList();
1003
+ return;
1004
+ }
1005
+ if (keyText(helpers, data, ['r'])) {
1006
+ reload();
1007
+ }
1008
+ }
1009
+
1010
+ function handleListInput(data) {
1011
+ const open = state.filterOpen;
1012
+ // Navigation and confirmation stay bindings while the filter is open (S6).
1013
+ if (keyText(helpers, data, ['up'])) {
1014
+ moveList(-1);
1015
+ return;
1016
+ }
1017
+ if (keyText(helpers, data, ['down'])) {
1018
+ moveList(1);
1019
+ return;
1020
+ }
1021
+ if (keyText(helpers, data, ['pageup'])) {
1022
+ pageList(-1);
1023
+ return;
1024
+ }
1025
+ if (keyText(helpers, data, ['pagedown'])) {
1026
+ pageList(1);
1027
+ return;
1028
+ }
1029
+ if (keyText(helpers, data, ['escape', 'ctrl+c'])) {
1030
+ if (open) closeFilter();
1031
+ else {
1032
+ state.view = 'popup';
1033
+ // S1: stepping back into the panel may focus a checkout that has not
1034
+ // been read yet; start its read now, never inside render().
1035
+ startFacts(focusedPopupPath());
1036
+ requestPaint();
1037
+ }
1038
+ return;
1039
+ }
1040
+ if (keyText(helpers, data, ['enter', 'return'])) {
1041
+ chooseList();
1042
+ return;
1043
+ }
1044
+ if (!open && keyText(helpers, data, ['k'])) {
1045
+ moveList(-1);
1046
+ return;
1047
+ }
1048
+ if (!open && keyText(helpers, data, ['j'])) {
1049
+ moveList(1);
1050
+ return;
1051
+ }
1052
+ if (!open && keyText(helpers, data, ['r'])) {
1053
+ reload();
1054
+ return;
1055
+ }
1056
+ // S4: the delete request is a browsing binding, so the search field owns
1057
+ // the keyboard instead (`delete` there clears the query).
1058
+ if (!open && keyText(helpers, data, ['ctrl+d'])) {
1059
+ askDeleteList();
1060
+ return;
1061
+ }
1062
+ if (isSpaceKey(helpers, data)) {
1063
+ if (open) {
1064
+ appendFilter(' ');
1065
+ return;
1066
+ }
1067
+ if (toggler) {
1068
+ const entry = focusedListCheckout();
1069
+ if (!entry) return;
1070
+ try {
1071
+ toggler(entry.path);
1072
+ } catch {
1073
+ // A hostile toggle never breaks navigation.
1074
+ }
1075
+ requestPaint();
1076
+ return;
1077
+ }
1078
+ openFilter(' ');
1079
+ return;
1080
+ }
1081
+ if (keyText(helpers, data, ['backspace'])) {
1082
+ if (open) backspaceFilter();
1083
+ return;
1084
+ }
1085
+ if (keyText(helpers, data, ['delete'])) {
1086
+ if (open) clearFilter();
1087
+ return;
1088
+ }
1089
+ const typed = printableChar(data);
1090
+ if (!open && (typed === 'T' || typed === '/')) {
1091
+ // The explicit openers start a NEW search, so a stale query never
1092
+ // silently hides the rows the user is about to browse.
1093
+ state.filter = '';
1094
+ openFilter('');
1095
+ return;
1096
+ }
1097
+ if (typed) {
1098
+ if (open) appendFilter(typed);
1099
+ else openFilter(typed);
1100
+ }
1101
+ }
1102
+
1103
+ // S11: every frame mark takes the palette's border role, so a theme that
1104
+ // names one decides the whole frame.
1105
+ function border(text) {
1106
+ return palette.paint(theme, 'border', text);
1107
+ }
1108
+
1109
+ function frameTop(width, title) {
1110
+ const shownTitle = fit(helpers, title, Math.max(1, width - 11));
1111
+ const left = `${border('╭─')} ${palette.title(theme, shownTitle)} `;
1112
+ const right = ` ${palette.key(theme, 'esc')} ${border('─╮')}`;
1113
+ const fill = Math.max(0, width - measure(helpers, left) - measure(helpers, right));
1114
+ return `${left}${border('─'.repeat(fill))}${right}`;
1115
+ }
1116
+
1117
+ function frameBottom(width) {
1118
+ return border(`╰${'─'.repeat(Math.max(0, width - 2))}╯`);
1119
+ }
1120
+
1121
+ // S11: a row body is either the focused band (the monochrome `▸` still marks
1122
+ // it when the theme offers no background) or one palette KIND.
1123
+ function frameRow(width, text, kind, focused) {
1124
+ const inner = Math.max(1, width - 4);
1125
+ const padded = pad(helpers, text, inner);
1126
+ const body = focused ? palette.highlight(theme, padded, true) : paintKind(theme, kind, padded);
1127
+ return `${border('│')} ${body} ${border('│')}`;
1128
+ }
1129
+
1130
+ // The panel legend's tokens: the offer row's key only when there is an
1131
+ // offer. S1/S4: no capability token is conditional any more — the delete
1132
+ // key and its predicate left with the worktree association.
1133
+ function popupLegend(plan) {
1134
+ return plan.viewAll ? POPUP_KEYS.slice() : POPUP_KEYS.filter((entry) => entry.text !== 'v all');
1135
+ }
1136
+
1137
+ // S2/S11: the footer legend renders through the frame row, so it is
1138
+ // compacted to the same inner width the row then pads to — one row, never
1139
+ // wrapped — and every key/description pair carries its own role.
1140
+ function legendRow(width, entries) {
1141
+ return frameRow(width, palette.legendRow(theme, entries, Math.max(1, width - 4), (text) => measure(helpers, text)), null);
1142
+ }
1143
+
1144
+ // S3/S4: the panel renders exactly `plan.height` rows, so a host that
1145
+ // anchors it at the bottom of the terminal gets a frame with no loose
1146
+ // lines inside or below it.
1147
+ function renderPopup(width, maxRows) {
1148
+ const plan = popupPlan(maxRows);
1149
+ const items = popupItems(plan);
1150
+ if (state.cursor > items.length - 1) state.cursor = Math.max(0, items.length - 1);
1151
+ if (state.cursor < 0) state.cursor = 0;
1152
+ const focused = items[state.cursor];
1153
+
1154
+ const lines = [frameTop(width, 'Warp')];
1155
+ lines.push(frameRow(width, current ? current.name : '(unknown checkout)', 'value'));
1156
+ lines.push(frameRow(width, currentFactsText(), 'count'));
1157
+ lines.push(frameRow(width, ''));
1158
+
1159
+ if (plan.list.length === 0) {
1160
+ lines.push(frameRow(width, '(no other worktrees)', 'count'));
1161
+ } else {
1162
+ for (const entry of plan.shown) {
1163
+ const isFocused = !!(focused && focused.kind === 'checkout' && focused.checkout.path === entry.path);
1164
+ lines.push(frameRow(width, rowText(entry, isFocused ? '▸ ' : ' '), null, isFocused));
1165
+ }
1166
+ if (plan.viewAll) {
1167
+ lines.push(frameRow(
1168
+ width,
1169
+ `${focused && focused.kind === 'viewall' ? '▸ ' : ' '}view all worktrees`,
1170
+ 'value',
1171
+ !!(focused && focused.kind === 'viewall'),
1172
+ ));
1173
+ }
1174
+ }
1175
+ lines.push(frameRow(width, factsRowText(), 'count'));
1176
+ // S4c: with nothing left out there is no offer row, so the legend does
1177
+ // not advertise its key either. `v` itself keeps working.
1178
+ lines.push(legendRow(width, popupLegend(plan)));
1179
+ lines.push(frameBottom(width));
1180
+ return lines;
1181
+ }
1182
+
1183
+ // S11: the search row paints its static label dim and the live query as a
1184
+ // value; the focused band marks it while the filter holds the keyboard.
1185
+ function filterLine() {
1186
+ const prefix = palette.label(theme, 'filter: ');
1187
+ if (state.filterOpen) return `${prefix}${palette.value(theme, `${state.filter}▏`)}`;
1188
+ if (state.filter === '') return `${prefix}${palette.description(theme, 'type to search')}`;
1189
+ return `${prefix}${palette.value(theme, state.filter)}`;
1190
+ }
1191
+
1192
+ function renderList(width, height) {
1193
+ const plan = listPlan(height);
1194
+ const rowTexts = [];
1195
+ if (plan.items.length === 0) {
1196
+ const needle = state.filter.trim();
1197
+ rowTexts.push({ text: needle === '' ? '(no other worktrees)' : `(no matches for "${needle}")`, kind: 'count' });
1198
+ } else {
1199
+ const pageStart = plan.page * plan.pageSize;
1200
+ plan.items.forEach((entry, offset) => {
1201
+ const isFocused = pageStart + offset === plan.cursor;
1202
+ rowTexts.push({
1203
+ text: rowText(entry, isFocused ? '▸ ' : ' '),
1204
+ focused: isFocused,
1205
+ });
1206
+ });
1207
+ }
1208
+ const filterText = filterLine();
1209
+ const lines = [frameTop(width, 'Warp · all worktrees')];
1210
+ lines.push(frameRow(width, filterText, state.filterOpen ? null : 'count', state.filterOpen));
1211
+ for (let row = 0; row < plan.pageSize; row += 1) {
1212
+ const entry = rowTexts[row];
1213
+ lines.push(entry ? frameRow(width, entry.text, entry.kind, entry.focused) : frameRow(width, ''));
1214
+ }
1215
+ const total = plan.rows.length;
1216
+ // S11: a static label, a live value and a count — each with its own role.
1217
+ lines.push(frameRow(width,
1218
+ `${palette.label(theme, 'page ')}${palette.value(theme, `${plan.page + 1}/${plan.pageCount}`)}`
1219
+ + `${palette.count(theme, ` · ${total} ${total === 1 ? 'worktree' : 'worktrees'}`)}`,
1220
+ null));
1221
+ // S12/S4: an active search names the Delete-clear key; the browsing list
1222
+ // keeps its own legend. Either way it is exactly one footer row.
1223
+ lines.push(legendRow(width, state.filterOpen ? LIST_FILTER_KEYS : LIST_KEYS));
1224
+ lines.push(frameBottom(width));
1225
+ return lines;
1226
+ }
1227
+
1228
+ function render(width) {
1229
+ const w = Math.floor(Number(width));
1230
+ if (!Number.isFinite(w) || w <= 0) return [];
1231
+ const maxRows = availableRows();
1232
+ const minimum = state.view === 'list' ? LIST_MIN_HEIGHT : POPUP_MIN_HEIGHT;
1233
+ if (w < MIN_WIDTH || maxRows < minimum) {
1234
+ return [pad(helpers, fit(helpers, 'Warp — enlarge the terminal', w), w)];
1235
+ }
1236
+ // S3/S4: the panel takes its own height (never taller than the host
1237
+ // offers); the extended list still fills every row it is given.
1238
+ return state.view === 'list' ? renderList(w, maxRows) : renderPopup(w, maxRows);
1239
+ }
1240
+
1241
+ function handleInput(data) {
1242
+ if (settled) return;
1243
+ if (isKeyRelease(helpers, data)) return;
1244
+ if (state.view === 'list') {
1245
+ handleListInput(data);
1246
+ return;
1247
+ }
1248
+ handlePopupInput(data);
1249
+ }
1250
+
1251
+ function invalidate() {}
1252
+
1253
+ return { render, handleInput, invalidate, apply, state };
1254
+ }
1255
+
1256
+ module.exports = {
1257
+ createWarpSelectorView,
1258
+ formatFactsLine,
1259
+ MIN_WIDTH,
1260
+ MAX_LIST_ROWS,
1261
+ POPUP_MIN_HEIGHT,
1262
+ LIST_MIN_HEIGHT,
1263
+ POPUP_FIXED_ROWS,
1264
+ LIST_FIXED_ROWS,
1265
+ };