pi-odd-worktree 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- 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
|
+
};
|