devflow-kit 2.0.1 → 2.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.
- package/CHANGELOG.md +22 -0
- package/dist/cli/agents-view/render.js +9 -42
- package/dist/cli/agents-view/terminal.js +29 -153
- package/dist/cli/commands/agents.js +11 -1
- package/dist/cli/commands/flags.js +501 -82
- package/dist/cli/commands/init-seed.js +60 -42
- package/dist/cli/commands/init.js +36 -79
- package/dist/cli/commands/proxy.js +42 -13
- package/dist/cli/commands/uninstall.js +2 -3
- package/dist/cli/flags-view/index.js +9 -0
- package/dist/cli/flags-view/render.js +271 -0
- package/dist/cli/flags-view/state.js +478 -0
- package/dist/cli/flags-view/terminal.js +63 -0
- package/dist/cli/tui/cells.js +47 -0
- package/dist/cli/tui/terminal.js +329 -0
- package/dist/cli.js +3 -2
- package/dist/core/ansi.js +84 -0
- package/dist/core/flags.js +938 -123
- package/dist/core/manifest.js +97 -13
- package/dist/core/teammate-mode-cleanup.js +2 -2
- package/dist/hud/colors.js +6 -70
- package/package.json +2 -2
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure keypress reducer for the devflow flags TUI.
|
|
3
|
+
*
|
|
4
|
+
* applies ADR-013: CLI-layer view module; consumes src/core/ imports only.
|
|
5
|
+
* applies ADR-016: one syntax, one semantic — value vocabulary.
|
|
6
|
+
* avoids PF-014: pure functions only — no process.exit(), no I/O.
|
|
7
|
+
* avoids PF-017: generic shell in tui/terminal.ts; this module is pure logic.
|
|
8
|
+
*
|
|
9
|
+
* viewMode GLUE RULE: view-mode's neutralValue ('default') maps to null in the TUI.
|
|
10
|
+
* `buildFlagRows` maps record value 'default' → null via `recordToTui` (core/flags.ts);
|
|
11
|
+
* `collectFlagRecord` maps null → 'default' via `tuiToRecord` (core/flags.ts).
|
|
12
|
+
* Number 0 is ACTIVE — null ≠ 0. Both functions live next to neutralValueOf, their
|
|
13
|
+
* definition dependency (PF-017 one-shared-definition corollary).
|
|
14
|
+
*
|
|
15
|
+
* Strict number parsing: leading/trailing whitespace and leading zeros are
|
|
16
|
+
* invalid ('007' → error, ' 8' → error). This rejects pathological inputs
|
|
17
|
+
* before they reach coerceFlagValue (applies PF-023).
|
|
18
|
+
*
|
|
19
|
+
* Buffer hard cap: BUFFER_MAX_LEN = 64 chars (paste-flood guard).
|
|
20
|
+
*
|
|
21
|
+
* allowUnset semantics:
|
|
22
|
+
* - boolean: false — no null stop, 'u' is noop
|
|
23
|
+
* - enum/number/string: true — 'u' sets null; enum with neutralValue includes
|
|
24
|
+
* null in the cycle as the first stop (round-trips through collectFlagRecord).
|
|
25
|
+
*
|
|
26
|
+
* FlagRow invariant: all rows are built by buildFlagRows from FLAG_REGISTRY.
|
|
27
|
+
* Every row.id maps to a known flag definition (row.def). collectFlagRecord and
|
|
28
|
+
* commitEdit rely on this — there is no unknown-flag fallback.
|
|
29
|
+
*/
|
|
30
|
+
import { FLAG_REGISTRY, defaultValueOf, coerceFlagValue, parseFlagValueInput, recordToTui, tuiToRecord, } from '../../core/flags.js';
|
|
31
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
32
|
+
/** Hard cap on edit buffer length — protects against paste floods. */
|
|
33
|
+
export const BUFFER_MAX_LEN = 64;
|
|
34
|
+
// ─── Viewport helpers ─────────────────────────────────────────────────────────
|
|
35
|
+
/** Adjust viewport offset so cursor stays visible. */
|
|
36
|
+
function adjustViewport(cursor, viewportOffset, viewportHeight, rowCount) {
|
|
37
|
+
if (viewportHeight <= 0 || rowCount === 0)
|
|
38
|
+
return 0;
|
|
39
|
+
let offset = viewportOffset;
|
|
40
|
+
if (cursor < offset)
|
|
41
|
+
offset = cursor;
|
|
42
|
+
if (cursor >= offset + viewportHeight)
|
|
43
|
+
offset = cursor - viewportHeight + 1;
|
|
44
|
+
const maxOffset = Math.max(0, rowCount - viewportHeight);
|
|
45
|
+
return Math.max(0, Math.min(offset, maxOffset));
|
|
46
|
+
}
|
|
47
|
+
// ─── Row building ─────────────────────────────────────────────────────────────
|
|
48
|
+
/** Compute the cycle stops for a flag in TUI coordinates. */
|
|
49
|
+
function buildStops(flag) {
|
|
50
|
+
switch (flag.kind) {
|
|
51
|
+
case 'boolean':
|
|
52
|
+
return [true, false];
|
|
53
|
+
case 'enum': {
|
|
54
|
+
if (flag.neutralValue !== undefined) {
|
|
55
|
+
// null is the TUI representation of neutralValue
|
|
56
|
+
const nonNeutral = flag.values.filter(v => v !== flag.neutralValue);
|
|
57
|
+
return [null, ...nonNeutral];
|
|
58
|
+
}
|
|
59
|
+
// No neutralValue: cycle over the declared values
|
|
60
|
+
return [...flag.values];
|
|
61
|
+
}
|
|
62
|
+
case 'number':
|
|
63
|
+
case 'string':
|
|
64
|
+
return []; // text edit mode
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Compute the devflow default in TUI coordinates. */
|
|
68
|
+
function buildDevflowDefault(flag) {
|
|
69
|
+
const base = defaultValueOf(flag); // single default-rule source (CONS-M2)
|
|
70
|
+
if (flag.kind === 'boolean')
|
|
71
|
+
return base;
|
|
72
|
+
if (base === null)
|
|
73
|
+
return null; // undefined defaultValue → null
|
|
74
|
+
// Apply the same neutralValue mapping used for record values
|
|
75
|
+
return recordToTui(flag, base);
|
|
76
|
+
}
|
|
77
|
+
/** Build the initial TUI value for a row from a FlagsRecord. */
|
|
78
|
+
function buildConfiguredValue(flag, record) {
|
|
79
|
+
const id = flag.id;
|
|
80
|
+
if (!(id in record)) {
|
|
81
|
+
// Key absent from record — fall back to devflow default
|
|
82
|
+
return buildDevflowDefault(flag);
|
|
83
|
+
}
|
|
84
|
+
const raw = record[id];
|
|
85
|
+
return recordToTui(flag, raw);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build the FlagRow array from FLAG_REGISTRY and an existing record.
|
|
89
|
+
*
|
|
90
|
+
* Row order matches FLAG_REGISTRY order. Every produced row embeds its registry
|
|
91
|
+
* definition as `row.def` — this is the FlagRow invariant: collectFlagRecord and
|
|
92
|
+
* commitEdit use row.def directly and assume all row ids are registry-derived.
|
|
93
|
+
* There is no unknown-flag fallback (ARCH-M4, CONS-S1).
|
|
94
|
+
*
|
|
95
|
+
* viewMode GLUE: record value 'default' → configuredValue null (via recordToTui).
|
|
96
|
+
* devflowDefault for view-mode = null (maps from neutralValue 'default').
|
|
97
|
+
*/
|
|
98
|
+
export function buildFlagRows(record) {
|
|
99
|
+
return FLAG_REGISTRY.map((flag) => {
|
|
100
|
+
const stops = buildStops(flag);
|
|
101
|
+
const allowUnset = flag.kind !== 'boolean';
|
|
102
|
+
const devflowDefault = buildDevflowDefault(flag);
|
|
103
|
+
const configuredValue = buildConfiguredValue(flag, record);
|
|
104
|
+
return {
|
|
105
|
+
id: flag.id,
|
|
106
|
+
label: flag.label,
|
|
107
|
+
hint: flag.hint,
|
|
108
|
+
blurb: flag.blurb,
|
|
109
|
+
def: flag,
|
|
110
|
+
kind: flag.kind,
|
|
111
|
+
stops,
|
|
112
|
+
allowUnset,
|
|
113
|
+
configuredValue,
|
|
114
|
+
originalValue: configuredValue,
|
|
115
|
+
devflowDefault,
|
|
116
|
+
recommended: flag.recommended,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Collect the current TUI row values back into a FlagsRecord.
|
|
122
|
+
*
|
|
123
|
+
* FlagRow invariant (see buildFlagRows): every row embeds its registry definition
|
|
124
|
+
* as row.def. There is no unknown-flag path — rows are always produced by
|
|
125
|
+
* buildFlagRows from FLAG_REGISTRY (ARCH-M4, CONS-S1).
|
|
126
|
+
*
|
|
127
|
+
* viewMode GLUE: null → neutralValue (e.g. 'default') for enum flags with neutralValue,
|
|
128
|
+
* via tuiToRecord (core/flags.ts). All other null values pass through as null.
|
|
129
|
+
*/
|
|
130
|
+
export function collectFlagRecord(rows) {
|
|
131
|
+
const record = {};
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
record[row.id] = tuiToRecord(row.def, row.configuredValue);
|
|
134
|
+
}
|
|
135
|
+
return record;
|
|
136
|
+
}
|
|
137
|
+
// ─── Cycle helpers ────────────────────────────────────────────────────────────
|
|
138
|
+
function cycleForward(stops, current) {
|
|
139
|
+
// Use === rather than Object.is: -0 vs 0 doesn't survive the JSON round-trip so the
|
|
140
|
+
// distinction is unreachable in practice. Keeps parity with render.ts dirty-detection (!==/!==).
|
|
141
|
+
const idx = stops.findIndex(s => s === current);
|
|
142
|
+
if (idx === -1)
|
|
143
|
+
return stops[0];
|
|
144
|
+
return stops[(idx + 1) % stops.length];
|
|
145
|
+
}
|
|
146
|
+
function cycleBackward(stops, current) {
|
|
147
|
+
// See cycleForward: === over Object.is for the same reason.
|
|
148
|
+
const idx = stops.findIndex(s => s === current);
|
|
149
|
+
if (idx === -1)
|
|
150
|
+
return stops[stops.length - 1];
|
|
151
|
+
return stops[(idx - 1 + stops.length) % stops.length];
|
|
152
|
+
}
|
|
153
|
+
/** Update a single row in the rows array (all other rows unchanged). */
|
|
154
|
+
function updateRow(rows, cursor, patch) {
|
|
155
|
+
return rows.map((r, i) => (i === cursor ? { ...r, ...patch } : r));
|
|
156
|
+
}
|
|
157
|
+
/** Move cursor by delta (+1 = down, -1 = up) and adjust the viewport. */
|
|
158
|
+
function move(state, delta) {
|
|
159
|
+
const n = state.rows.length;
|
|
160
|
+
const newCursor = Math.max(0, Math.min(n - 1, state.cursor + delta));
|
|
161
|
+
const newOffset = adjustViewport(newCursor, state.viewportOffset, state.viewportHeight, n);
|
|
162
|
+
if (newCursor === state.cursor && newOffset === state.viewportOffset) {
|
|
163
|
+
return { state, intent: 'none' };
|
|
164
|
+
}
|
|
165
|
+
return { state: { ...state, cursor: newCursor, viewportOffset: newOffset }, intent: 'none' };
|
|
166
|
+
}
|
|
167
|
+
/** Advance or retreat the cycle stop for a cycling row. */
|
|
168
|
+
function cycle(state, row, dir) {
|
|
169
|
+
const next = dir === 'forward'
|
|
170
|
+
? cycleForward(row.stops, row.configuredValue)
|
|
171
|
+
: cycleBackward(row.stops, row.configuredValue);
|
|
172
|
+
return {
|
|
173
|
+
state: { ...state, rows: updateRow(state.rows, state.cursor, { configuredValue: next }) },
|
|
174
|
+
intent: 'none',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// ─── Edit mode helpers ────────────────────────────────────────────────────────
|
|
178
|
+
/** Format a value as an edit buffer string. */
|
|
179
|
+
function valueToBuffer(value) {
|
|
180
|
+
if (value === null)
|
|
181
|
+
return '';
|
|
182
|
+
return String(value);
|
|
183
|
+
}
|
|
184
|
+
/** Enter edit mode for the current row — pre-fill buffer with current value. */
|
|
185
|
+
function enterEdit(state) {
|
|
186
|
+
const row = state.rows[state.cursor];
|
|
187
|
+
if (row.stops.length !== 0)
|
|
188
|
+
return state; // not a text row
|
|
189
|
+
const buffer = valueToBuffer(row.configuredValue);
|
|
190
|
+
return {
|
|
191
|
+
...state,
|
|
192
|
+
editing: { buffer, caret: buffer.length, error: null },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Commit the current edit buffer for a text row.
|
|
197
|
+
*
|
|
198
|
+
* Contract:
|
|
199
|
+
* - Empty buffer + allowUnset → commit null (unset)
|
|
200
|
+
* - Empty buffer + !allowUnset → error "Value is required"
|
|
201
|
+
* - For number/string flags: delegate to parseFlagValueInput (applies PF-023 —
|
|
202
|
+
* strict grammar enforced at the core sink, not per-caller). Error messages
|
|
203
|
+
* distinguish format failures (padded/hex/leading-zeros) from bounds failures.
|
|
204
|
+
* - parseFlagValueInput returns null on invalid input → stay editing + error
|
|
205
|
+
*/
|
|
206
|
+
function commitEdit(state) {
|
|
207
|
+
const { editing, rows, cursor } = state;
|
|
208
|
+
if (!editing)
|
|
209
|
+
return state;
|
|
210
|
+
const row = rows[cursor];
|
|
211
|
+
// row.def is always defined — FlagRow invariant: all rows are built from FLAG_REGISTRY (ARCH-M4).
|
|
212
|
+
const flagDef = row.def;
|
|
213
|
+
if (flagDef.kind === 'boolean' || flagDef.kind === 'enum')
|
|
214
|
+
return state;
|
|
215
|
+
const buf = editing.buffer;
|
|
216
|
+
// Empty buffer
|
|
217
|
+
if (buf === '') {
|
|
218
|
+
if (row.allowUnset) {
|
|
219
|
+
// Commit as null (unset)
|
|
220
|
+
return {
|
|
221
|
+
...state,
|
|
222
|
+
editing: null,
|
|
223
|
+
rows: updateRow(rows, cursor, { configuredValue: null }),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
return {
|
|
228
|
+
...state,
|
|
229
|
+
editing: { ...editing, error: 'Value is required' },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Number flag: parseFlagValueInput enforces strict decimal grammar (avoids PF-023).
|
|
234
|
+
// Provide specific error messages to distinguish format from bounds failures.
|
|
235
|
+
if (flagDef.kind === 'number') {
|
|
236
|
+
if (buf !== buf.trim()) {
|
|
237
|
+
return { ...state, editing: { ...editing, error: 'No leading or trailing spaces allowed' } };
|
|
238
|
+
}
|
|
239
|
+
if (/^[+-]?0\d/.test(buf)) {
|
|
240
|
+
return { ...state, editing: { ...editing, error: 'Leading zeros are not allowed (e.g. use 7, not 007)' } };
|
|
241
|
+
}
|
|
242
|
+
const coerced = parseFlagValueInput(flagDef, buf);
|
|
243
|
+
if (coerced === null) {
|
|
244
|
+
const parts = [];
|
|
245
|
+
if (flagDef.min !== undefined)
|
|
246
|
+
parts.push(`min ${flagDef.min}`);
|
|
247
|
+
if (flagDef.max !== undefined)
|
|
248
|
+
parts.push(`max ${flagDef.max}`);
|
|
249
|
+
if (flagDef.integer)
|
|
250
|
+
parts.push('must be an integer');
|
|
251
|
+
return {
|
|
252
|
+
...state,
|
|
253
|
+
editing: { ...editing, error: parts.length ? `Invalid value (${parts.join(', ')})` : 'Must be a valid number' },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
...state,
|
|
258
|
+
editing: null,
|
|
259
|
+
rows: updateRow(rows, cursor, { configuredValue: coerced }),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// String flag: coerceFlagValue handles maxLength and control-char rejection.
|
|
263
|
+
// At this point flagDef.kind is 'string' (boolean and enum are guarded above,
|
|
264
|
+
// number returned in its own branch).
|
|
265
|
+
const coerced = coerceFlagValue(flagDef, buf);
|
|
266
|
+
if (coerced === null) {
|
|
267
|
+
const msg = flagDef.maxLength !== undefined ? `Max ${flagDef.maxLength} characters` : 'Invalid value';
|
|
268
|
+
return { ...state, editing: { ...editing, error: msg } };
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
...state,
|
|
272
|
+
editing: null,
|
|
273
|
+
rows: updateRow(rows, cursor, { configuredValue: coerced }),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* ASCII control characters (C0 + DEL). These must never enter the edit buffer:
|
|
278
|
+
* - coerceFlagValue rejects any string containing them, so a buffer holding one
|
|
279
|
+
* can only ever fail to commit — and the failure message would name length,
|
|
280
|
+
* not the real cause.
|
|
281
|
+
* - renderBuffer strips them for display, so the rendered string and the buffer
|
|
282
|
+
* would have different lengths and the caret would land on the wrong character.
|
|
283
|
+
* normalizeKey passes ctrl-modified keys through as their raw control byte
|
|
284
|
+
* (e.g. ctrl-a → \x01), so this is reachable by ordinary typing, not just hostile input.
|
|
285
|
+
*/
|
|
286
|
+
const CONTROL_CHAR = /[\x00-\x1f\x7f]/;
|
|
287
|
+
/** Insert a printable character at the caret position (bounded by BUFFER_MAX_LEN). */
|
|
288
|
+
function insertChar(editing, char) {
|
|
289
|
+
if (editing.buffer.length >= BUFFER_MAX_LEN)
|
|
290
|
+
return editing;
|
|
291
|
+
if (CONTROL_CHAR.test(char))
|
|
292
|
+
return editing; // never buffer an uncommittable char
|
|
293
|
+
const { buffer, caret } = editing;
|
|
294
|
+
const next = buffer.slice(0, caret) + char + buffer.slice(caret);
|
|
295
|
+
return { buffer: next, caret: caret + 1, error: null };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Handle a key while in edit mode. Returns the new state.
|
|
299
|
+
*
|
|
300
|
+
* `editing` is passed as a parameter so callers can pass the already-narrowed
|
|
301
|
+
* `EditState` value (callers guard `state.editing !== null` before calling),
|
|
302
|
+
* eliminating the non-null assertion (TS-S1).
|
|
303
|
+
*/
|
|
304
|
+
function reduceEditMode(state, key, editing) {
|
|
305
|
+
switch (key) {
|
|
306
|
+
case 'enter':
|
|
307
|
+
return commitEdit(state);
|
|
308
|
+
case 'escape':
|
|
309
|
+
// Discard edit — restore without changing configuredValue
|
|
310
|
+
return { ...state, editing: null };
|
|
311
|
+
case 'backspace': {
|
|
312
|
+
if (editing.caret === 0)
|
|
313
|
+
return { ...state, editing: { ...editing, error: null } };
|
|
314
|
+
const buf = editing.buffer;
|
|
315
|
+
const next = buf.slice(0, editing.caret - 1) + buf.slice(editing.caret);
|
|
316
|
+
return {
|
|
317
|
+
...state,
|
|
318
|
+
editing: { buffer: next, caret: editing.caret - 1, error: null },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
case 'delete': {
|
|
322
|
+
const buf = editing.buffer;
|
|
323
|
+
if (editing.caret >= buf.length)
|
|
324
|
+
return { ...state, editing: { ...editing, error: null } };
|
|
325
|
+
const next = buf.slice(0, editing.caret) + buf.slice(editing.caret + 1);
|
|
326
|
+
return {
|
|
327
|
+
...state,
|
|
328
|
+
editing: { buffer: next, caret: editing.caret, error: null },
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
case 'home':
|
|
332
|
+
return { ...state, editing: { ...editing, caret: 0, error: null } };
|
|
333
|
+
case 'end':
|
|
334
|
+
return {
|
|
335
|
+
...state,
|
|
336
|
+
editing: { ...editing, caret: editing.buffer.length, error: null },
|
|
337
|
+
};
|
|
338
|
+
case 'left': {
|
|
339
|
+
const next = Math.max(0, editing.caret - 1);
|
|
340
|
+
return { ...state, editing: { ...editing, caret: next, error: null } };
|
|
341
|
+
}
|
|
342
|
+
case 'right': {
|
|
343
|
+
const next = Math.min(editing.buffer.length, editing.caret + 1);
|
|
344
|
+
return { ...state, editing: { ...editing, caret: next, error: null } };
|
|
345
|
+
}
|
|
346
|
+
// up/down: ignored while editing; j/k intentionally NOT listed here so they
|
|
347
|
+
// insert literally (spec: literal q d u j k — default-model and spellcheck
|
|
348
|
+
// may contain 'j'/'k' as part of a command or path).
|
|
349
|
+
case 'up':
|
|
350
|
+
case 'down':
|
|
351
|
+
return state;
|
|
352
|
+
// normalizeKey maps the space bar to the NAME 'space', not to ' '. Without this
|
|
353
|
+
// case the default branch below drops it (5 chars, not 1), so a space could never
|
|
354
|
+
// be typed — silently. spellcheck's whole purpose is to hold a shell command
|
|
355
|
+
// ("aspell list"), and default-model likewise; both were unenterable as multi-word
|
|
356
|
+
// values, with no error and no visual cue that the key had been ignored.
|
|
357
|
+
case 'space':
|
|
358
|
+
return { ...state, editing: insertChar(editing, ' ') };
|
|
359
|
+
default: {
|
|
360
|
+
// Printable character: single char, not ctrl
|
|
361
|
+
if (key.length === 1) {
|
|
362
|
+
return { ...state, editing: insertChar(editing, key) };
|
|
363
|
+
}
|
|
364
|
+
return state;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Apply a new viewport height (terminal resize) and re-clamp the scroll offset.
|
|
370
|
+
*
|
|
371
|
+
* adjustViewport otherwise only runs on up/down, so a resize alone changed the
|
|
372
|
+
* height without moving the offset: shrinking a tall terminal with the cursor
|
|
373
|
+
* below the new fold left the cursor outside the visible slice, and renderFrame
|
|
374
|
+
* draws the `❯` marker only for rows inside that slice — so the selection marker
|
|
375
|
+
* vanished entirely until the user pressed an arrow key.
|
|
376
|
+
*
|
|
377
|
+
* Pure — returns a new state.
|
|
378
|
+
*/
|
|
379
|
+
export function resizeViewport(state, viewportHeight) {
|
|
380
|
+
return {
|
|
381
|
+
...state,
|
|
382
|
+
viewportHeight,
|
|
383
|
+
viewportOffset: adjustViewport(state.cursor, state.viewportOffset, viewportHeight, state.rows.length),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// ─── reduce ───────────────────────────────────────────────────────────────────
|
|
387
|
+
/**
|
|
388
|
+
* Pure keypress reducer.
|
|
389
|
+
*
|
|
390
|
+
* Key dispatch:
|
|
391
|
+
* Editing mode (editing !== null): all keys handled by reduceEditMode.
|
|
392
|
+
* - enter → commitEdit
|
|
393
|
+
* - escape → discard edit (exit edit mode, value unchanged)
|
|
394
|
+
* - backspace/delete/home/end/left/right → buffer manipulation
|
|
395
|
+
* - up/down/j/k → noop (navigation suppressed while editing)
|
|
396
|
+
* - single char → insertChar (bounded at BUFFER_MAX_LEN)
|
|
397
|
+
*
|
|
398
|
+
* Browse mode (editing === null):
|
|
399
|
+
* - up/k, down/j → navigate, adjust viewport
|
|
400
|
+
* - space: text row → enterEdit; cycling row → cycleForward
|
|
401
|
+
* - left → cycleBackward (cycling rows only; noop on text rows)
|
|
402
|
+
* - right → cycleForward (cycling rows only; noop on text rows)
|
|
403
|
+
* - enter: text row → enterEdit; cycling row → save intent
|
|
404
|
+
* - e: text row → enterEdit; cycling row → noop
|
|
405
|
+
* - d → set devflowDefault
|
|
406
|
+
* - u → setNull (allowUnset only; noop for boolean)
|
|
407
|
+
* - escape/q → cancel intent
|
|
408
|
+
* - ctrl-c → abort intent
|
|
409
|
+
*/
|
|
410
|
+
export function reduce(state, key) {
|
|
411
|
+
const n = state.rows.length;
|
|
412
|
+
// Delegate to edit mode handler.
|
|
413
|
+
// ctrl-c is handled BEFORE delegating: reduceEditMode has no case for it, so it
|
|
414
|
+
// would fall through to 'none' and be swallowed. Raw mode suppresses the SIGINT
|
|
415
|
+
// that would otherwise rescue the user, so ctrl-c was completely dead while
|
|
416
|
+
// editing — the only way out was to discover escape first.
|
|
417
|
+
if (state.editing !== null) {
|
|
418
|
+
if (key === 'ctrl-c')
|
|
419
|
+
return { state, intent: 'abort' };
|
|
420
|
+
// Pass the narrowed editing (not null) — eliminates state.editing! inside reduceEditMode (TS-S1).
|
|
421
|
+
const next = reduceEditMode(state, key, state.editing);
|
|
422
|
+
return { state: next, intent: 'none' };
|
|
423
|
+
}
|
|
424
|
+
// Browse mode
|
|
425
|
+
//
|
|
426
|
+
// Keys that produce an intent regardless of row count come first. escape/q and
|
|
427
|
+
// ctrl-c already had no emptiness guard; enter must still save on an empty list
|
|
428
|
+
// (the one deliberate exception — do not fold into the unified guard below).
|
|
429
|
+
if (key === 'escape' || key === 'q')
|
|
430
|
+
return { state, intent: 'cancel' };
|
|
431
|
+
if (key === 'ctrl-c')
|
|
432
|
+
return { state, intent: 'abort' };
|
|
433
|
+
if (key === 'enter' && n === 0)
|
|
434
|
+
return { state, intent: 'save' };
|
|
435
|
+
// Unified emptiness guard: all remaining browse-mode keys are no-ops on an empty list.
|
|
436
|
+
if (n === 0)
|
|
437
|
+
return { state, intent: 'none' };
|
|
438
|
+
const row = state.rows[state.cursor];
|
|
439
|
+
switch (key) {
|
|
440
|
+
case 'up':
|
|
441
|
+
case 'k':
|
|
442
|
+
return move(state, -1);
|
|
443
|
+
case 'down':
|
|
444
|
+
case 'j':
|
|
445
|
+
return move(state, +1);
|
|
446
|
+
case 'space':
|
|
447
|
+
return row.stops.length === 0
|
|
448
|
+
? { state: enterEdit(state), intent: 'none' }
|
|
449
|
+
: cycle(state, row, 'forward');
|
|
450
|
+
case 'left':
|
|
451
|
+
return row.stops.length === 0 ? { state, intent: 'none' } : cycle(state, row, 'backward');
|
|
452
|
+
case 'right':
|
|
453
|
+
return row.stops.length === 0 ? { state, intent: 'none' } : cycle(state, row, 'forward');
|
|
454
|
+
case 'enter':
|
|
455
|
+
return row.stops.length === 0
|
|
456
|
+
? { state: enterEdit(state), intent: 'none' }
|
|
457
|
+
: { state, intent: 'save' };
|
|
458
|
+
case 'e':
|
|
459
|
+
return row.stops.length === 0
|
|
460
|
+
? { state: enterEdit(state), intent: 'none' }
|
|
461
|
+
: { state, intent: 'none' };
|
|
462
|
+
case 'd':
|
|
463
|
+
return {
|
|
464
|
+
state: { ...state, rows: updateRow(state.rows, state.cursor, { configuredValue: row.devflowDefault }) },
|
|
465
|
+
intent: 'none',
|
|
466
|
+
};
|
|
467
|
+
case 'u':
|
|
468
|
+
if (!row.allowUnset)
|
|
469
|
+
return { state, intent: 'none' };
|
|
470
|
+
return {
|
|
471
|
+
state: { ...state, rows: updateRow(state.rows, state.cursor, { configuredValue: null }) },
|
|
472
|
+
intent: 'none',
|
|
473
|
+
};
|
|
474
|
+
default:
|
|
475
|
+
return { state, intent: 'none' };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin adapter — devflow flags TUI shell over the generic runTui driver.
|
|
3
|
+
*
|
|
4
|
+
* applies ADR-013: impure I/O shell in CLI layer; pure logic lives in state.ts/render.ts.
|
|
5
|
+
* avoids PF-014: cleanup wired via Promise resolve — never process.exit() inside
|
|
6
|
+
* a finally-guarded scope.
|
|
7
|
+
* avoids PF-017: thin adapter over the generic shell (src/cli/tui/terminal.ts).
|
|
8
|
+
*
|
|
9
|
+
* Public API:
|
|
10
|
+
* - runFlagsTui(initialRows, io?) → Promise<FlagsTuiResult>
|
|
11
|
+
*
|
|
12
|
+
* Bounded: MAX_KEYPRESSES = 50_000 hard limit (re-exported from src/cli/tui/terminal.ts).
|
|
13
|
+
*/
|
|
14
|
+
import { reduce, resizeViewport } from './state.js';
|
|
15
|
+
import { renderFrame, computeViewportHeight } from './render.js';
|
|
16
|
+
import { runTui } from '../tui/terminal.js';
|
|
17
|
+
export { MAX_KEYPRESSES } from '../tui/terminal.js';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// runFlagsTui
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/**
|
|
22
|
+
* Launch the interactive flags TUI.
|
|
23
|
+
*
|
|
24
|
+
* @param initialRows - Initial flag rows (built by buildFlagRows).
|
|
25
|
+
* @param io - Optional I/O override (defaults to process.stdin/stdout). Pass fake
|
|
26
|
+
* streams in tests to drive the TUI without a real TTY.
|
|
27
|
+
* @returns Promise resolving to { action, rows } when the user saves, cancels, or aborts.
|
|
28
|
+
*/
|
|
29
|
+
export async function runFlagsTui(initialRows, io) {
|
|
30
|
+
const initialState = {
|
|
31
|
+
rows: initialRows,
|
|
32
|
+
cursor: 0,
|
|
33
|
+
viewportOffset: 0,
|
|
34
|
+
// Placeholder height — onResize overwrites this at startup with actual terminal dims
|
|
35
|
+
viewportHeight: 10,
|
|
36
|
+
editing: null,
|
|
37
|
+
};
|
|
38
|
+
// C='none' makes runTui return Promise<{ intent: Exclude<FlagsIntent,'none'>; state }>.
|
|
39
|
+
// Exclude<FlagsIntent,'none'> = 'save' | 'cancel' | 'abort', which matches
|
|
40
|
+
// FlagsTuiResult.action exactly — no casts needed, and adding a new FlagsIntent member
|
|
41
|
+
// is a compile error here (exhaustiveness enforced at the type level).
|
|
42
|
+
//
|
|
43
|
+
// D-INLINE: flags editor uses inline mode — renders in-place in the scroll buffer
|
|
44
|
+
// without entering the alt screen. This is friendlier for devflow init's wizard
|
|
45
|
+
// context where the flags editor is embedded in a multi-step interactive flow.
|
|
46
|
+
const result = await runTui({
|
|
47
|
+
initialState,
|
|
48
|
+
reduce,
|
|
49
|
+
renderFrame,
|
|
50
|
+
// resizeViewport re-clamps viewportOffset for the new height — setting the
|
|
51
|
+
// height alone can strand the cursor outside the visible slice.
|
|
52
|
+
onResize: (state, dims) => resizeViewport(state, computeViewportHeight(dims.rows)),
|
|
53
|
+
signalAction: 'abort',
|
|
54
|
+
continueIntent: 'none',
|
|
55
|
+
screen: 'inline',
|
|
56
|
+
io,
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
action: result.intent,
|
|
60
|
+
rows: result.state.rows,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=terminal.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared TUI cell helpers — shared by agents-view and flags-view.
|
|
3
|
+
*
|
|
4
|
+
* Applies PF-017: generified into a shared module rather than copy-adapted per consumer.
|
|
5
|
+
* Pure functions, no I/O.
|
|
6
|
+
*/
|
|
7
|
+
import { stripAnsi, truncate } from '../../core/ansi.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Cell padding and truncation
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/**
|
|
12
|
+
* Pad a string to `width` visible characters.
|
|
13
|
+
* Padding is measured against the ANSI-stripped visible length.
|
|
14
|
+
*/
|
|
15
|
+
export function padToVisible(s, width) {
|
|
16
|
+
const visible = stripAnsi(s);
|
|
17
|
+
const padding = Math.max(0, width - visible.length);
|
|
18
|
+
return s + ' '.repeat(padding);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Truncate a string to at most `maxWidth` visible characters.
|
|
22
|
+
* Uses the ANSI-stripped length for measurement; rebuilds from the stripped value
|
|
23
|
+
* so styling is not carried across the truncation boundary.
|
|
24
|
+
*/
|
|
25
|
+
export function truncateVisible(s, maxWidth) {
|
|
26
|
+
const raw = stripAnsi(s);
|
|
27
|
+
if (raw.length <= maxWidth)
|
|
28
|
+
return s;
|
|
29
|
+
// Re-truncate the unstyled version — simpler than ANSI-aware slice.
|
|
30
|
+
return truncate(raw, maxWidth);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Sanitize an untrusted string for a fixed-width TUI cell.
|
|
34
|
+
*
|
|
35
|
+
* stripAnsi strips escape sequences and C0 controls but, by contract, KEEPS
|
|
36
|
+
* TAB (\x09) and LF (\x0a). Both are layout-breaking in a fixed-width frame:
|
|
37
|
+
* - LF emits a newline inside a frame line, breaking the one-string-per-
|
|
38
|
+
* terminal-line contract and desyncing the cursor arithmetic in the shell.
|
|
39
|
+
* - TAB measures as one visible character but occupies up to eight terminal
|
|
40
|
+
* columns, misaligning every column to its right.
|
|
41
|
+
* Both collapse to a single space; raw key is untouched for save-path merges.
|
|
42
|
+
*/
|
|
43
|
+
const LAYOUT_BREAKING_WS = /[\t\n]/g;
|
|
44
|
+
export function sanitizeCell(s) {
|
|
45
|
+
return stripAnsi(s).replace(LAYOUT_BREAKING_WS, ' ');
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=cells.js.map
|