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,906 @@
1
+ 'use strict';
2
+ // The reusable picker primitives the warp surfaces share: `paginate` (the
3
+ // page clamps lib/warp-selector-view.js renders with), the single-select
4
+ // prompt used by the warp's pending-changes question, and the multi-select /
5
+ // text-input components.
6
+ //
7
+ // S1: the ISSUE and ODD dataset plumbing that used to live beside them (the
8
+ // `gh` open-issue fetch, the `odd/tasks` listing, the two search matchers and
9
+ // their pick-item adapters) is GONE. Worktrees no longer receive issues or
10
+ // ODD documents and the create panel asks for a type and a name only, so
11
+ // nothing in this package lists either dataset for selection any more.
12
+ //
13
+ // Pure Node, no package imports: `child_process` and `fs` resolve lazily
14
+ // inside the functions (or ride the injected seams), so the extension
15
+ // stays lazily-loaded and the copy-to-.mjs test loaders keep working.
16
+ // Every external call is injectable, so tests never spawn a child or open a
17
+ // real prompt.
18
+ //
19
+ // Host component contract (the same seam the customize panel uses):
20
+ // `createMultiSelect` returns `{ render(width), handleInput(data),
21
+ // invalidate(), state }`; the factory never writes state, notifies, or
22
+ // reloads — `done({ selected: [...] })` confirms (an empty list is a
23
+ // valid confirmation) and `done(null)` cancels (Esc). Selection is
24
+ // tracked by item id, so it survives filtering and paging.
25
+
26
+ const path = require('node:path');
27
+ // S11: the ONE palette every reader and panel shares decides which theme role
28
+ // paints each kind of token (key, title, description, count, label, value,
29
+ // border, error), so a footer KEY is always distinguishable from the words
30
+ // beside it and this module keeps no copy of the per-role try/catch guards.
31
+ const palette = require('./tui-paint.js');
32
+
33
+ // Rows per selector page when the caller names none.
34
+ const MULTI_SELECT_PAGE_SIZE_DEFAULT = 8;
35
+ const MULTI_SELECT_MAX_HEIGHT = 24;
36
+
37
+ function shortError(err) {
38
+ if (err === null || err === undefined) return 'unknown error';
39
+ if (typeof err === 'string') return err.split('\n')[0];
40
+ if (err.code === 'ENOENT' || /ENOENT/i.test(String((err && err.message) || err))) {
41
+ return 'gh CLI is not available';
42
+ }
43
+ return String((err && err.message) || err).split('\n')[0];
44
+ }
45
+
46
+ // Slice the (already filtered) rows into pages, clamping the requested
47
+ // page into range. The empty list still yields one page so the footer
48
+ // never divides by zero.
49
+ function paginate(items, page, pageSize) {
50
+ const rows = Array.isArray(items) ? items : [];
51
+ const size = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : MULTI_SELECT_PAGE_SIZE_DEFAULT;
52
+ const pageCount = Math.max(1, Math.ceil(rows.length / size));
53
+ const at = Math.min(Math.max(0, Number.isInteger(page) ? page : 0), pageCount - 1);
54
+ return { items: rows.slice(at * size, at * size + size), page: at, pageCount, total: rows.length };
55
+ }
56
+
57
+ function keyText(helpers, data, names) {
58
+ for (const name of names) {
59
+ try {
60
+ if (helpers && typeof helpers.matchesKey === 'function' && helpers.matchesKey(data, name)) return true;
61
+ } catch {
62
+ // A throwing matcher never matches; the raw fallback below decides.
63
+ }
64
+ }
65
+ // Raw fallback: the terminal bytes the installed matcher accepts in
66
+ // legacy mode, so the picker stays drivable when the helper module
67
+ // failed to load (a picker that ignores Esc/Enter reads as frozen).
68
+ if (typeof data === 'string' && data.length > 0) {
69
+ for (const name of names) {
70
+ const raw = RAW_KEY_BYTES[String(name).toLowerCase()];
71
+ if (raw && raw.includes(data)) return true;
72
+ }
73
+ }
74
+ const text = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
75
+ const lower = String(text).toLowerCase();
76
+ return names.some((name) => lower === String(name).toLowerCase());
77
+ }
78
+
79
+ // Raw terminal bytes per key id, mirroring the installed pi-tui
80
+ // keys.js legacy sequences (the same bytes its matchesKey accepts
81
+ // without the Kitty protocol). Minimal on purpose: the arrows, the
82
+ // toggle/confirm/cancel keys, paging and editing — every key the
83
+ // picker binds. `delete` also covers DEL, which terminals send for
84
+ // backspace over some transports.
85
+ const RAW_KEY_BYTES = {
86
+ escape: ['\x1b'],
87
+ 'ctrl+c': ['\x03'],
88
+ 'ctrl+d': ['\x04'],
89
+ enter: ['\r', '\n'],
90
+ return: ['\r', '\n'],
91
+ tab: ['\t'],
92
+ space: [' '],
93
+ backspace: ['\x7f', '\b'],
94
+ delete: ['\x1b[3~', '\x7f'],
95
+ '\x7f': ['\x7f'],
96
+ up: ['\x1b[A'],
97
+ down: ['\x1b[B'],
98
+ right: ['\x1b[C'],
99
+ left: ['\x1b[D'],
100
+ pageup: ['\x1b[5~'],
101
+ pagedown: ['\x1b[6~'],
102
+ };
103
+
104
+ function isKeyRelease(helpers, data) {
105
+ try {
106
+ if (helpers && typeof helpers.isKeyRelease === 'function') return helpers.isKeyRelease(data) === true;
107
+ } catch {
108
+ // A throwing classifier never releases; fall through.
109
+ }
110
+ return false;
111
+ }
112
+
113
+ function measureWidth(helpers, text) {
114
+ try {
115
+ if (helpers && typeof helpers.visibleWidth === 'function') return helpers.visibleWidth(text);
116
+ } catch {
117
+ // Fall through to the codepoint count.
118
+ }
119
+ return [...String(text).replace(/\x1b\[[0-9;]*m/g, '')].length;
120
+ }
121
+
122
+ function fitWidth(helpers, text, width, ellipsis = '…') {
123
+ const clean = String(text).split('\n')[0] ?? '';
124
+ if (measureWidth(helpers, clean) <= width) return clean;
125
+ try {
126
+ if (helpers && typeof helpers.truncateToWidth === 'function') return helpers.truncateToWidth(clean, width, ellipsis);
127
+ } catch {
128
+ // Fall through to the plain slice.
129
+ }
130
+ const chars = [...clean];
131
+ return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + ellipsis : clean;
132
+ }
133
+
134
+ // S11: the role per token KIND lives in lib/tui-paint.js, so the components
135
+ // below name kinds (title, key, label, value, count, description, border)
136
+ // instead of colours, and a missing, partial or throwing theme degrades to
137
+ // plain text inside the palette. T9 (S13) is why a kind — never a role spelling
138
+ // — is written here: the table paints a TITLE with `customMessageLabel`, the role
139
+ // the Pi Agents view uses for its own frame title, a BUTTON/shortcut key with the
140
+ // primary accent and a live value with the normal `text` role, so no component in
141
+ // this module decides any of that on its own. `accent` is the focus-marker form: the accent role without
142
+ // bold, so a `❯` marks focus without reading like data.
143
+ function kindText(theme, kind, text) {
144
+ if (kind === 'accent') return palette.paint(theme, 'accent', text);
145
+ const painter = palette[kind];
146
+ if (typeof painter === 'function') return painter(theme, text);
147
+ const role = palette.ROLES[kind];
148
+ return typeof role === 'string' ? palette.paint(theme, role, text) : String(text);
149
+ }
150
+
151
+ // A composed line of kind-tagged segments, painted only AFTER the plain text
152
+ // has been fitted to the width: painting first would let the fallback
153
+ // truncation cut through an escape sequence, so an overlong row degrades to an
154
+ // uncoloured, honestly truncated line instead of a broken one.
155
+ function composed(helpers, theme, segments, width) {
156
+ const plain = segments.map((segment) => segment.text).join('');
157
+ if (measureWidth(helpers, plain) > width) return fitWidth(helpers, plain, width, '');
158
+ return segments.map((segment) => kindText(theme, segment.kind, segment.text)).join('');
159
+ }
160
+
161
+ // A frame rule: the palette's border role, never a text role.
162
+ function rule(theme, width) {
163
+ return kindText(theme, 'border', '─'.repeat(Math.max(0, width)));
164
+ }
165
+
166
+ // S2/S11: a key legend stays on ONE row. lib/tui-paint.js owns BOTH the
167
+ // compaction (the full text while it fits, then the short tokens, then the
168
+ // lowest-priority keys dropped — display order never changes and no token
169
+ // carries a newline) and the roles, so each entry's key prefix is painted
170
+ // accent+bold and its description muted. The caller's ANSI-aware measurer is
171
+ // what decides a fit, so painting never changes what fits; a legend that still
172
+ // overflows (one token wider than the panel) is truncated from its PLAIN form,
173
+ // so no escape sequence is ever cut in half.
174
+ function legendRow(helpers, theme, entries, width) {
175
+ const measure = (text) => measureWidth(helpers, text);
176
+ const painted = palette.legendRow(theme, entries, width, measure);
177
+ if (measure(painted) <= width) return painted;
178
+ return fitWidth(helpers, palette.legendRow(null, entries, width, measure), width, '');
179
+ }
180
+
181
+ // S2: the one-row legends of this module (`text` full, `short` compact,
182
+ // `priority` the drop order — the lowest goes first, so cancel outlives the
183
+ // rest). S11: `key` is the exact key spelling the token starts with, so
184
+ // lib/tui-paint.js paints that prefix with the key role (accent+bold) and the
185
+ // words after it as their description; every compact spelling keeps the same
186
+ // prefix, so a key never loses its role when the row compacts.
187
+ const WRITE_KEYS = [
188
+ { text: 'type to write', short: 'type', priority: 40 },
189
+ { key: 'Enter', text: 'Enter confirm', short: 'Enter confirm', priority: 90 },
190
+ { key: 'Tab', text: 'Tab back', short: 'Tab back', priority: 85 },
191
+ { key: 'Esc', text: 'Esc cancel', short: 'Esc cancel', priority: 100 },
192
+ ];
193
+ const SELECT_KEYS = [
194
+ { key: '↑↓', text: '↑↓ move', short: '↑↓', priority: 95 },
195
+ { key: '←/→', text: '←/→ pages', short: '←/→', priority: 50 },
196
+ { key: 'T', text: 'T search', short: 'T', priority: 55 },
197
+ { key: 'Space', text: 'Space toggle', short: 'Space', priority: 65 },
198
+ { key: 'Enter', text: 'Enter confirm', short: 'Enter confirm', priority: 90 },
199
+ { key: 'Tab', text: 'Tab back', short: 'Tab back', priority: 80 },
200
+ { key: 'Esc', text: 'Esc cancel', short: 'Esc cancel', priority: 100 },
201
+ ];
202
+ const SEARCH_KEYS = [
203
+ { text: 'Search', short: 'search', priority: 100 },
204
+ { text: 'type to filter', short: 'filter', priority: 60 },
205
+ { key: 'Enter/Esc', text: 'Enter/Esc back to list (filter kept)', short: 'Enter/Esc keep', priority: 90 },
206
+ { key: 'delete', text: 'delete clear', short: 'delete clear', priority: 85 },
207
+ { key: 'Tab', text: 'Tab back', short: 'Tab back', priority: 80 },
208
+ ];
209
+
210
+ // Standalone-panel rule: this component renders ONLY its own title, its
211
+ // own search box, its own data rows and its own key footer. It never
212
+ // renders cross-field wizard chips, tabs or step counters (no `[n/m]`,
213
+ // no `Type Name Issues Odd` header): each wizard field is an independent
214
+ // panel, mounted alone and disposed before the next opens. Sequential
215
+ // data flow is unchanged (the wizard keeps every kept value); only the
216
+ // grouping chrome is gone.
217
+ // Navigation rows: there is deliberately NO selectable Back row and NO
218
+ // Confirm row — the host has no non-selectable row, so any rendered row
219
+ // would itself be pickable. Enter confirms the step (empty is valid)
220
+ // and Space toggles the focused checkbox. Tab is reserved EXCLUSIVELY
221
+ // for Back (previous wizard field, values kept): it never switches
222
+ // focus and is never overloaded. Because Space also types multi-word
223
+ // queries, the picker tracks an explicit focus zone — the search field
224
+ // or the checkbox list.
225
+
226
+ // ONE reusable multi-select as a STANDALONE panel: a title, a search
227
+ // box at the top, paginated data rows, several rows selectable — and
228
+ // nothing else. No cross-field chips/tabs/counters, no selectable Back
229
+ // row, no Confirm row.
230
+ // `items` are `{ id, label }` rows; `filterFn(items, query)` narrows
231
+ // them; `initialSelected` restores the ids picked on an earlier pass
232
+ // (Tab Back preserves them); `getId`/`getLabel` default to those fields.
233
+ // Keys (LIST mode): T opens the dedicated search field at the top;
234
+ // up/down move in the list (following across pages); left/right (or
235
+ // PageUp/PageDown) turn item pages; Space toggles the focused
236
+ // checkbox; Enter confirms with the selected ids (empty is valid);
237
+ // Tab goes Back to the previous wizard step; Esc cancels the whole
238
+ // step; other printables are ignored. SEARCH mode: typing (T and Space
239
+ // included) filters live; Backspace edits; left/right belong to the
240
+ // text caret and never page; Enter or Esc leaves search for the list
241
+ // keeping the filter (Enter never confirms and Esc never cancels
242
+ // there); up/down leaves for the list and moves.
243
+ // `done({ selected })` on Enter, `done({ back: true })` on Back,
244
+ // `done(null)` on cancel — so the wizard never hangs on an unresolved
245
+ // prompt and never double-resolves. The view clips to the live
246
+ // terminal height and truncates rows to the width; hostile dimensions
247
+ // render nothing.
248
+ function createMultiSelect({ title, items, filterFn, getId, getLabel, searchPlaceholder, pageSize, helpers, hostTui, theme, rowsAvailable, initialSelected, initialQuery, initialPage, initialCursor, done }) {
249
+ const rows = Array.isArray(items) ? items.slice() : [];
250
+ const match = typeof filterFn === 'function' ? filterFn : (list) => (Array.isArray(list) ? list.slice() : []);
251
+ const idOf = typeof getId === 'function' ? getId : (item) => item && item.id;
252
+ const labelOf = typeof getLabel === 'function' ? getLabel : (item) => String((item && item.label) ?? '');
253
+ const size = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : MULTI_SELECT_PAGE_SIZE_DEFAULT;
254
+ const rawFinish = typeof done === 'function' ? done : () => {};
255
+ // Exactly-once completion: the host disposes on done, but stray input
256
+ // after it must neither re-resolve nor throw — an unresolved or
257
+ // double-resolved prompt is the freeze.
258
+ let settled = false;
259
+ const finish = (value) => {
260
+ if (settled) return;
261
+ settled = true;
262
+ rawFinish(value);
263
+ };
264
+
265
+ const state = {
266
+ // A revisit restores the previous pass exactly: the kept filter,
267
+ // page and cursor ride in alongside the kept selections, so Tab
268
+ // Back never loses filter text, paging or toggles.
269
+ query: typeof initialQuery === 'string' ? initialQuery : '',
270
+ page: Number.isInteger(initialPage) && initialPage > 0 ? initialPage : 0,
271
+ cursor: Number.isInteger(initialCursor) && initialCursor > 0 ? initialCursor : 0,
272
+ selected: Array.isArray(initialSelected) ? initialSelected.slice() : [],
273
+ // Dedicated search mode: 'list' drives the checkboxes, 'search'
274
+ // edits the top field. T/t in list mode opens the field (keeping
275
+ // any existing query for editing); Enter or Esc in search mode
276
+ // leaves it for the list while RETAINING the filter — Enter there
277
+ // never confirms the step and Esc there never cancels it. Arrowing
278
+ // focuses the list; nothing else switches implicitly, and Tab is
279
+ // always Back, never a mode switch.
280
+ mode: 'list',
281
+ // Explicit focus zone mirrors the mode ('search' types, Space
282
+ // included; 'list' toggles with Space).
283
+ focus: 'list',
284
+ };
285
+ // A restored page without a cursor starts at that page's first row,
286
+ // so the derived page below lands where the echo left it.
287
+ if (!(Number.isInteger(initialCursor) && initialCursor > 0) && state.page > 0) {
288
+ state.cursor = state.page * size;
289
+ }
290
+
291
+ function filtered() {
292
+ try {
293
+ const out = match(rows, state.query);
294
+ return Array.isArray(out) ? out : [];
295
+ } catch {
296
+ // A throwing filter never blanks the list.
297
+ return rows.slice();
298
+ }
299
+ }
300
+
301
+ function pageView() {
302
+ const list = filtered();
303
+ const pageCount = Math.max(1, Math.ceil(list.length / size));
304
+ // A restored cursor implies its page: the echo always pairs them,
305
+ // so a revisit lands on the same rows even if the order drifted.
306
+ const page = list.length === 0 ? 0 : Math.min(Math.max(0, Math.floor(Math.min(Math.max(0, state.cursor), list.length - 1) / size)), pageCount - 1);
307
+ const pageItems = list.slice(page * size, page * size + size);
308
+ // The cursor roams DATA rows only (0..list.length-1, clamped).
309
+ // There is no Back row and no Confirm row: Enter always confirms,
310
+ // Tab alone goes back, even on an empty filter.
311
+ const cursor = list.length === 0 ? 0 : Math.min(Math.max(0, state.cursor), list.length - 1);
312
+ return { list, pageCount, page, pageItems, cursor };
313
+ }
314
+
315
+ // The revisit echo: the current filter, page and cursor travel with
316
+ // every resolution (confirm AND Back), so the wizard can restore this
317
+ // exact pass when the user returns. Selections ride top-level next to
318
+ // them, never hidden inside.
319
+ function snapshot() {
320
+ const view = pageView();
321
+ return { query: state.query, page: view.page, cursor: view.cursor };
322
+ }
323
+
324
+ function availableRows() {
325
+ try {
326
+ if (typeof rowsAvailable === 'function') {
327
+ const n = Math.floor(rowsAvailable());
328
+ if (Number.isFinite(n) && n > 0) return Math.min(MULTI_SELECT_MAX_HEIGHT, n);
329
+ }
330
+ } catch {
331
+ // A hostile rows source never breaks the picker.
332
+ }
333
+ return MULTI_SELECT_MAX_HEIGHT;
334
+ }
335
+
336
+ function requestPaint() {
337
+ try {
338
+ if (hostTui && typeof hostTui.requestRender === 'function') hostTui.requestRender();
339
+ } catch {
340
+ // A repaint failure never breaks navigation.
341
+ }
342
+ }
343
+
344
+ // Visual recipe (gentle-shell ask/choice questionnaire,
345
+ // lib/questionnaire/questionnaire-view.ts): thin border rules framing
346
+ // the component, a title line in the shared palette's TITLE role for THIS
347
+ // panel only, vertically aligned `[ ]`/`[x]` data rows with a distinct
348
+ // accent ❯
349
+ // cursor, a TOTAL + selection + page count of THIS list
350
+ // (`N total · k selected · page n/m`, or `f of N · k selected · page n/m`
351
+ // while a search filter narrows it — the filtered count never poses as
352
+ // the total), and a key footer whose KEYS are accent+bold over muted
353
+ // descriptions. The rules are drawn in-component: the questionnaire itself
354
+ // draws none (its frame is host dock chrome), so drawing them here is
355
+ // what guarantees the look inline. No two-pane customize layout, no
356
+ // cross-field chips/tabs/counters, no Back row, no Confirm row,
357
+ // no free-text custom row (the search box covers text).
358
+ function render(width) {
359
+ const w = typeof width === 'number' && Number.isFinite(width) ? Math.floor(width) : 0;
360
+ if (w <= 0) return [];
361
+ const height = availableRows();
362
+ if (!height) return [];
363
+ const view = pageView();
364
+ const lines = [];
365
+ lines.push(rule(theme, w));
366
+ lines.push(kindText(theme, 'title', String(title ?? 'Select')));
367
+ const searching = state.mode === 'search';
368
+ const searchFocused = searching || state.focus === 'search';
369
+ // S11: the box label is a LABEL (dim), the live query a VALUE (accent),
370
+ // the idle placeholder a DESCRIPTION (muted) and the `❯` focus marker the
371
+ // accent role — so the box never paints its own label like content.
372
+ const searchSegments = [
373
+ { kind: searchFocused ? 'accent' : null, text: searchFocused ? '❯ ' : ' ' },
374
+ { kind: 'label', text: 'Search: ' },
375
+ ];
376
+ if (state.query !== '') searchSegments.push({ kind: 'value', text: state.query });
377
+ else if (!searching) searchSegments.push({ kind: 'description', text: String(searchPlaceholder ?? '') });
378
+ if (searching) searchSegments.push({ kind: 'value', text: '▊' });
379
+ lines.push(composed(helpers, theme, searchSegments, w));
380
+ // Reserve the frame (two rules), the prompt, the search box and
381
+ // the two-line footer (counts plus keys, so narrow terminals keep
382
+ // every documented key): the data items take whatever height is left.
383
+ const room = Math.max(0, Math.min(view.pageItems.length, height - 9));
384
+ for (let i = 0; i < room; i += 1) {
385
+ const absolute = view.page * size + i;
386
+ const item = view.pageItems[i];
387
+ const id = idOf(item);
388
+ const marked = state.selected.some((s) => s === id);
389
+ const focused = state.focus === 'list' && absolute === view.cursor;
390
+ // S11: the focus chevron takes the accent role; the row's own text stays
391
+ // data, so a row label is never painted like chrome.
392
+ lines.push(composed(helpers, theme, [
393
+ { kind: focused ? 'accent' : null, text: focused ? '❯ ' : ' ' },
394
+ { kind: null, text: `[${marked ? 'x' : ' '}] ${labelOf(item)}` },
395
+ ], w));
396
+ }
397
+ if (view.list.length === 0) lines.push(composed(helpers, theme, [{ kind: 'description', text: '(no matches)' }], w));
398
+ const total = rows.length;
399
+ const shownCount = view.list.length;
400
+ const filtering = state.query.trim() !== '';
401
+ const counts = filtering
402
+ ? `${shownCount} of ${total} · ${state.selected.length} selected · page ${view.page + 1}/${view.pageCount}`
403
+ : `${total} total · ${state.selected.length} selected · page ${view.page + 1}/${view.pageCount}`;
404
+ lines.push(composed(helpers, theme, [{ kind: 'count', text: counts }], w));
405
+ lines.push(legendRow(helpers, theme, searching ? SEARCH_KEYS : SELECT_KEYS, w));
406
+ lines.push(rule(theme, w));
407
+ return lines.slice(0, Math.max(0, height));
408
+ }
409
+
410
+ function moveCursor(delta) {
411
+ const view = pageView();
412
+ const max = Math.max(0, view.list.length - 1);
413
+ const next = Math.min(Math.max(0, view.cursor + delta), max);
414
+ state.cursor = next;
415
+ state.mode = 'list';
416
+ state.focus = 'list';
417
+ if (view.list.length > 0) state.page = Math.floor(next / size);
418
+ }
419
+
420
+ function turnPage(delta) {
421
+ const view = pageView();
422
+ const next = Math.min(Math.max(0, view.page + delta), view.pageCount - 1);
423
+ state.page = next;
424
+ state.mode = 'list';
425
+ state.focus = 'list';
426
+ // The cursor follows onto the new page; it never parks on a
427
+ // navigation row because none exists.
428
+ if (view.cursor <= view.list.length - 1 && view.list.length > 0) {
429
+ const start = next * size;
430
+ const end = Math.min(start + size - 1, view.list.length - 1);
431
+ if (state.cursor < start || state.cursor > end) state.cursor = start;
432
+ }
433
+ }
434
+
435
+ // Space in list focus toggles the focused data checkbox. There is no
436
+ // navigation row, so Space never leaves the list and never completes.
437
+ function toggleFocused() {
438
+ const view = pageView();
439
+ const item = view.list[view.cursor];
440
+ if (!item) return false;
441
+ const id = idOf(item);
442
+ const at = state.selected.findIndex((s) => s === id);
443
+ if (at >= 0) state.selected.splice(at, 1);
444
+ else state.selected.push(id);
445
+ return false;
446
+ }
447
+
448
+ function handleInput(data) {
449
+ if (settled) return;
450
+ if (isKeyRelease(helpers, data)) return;
451
+ // Tab is Back in EVERY mode, exclusively: the previous wizard field
452
+ // with the kept values. Selections and the list position echo along,
453
+ // so the revisit restores them. Tab never switches modes and never
454
+ // toggles, so search mode can never trap the user.
455
+ if (keyText(helpers, data, ['tab'])) {
456
+ finish({ back: true, selected: state.selected.slice(), uiState: snapshot() });
457
+ requestPaint();
458
+ return;
459
+ }
460
+ // SEARCH mode: a dedicated editing field at the top. Typing (T
461
+ // included — it is normal text here) updates the filtered list
462
+ // live; Space and LEFT/RIGHT are normal text/caret keys, never
463
+ // toggle/page. Enter or Esc leaves search for the list while the
464
+ // filter stays on: Enter here never confirms the step and Esc here
465
+ // never cancels it. ↑/↓ also leave for the list and move there.
466
+ if (state.mode === 'search') {
467
+ if (keyText(helpers, data, ['escape', 'ctrl+c'])) {
468
+ state.mode = 'list';
469
+ state.focus = 'list';
470
+ requestPaint();
471
+ return;
472
+ }
473
+ if (keyText(helpers, data, ['enter', 'return'])) {
474
+ state.mode = 'list';
475
+ state.focus = 'list';
476
+ requestPaint();
477
+ return;
478
+ }
479
+ if (keyText(helpers, data, ['up'])) {
480
+ moveCursor(-1);
481
+ requestPaint();
482
+ return;
483
+ }
484
+ if (keyText(helpers, data, ['down'])) {
485
+ moveCursor(1);
486
+ requestPaint();
487
+ return;
488
+ }
489
+ // S4/S12: while this field is active, Delete clears the whole query in
490
+ // one keystroke and Backspace keeps deleting one character. Neither
491
+ // leaves the search field.
492
+ if (keyText(helpers, data, ['backspace'])) {
493
+ if (state.query.length > 0) {
494
+ state.query = state.query.slice(0, -1);
495
+ state.page = 0;
496
+ state.cursor = 0;
497
+ requestPaint();
498
+ }
499
+ return;
500
+ }
501
+ if (keyText(helpers, data, ['delete'])) {
502
+ if (state.query.length > 0) {
503
+ state.query = '';
504
+ state.page = 0;
505
+ state.cursor = 0;
506
+ requestPaint();
507
+ }
508
+ return;
509
+ }
510
+ if (data === ' ' || keyText(helpers, data, ['space'])) {
511
+ state.query += ' ';
512
+ state.page = 0;
513
+ state.cursor = 0;
514
+ requestPaint();
515
+ return;
516
+ }
517
+ // LEFT/RIGHT (and paging keys) belong to the text caret here:
518
+ // ignored, never paginating, never leaving the mode.
519
+ if (keyText(helpers, data, ['left', 'right', 'pageup', 'pagedown'])) return;
520
+ const typed = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
521
+ if (typeof typed === 'string' && [...typed].length === 1) {
522
+ const ch = typed;
523
+ if (ch >= ' ' && ch !== '\x7f') {
524
+ state.query += ch;
525
+ state.page = 0;
526
+ state.cursor = 0;
527
+ requestPaint();
528
+ }
529
+ }
530
+ return;
531
+ }
532
+ if (keyText(helpers, data, ['escape', 'ctrl+c'])) {
533
+ finish(null);
534
+ return;
535
+ }
536
+ // Enter CONFIRMS the panel with the selected ids (empty is valid),
537
+ // echoing the kept filter and position for a later revisit.
538
+ // No Back row exists: every Enter here confirms.
539
+ if (keyText(helpers, data, ['enter', 'return'])) {
540
+ finish({ selected: state.selected.slice(), uiState: snapshot() });
541
+ return;
542
+ }
543
+ if (keyText(helpers, data, ['up'])) {
544
+ moveCursor(-1);
545
+ requestPaint();
546
+ return;
547
+ }
548
+ if (keyText(helpers, data, ['down'])) {
549
+ moveCursor(1);
550
+ requestPaint();
551
+ return;
552
+ }
553
+ // LEFT/RIGHT (or PageUp/PageDown) turn item pages in list mode.
554
+ // Selections live outside the filter and the page, so they survive
555
+ // page changes; boundaries clamp safely.
556
+ if (keyText(helpers, data, ['left', 'pageup'])) {
557
+ turnPage(-1);
558
+ requestPaint();
559
+ return;
560
+ }
561
+ if (keyText(helpers, data, ['right', 'pagedown'])) {
562
+ turnPage(1);
563
+ requestPaint();
564
+ return;
565
+ }
566
+ // Space toggles the focused data checkbox in list mode.
567
+ if (data === ' ' || keyText(helpers, data, ['space'])) {
568
+ toggleFocused();
569
+ requestPaint();
570
+ return;
571
+ }
572
+ // T/t opens the dedicated search field (keeping any existing
573
+ // query for editing). Every other printable is ignored in list
574
+ // mode: filtering starts from T, never by accident.
575
+ if (data === 't' || data === 'T') {
576
+ state.mode = 'search';
577
+ state.focus = 'search';
578
+ requestPaint();
579
+ return;
580
+ }
581
+ if (keyText(helpers, data, ['backspace', 'delete', '\x7f'])) {
582
+ if (state.query.length > 0) {
583
+ state.query = state.query.slice(0, -1);
584
+ state.page = 0;
585
+ state.cursor = 0;
586
+ state.mode = 'search';
587
+ state.focus = 'search';
588
+ requestPaint();
589
+ }
590
+ return;
591
+ }
592
+ }
593
+
594
+ function invalidate() {}
595
+ return { render, handleInput, invalidate, state };
596
+ }
597
+
598
+ // ONE inline single-line text input for the wizard's name field as a
599
+ // STANDALONE panel: only its own title, its own value line and its own
600
+ // key footer — never cross-field chips/tabs/counters. The host `input`
601
+ // primitive carries no Back affordance, so Back on this field needs a
602
+ // real component here (never an invented host-metadata API). Same
603
+ // questionnaire visual as the multi-select (border rules, accent+bold prompt,
604
+ // key/description footer), same inline dock placement through `ctx.ui.custom`
605
+ // with no overlay options. Keys:
606
+ // typing appends (spaces included — change names are free text),
607
+ // Backspace deletes, Enter submits the value (empty stays empty; the
608
+ // caller decides keep-vs-refuse), Tab goes Back with `{back:true}`
609
+ // keeping the typed value, Esc cancels with `done(null)`. Arrows do
610
+ // nothing (single line, no caret to steal). Completion fires exactly
611
+ // once; hostile dimensions render nothing.
612
+ function createTextInput({ title, placeholder, initial, helpers, hostTui, theme, rowsAvailable, done }) {
613
+ // `placeholder` is accepted for call-site compatibility but NEVER
614
+ // rendered inside the input line: the field starts truly empty and
615
+ // only the user's actual typed value (or the kept value on Back)
616
+ // ever shows. Rendering the example as ghost text made it look like a
617
+ // real value and parked the cursor after it.
618
+ void placeholder;
619
+ const rawFinish = typeof done === 'function' ? done : () => {};
620
+ let settled = false;
621
+ const finish = (value) => {
622
+ if (settled) return;
623
+ settled = true;
624
+ rawFinish(value);
625
+ };
626
+ const state = { value: typeof initial === 'string' ? initial : '' };
627
+ // The real Pi cursor marker, resolved once: the injected helpers first
628
+ // (the live pi-tui module in production), then a lazy package require
629
+ // (plain-node tests pass helpers:null). Empty when unreachable — the
630
+ // visual cursor cell still renders, only hardware positioning degrades.
631
+ let cursorMarker = '';
632
+ try {
633
+ if (helpers && typeof helpers.CURSOR_MARKER === 'string') cursorMarker = helpers.CURSOR_MARKER;
634
+ } catch {
635
+ cursorMarker = '';
636
+ }
637
+ if (cursorMarker === '') {
638
+ try {
639
+ const tui = require('@earendil-works/pi-tui');
640
+ if (tui && typeof tui.CURSOR_MARKER === 'string') cursorMarker = tui.CURSOR_MARKER;
641
+ } catch {
642
+ cursorMarker = '';
643
+ }
644
+ }
645
+
646
+ function availableRows() {
647
+ try {
648
+ if (typeof rowsAvailable === 'function') {
649
+ const n = Math.floor(rowsAvailable());
650
+ if (Number.isFinite(n) && n > 0) return Math.min(MULTI_SELECT_MAX_HEIGHT, n);
651
+ }
652
+ } catch {
653
+ // A hostile rows source never breaks the input.
654
+ }
655
+ return MULTI_SELECT_MAX_HEIGHT;
656
+ }
657
+
658
+ function requestPaint() {
659
+ try {
660
+ if (hostTui && typeof hostTui.requestRender === 'function') hostTui.requestRender();
661
+ } catch {
662
+ // A repaint failure never breaks typing.
663
+ }
664
+ }
665
+
666
+ function render(width) {
667
+ const w = typeof width === 'number' && Number.isFinite(width) ? Math.floor(width) : 0;
668
+ if (w <= 0) return [];
669
+ const height = availableRows();
670
+ if (!height) return [];
671
+ const lines = [];
672
+ lines.push(rule(theme, w));
673
+ lines.push(kindText(theme, 'title', String(title ?? 'Write')));
674
+ // The input line carries ONLY the actual value plus the cursor: an
675
+ // empty field renders as just the prompt plus cursor, never example
676
+ // text. CURSOR_MARKER (the real Pi contract, zero-width under
677
+ // visibleWidth/truncateToWidth) sits immediately before the visual
678
+ // cursor cell so the host positions the hardware cursor after the
679
+ // actual value — never after a placeholder. Single-line
680
+ // append-only editing means the end is the only correct position.
681
+ // S11/T9: the `❯` prompt is the accent focus marker and what the user typed
682
+ // is a live VALUE (the normal text role), so the field never paints its own
683
+ // input like chrome and the typed value never borrows the button accent.
684
+ lines.push(composed(helpers, theme, [
685
+ { kind: 'accent', text: '❯ ' },
686
+ { kind: 'value', text: state.value },
687
+ { kind: null, text: `${cursorMarker}▊` },
688
+ ], w));
689
+ lines.push(legendRow(helpers, theme, WRITE_KEYS, w));
690
+ lines.push(rule(theme, w));
691
+ return lines.slice(0, Math.max(0, height));
692
+ }
693
+
694
+ function handleInput(data) {
695
+ if (settled) return;
696
+ if (isKeyRelease(helpers, data)) return;
697
+ if (keyText(helpers, data, ['escape', 'ctrl+c'])) {
698
+ finish(null);
699
+ return;
700
+ }
701
+ // Tab is Back, exclusively — the typed value travels along in the
702
+ // payload so the wizard keeps exactly what the user typed.
703
+ if (keyText(helpers, data, ['tab'])) {
704
+ finish({ back: true, value: state.value });
705
+ return;
706
+ }
707
+ if (keyText(helpers, data, ['enter', 'return'])) {
708
+ finish(state.value);
709
+ return;
710
+ }
711
+ if (keyText(helpers, data, ['backspace', 'delete', '\x7f'])) {
712
+ if (state.value.length > 0) {
713
+ state.value = state.value.slice(0, -1);
714
+ requestPaint();
715
+ }
716
+ return;
717
+ }
718
+ // Arrows (and paging keys) do nothing on a single line without a
719
+ // caret: they must neither toggle, page, nor type.
720
+ if (keyText(helpers, data, ['up', 'down', 'left', 'right', 'pageup', 'pagedown'])) return;
721
+ const raw = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
722
+ if (typeof raw === 'string' && [...raw].length === 1) {
723
+ const ch = raw;
724
+ if (ch >= ' ' && ch !== '\x7f') {
725
+ state.value += ch;
726
+ requestPaint();
727
+ }
728
+ }
729
+ }
730
+
731
+ function invalidate() {}
732
+ return { render, handleInput, invalidate, state };
733
+ }
734
+
735
+ // ONE inline single-select for the wizard's type field as a STANDALONE
736
+ // panel: only its own title, its eleven data rows and its own key
737
+ // footer — never cross-field chips/tabs/counters, never a selectable
738
+ // Back or Confirm row. The built-in `ctx.ui.select` cannot return a Tab
739
+ // Back (it only resolves picked strings), so this smallest custom
740
+ // inline selector carries the consistent Tab raw-key handling the other
741
+ // panels already have. `items` are option strings (or `{ id, label }`
742
+ // rows via `getId`/`getLabel`); `initialSelected` restores the kept type
743
+ // on the return trip by moving the cursor there. Keys: Up/Down move
744
+ // over data rows only (clamped, no phantom row after the last item),
745
+ // Enter confirms the focused data row, Tab goes Back with `{ back: true }`
746
+ // keeping the kept value, Esc cancels with `done(null)`. Completion
747
+ // fires exactly once; hostile dimensions render nothing.
748
+ // Warp-selector extension (no unrelated redesign): `canDelete` is an
749
+ // opt-in predicate `(index, item) => boolean` — when it reports the
750
+ // focused row deletable, raw Ctrl+D (`\\x04` via the installed matcher
751
+ // or the raw fallback) finishes with `{ delete: true, index, value }`
752
+ // and the footer names the actual `Ctrl+D delete` key only on that row;
753
+ // on any other row Ctrl+D is a safe no-op. `confirmLabel` renames the
754
+ // Enter verb (`warp` for the checkout selector, `confirm` everywhere
755
+ // else) and `showTab: false` hides the Tab hint and ignores Tab (the
756
+ // top-level warp selector has no previous field). Defaults preserve the
757
+ // type panel byte-identically.
758
+ function createSingleSelect({ title, items, getId, getLabel, helpers, hostTui, theme, rowsAvailable, initialSelected, canDelete, confirmLabel, showTab, done }) {
759
+ const rows = Array.isArray(items) ? items.slice() : [];
760
+ const idOf = typeof getId === 'function' ? getId : (item) => (item && typeof item === 'object' && 'id' in item ? item.id : String(item));
761
+ const labelOf = typeof getLabel === 'function' ? getLabel : (item) => String((item && typeof item === 'object' && 'label' in item ? item.label : item) ?? '');
762
+ const rawFinish = typeof done === 'function' ? done : () => {};
763
+ let settled = false;
764
+ const finish = (value) => {
765
+ if (settled) return;
766
+ settled = true;
767
+ rawFinish(value);
768
+ };
769
+ const state = { cursor: 0 };
770
+ if (initialSelected !== undefined && initialSelected !== null) {
771
+ const at = rows.findIndex((item) => idOf(item) === initialSelected);
772
+ if (at >= 0) state.cursor = at;
773
+ }
774
+ function availableRows() {
775
+ try {
776
+ if (typeof rowsAvailable === 'function') {
777
+ const n = Math.floor(rowsAvailable());
778
+ if (Number.isFinite(n) && n > 0) return Math.min(MULTI_SELECT_MAX_HEIGHT, n);
779
+ }
780
+ } catch {
781
+ // A hostile rows source never breaks the selector.
782
+ }
783
+ return MULTI_SELECT_MAX_HEIGHT;
784
+ }
785
+ function requestPaint() {
786
+ try {
787
+ if (hostTui && typeof hostTui.requestRender === 'function') hostTui.requestRender();
788
+ } catch {
789
+ // A repaint failure never breaks navigation.
790
+ }
791
+ }
792
+ function render(width) {
793
+ const w = typeof width === 'number' && Number.isFinite(width) ? Math.floor(width) : 0;
794
+ if (w <= 0) return [];
795
+ const height = availableRows();
796
+ if (!height) return [];
797
+ const lines = [];
798
+ lines.push(rule(theme, w));
799
+ lines.push(kindText(theme, 'title', String(title ?? 'Select')));
800
+ const room = Math.max(0, Math.min(rows.length, height - 4));
801
+ for (let i = 0; i < room; i += 1) {
802
+ const focused = i === state.cursor;
803
+ // S11: the focus chevron takes the accent role; the option text stays
804
+ // data, so a row is never painted like chrome.
805
+ lines.push(composed(helpers, theme, [
806
+ { kind: focused ? 'accent' : null, text: focused ? '❯ ' : ' ' },
807
+ { kind: null, text: ` ${labelOf(rows[i])}` },
808
+ ], w));
809
+ }
810
+ // Warp delete affordance: the footer names the actual Ctrl+D key ONLY
811
+ // when the focused row is deletable. A hostile predicate never breaks
812
+ // the render and never enables a delete.
813
+ let deletableFocused = false;
814
+ if (typeof canDelete === 'function') {
815
+ try {
816
+ deletableFocused = canDelete(state.cursor, rows[state.cursor]) === true;
817
+ } catch {
818
+ deletableFocused = false;
819
+ }
820
+ }
821
+ const verb = typeof confirmLabel === 'string' && confirmLabel.length > 0 ? confirmLabel : 'confirm';
822
+ // S11: each entry's `key` is the exact key spelling it starts with, so the
823
+ // compact spelling keeps the key role instead of degrading to words.
824
+ const parts = [
825
+ { key: '↑↓', text: '↑↓ move', short: '↑↓', priority: 60 },
826
+ { key: 'Enter', text: `Enter ${verb}`, short: `Enter ${verb}`, priority: 90 },
827
+ ];
828
+ if (deletableFocused) parts.push({ key: 'Ctrl+D', text: 'Ctrl+D delete', short: 'Ctrl+D', priority: 70 });
829
+ if (showTab !== false) parts.push({ key: 'Tab', text: 'Tab back', short: 'Tab back', priority: 80 });
830
+ parts.push({ key: 'Esc', text: 'Esc cancel', short: 'Esc cancel', priority: 100 });
831
+ lines.push(legendRow(helpers, theme, parts, w));
832
+ lines.push(rule(theme, w));
833
+ return lines.slice(0, Math.max(0, height));
834
+ }
835
+ function handleInput(data) {
836
+ if (settled) return;
837
+ if (isKeyRelease(helpers, data)) return;
838
+ // Ctrl+D deletes the focused row ONLY when the opt-in predicate says
839
+ // so (warp selector: eligible worktree rows). On a protected row
840
+ // (principal, create) it is a safe no-op: the selector stays open on
841
+ // the same focus with nothing resolved.
842
+ if (keyText(helpers, data, ['ctrl+d'])) {
843
+ let deletable = false;
844
+ if (typeof canDelete === 'function') {
845
+ try {
846
+ deletable = canDelete(state.cursor, rows[state.cursor]) === true;
847
+ } catch {
848
+ deletable = false;
849
+ }
850
+ }
851
+ if (deletable) {
852
+ const item = rows[state.cursor];
853
+ finish({ delete: true, index: state.cursor, value: idOf(item) });
854
+ try {
855
+ if (hostTui && typeof hostTui.requestRender === 'function') hostTui.requestRender();
856
+ } catch {
857
+ // A repaint failure never breaks navigation.
858
+ }
859
+ }
860
+ return;
861
+ }
862
+ // Tab is Back, exclusively — the kept selection travels via the
863
+ // wizard's own memory, never a selectable row. The top-level warp
864
+ // selector passes showTab:false: Tab is then ignored (no previous
865
+ // field) instead of resolving a Back.
866
+ if (showTab === false && keyText(helpers, data, ['tab'])) return;
867
+ if (keyText(helpers, data, ['tab'])) {
868
+ finish({ back: true });
869
+ requestPaint();
870
+ return;
871
+ }
872
+ if (keyText(helpers, data, ['escape', 'ctrl+c'])) {
873
+ finish(null);
874
+ return;
875
+ }
876
+ if (keyText(helpers, data, ['enter', 'return'])) {
877
+ const item = rows[state.cursor];
878
+ if (item === undefined) {
879
+ finish(null);
880
+ return;
881
+ }
882
+ finish(idOf(item));
883
+ return;
884
+ }
885
+ if (keyText(helpers, data, ['up'])) {
886
+ state.cursor = Math.max(0, state.cursor - 1);
887
+ requestPaint();
888
+ return;
889
+ }
890
+ if (keyText(helpers, data, ['down'])) {
891
+ state.cursor = Math.min(Math.max(0, rows.length - 1), state.cursor + 1);
892
+ requestPaint();
893
+ return;
894
+ }
895
+ }
896
+ function invalidate() {}
897
+ return { render, handleInput, invalidate, state };
898
+ }
899
+
900
+ module.exports = {
901
+ MULTI_SELECT_PAGE_SIZE_DEFAULT,
902
+ paginate,
903
+ createMultiSelect,
904
+ createSingleSelect,
905
+ createTextInput,
906
+ };