mergestorm 0.3.13 → 0.3.15

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 +14 -15
  2. package/dist/api.d.ts +82 -5
  3. package/dist/api.js +166 -7
  4. package/dist/client/index.d.ts +2 -2
  5. package/dist/client/index.js +2 -2
  6. package/dist/commands/branches.d.ts +6 -2
  7. package/dist/commands/branches.js +29 -14
  8. package/dist/commands/browse.d.ts +39 -0
  9. package/dist/commands/browse.js +259 -0
  10. package/dist/commands/credits.js +7 -10
  11. package/dist/commands/help.d.ts +5 -0
  12. package/dist/commands/help.js +93 -0
  13. package/dist/commands/jobs.d.ts +11 -2
  14. package/dist/commands/jobs.js +64 -15
  15. package/dist/commands/login.js +4 -3
  16. package/dist/commands/logout.js +2 -1
  17. package/dist/commands/pr.d.ts +22 -0
  18. package/dist/commands/pr.js +127 -0
  19. package/dist/commands/queue.d.ts +12 -0
  20. package/dist/commands/queue.js +160 -0
  21. package/dist/commands/registry.js +93 -18
  22. package/dist/commands/review-client.d.ts +13 -0
  23. package/dist/commands/review-client.js +110 -0
  24. package/dist/commands/review.d.ts +6 -0
  25. package/dist/commands/review.js +18 -7
  26. package/dist/commands/settings.d.ts +17 -0
  27. package/dist/commands/settings.js +89 -0
  28. package/dist/commands/skill.d.ts +20 -0
  29. package/dist/commands/skill.js +145 -0
  30. package/dist/commands/stack.d.ts +2 -0
  31. package/dist/commands/stack.js +91 -57
  32. package/dist/commands/status.d.ts +2 -0
  33. package/dist/commands/status.js +30 -15
  34. package/dist/commands/whoami.d.ts +3 -1
  35. package/dist/commands/whoami.js +21 -7
  36. package/dist/errors.d.ts +1 -1
  37. package/dist/shell.js +39 -31
  38. package/dist/stack-dto.d.ts +82 -1
  39. package/dist/ui/ansi.d.ts +2 -0
  40. package/dist/ui/ansi.js +2 -0
  41. package/dist/ui/banner.d.ts +34 -2
  42. package/dist/ui/banner.js +163 -79
  43. package/dist/ui/findings.d.ts +2 -1
  44. package/dist/ui/findings.js +13 -7
  45. package/dist/ui/line-tabs.d.ts +27 -0
  46. package/dist/ui/line-tabs.js +170 -0
  47. package/dist/ui/logo.d.ts +10 -2
  48. package/dist/ui/logo.js +13 -9
  49. package/dist/ui/present.d.ts +10 -0
  50. package/dist/ui/present.js +13 -0
  51. package/dist/ui/prompt.d.ts +58 -1
  52. package/dist/ui/prompt.js +225 -42
  53. package/dist/ui/select.js +1 -1
  54. package/dist/ui/tabs.d.ts +125 -0
  55. package/dist/ui/tabs.js +480 -0
  56. package/dist/ui/usage.d.ts +4 -0
  57. package/dist/ui/usage.js +22 -2
  58. package/package.json +2 -1
  59. package/skill/SKILL.md +98 -0
  60. package/skill/mergestorm-pr-loop/SKILL.md +76 -0
@@ -17,6 +17,13 @@ export interface AskLineOptions {
17
17
  /** Streams (defaults: process.stdin / process.stdout). Injectable for tests. */
18
18
  input?: NodeJS.ReadStream;
19
19
  output?: NodeJS.WriteStream;
20
+ /**
21
+ * Welcome-panel rows sitting directly above this prompt, reflowed per
22
+ * width. Painted on every frame from the pane origin (`ESC[H`) so a
23
+ * resize cannot desync CUU counts. Pass `true` for compact, `"mini"` /
24
+ * `"nano"` for a short pane that still shows the mark.
25
+ */
26
+ header?: (columns?: number, variant?: boolean | "mini" | "nano") => string[];
20
27
  }
21
28
  /** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
22
29
  export declare class PromptClosedError extends Error {
@@ -34,6 +41,12 @@ export declare function promptRedrawPrefix(inputRow: number): string;
34
41
  * (`down(2)`). Plain chrome is the content line (`down(1)`).
35
42
  */
36
43
  export declare function promptClearOverlay(boxed: boolean): string;
44
+ /**
45
+ * Cursor home + ED 0: clears the whole screen from row 1 and leaves the
46
+ * cursor there. Used to redraw the welcome panel on resize, where the old
47
+ * frame's row count is unknowable after the terminal rewraps.
48
+ */
49
+ export declare const HEADER_REDRAW_PREFIX = "\u001B[H\u001B[0J";
37
50
  export declare const DROPDOWN_MAX_COMMAND_ROWS = 8;
38
51
  export declare function formatSlashLabel(spec: CommandSpec): string;
39
52
  export declare function specMatchesTerm(spec: CommandSpec, term: string): boolean;
@@ -51,6 +64,39 @@ export type OverlayRow = {
51
64
  selected?: boolean;
52
65
  };
53
66
  /** Overlay rows (no invert). At most `maxRows` commands plus edge markers. */
67
+ /** Prompt label + input chrome + footer hint. */
68
+ export declare function promptChromeRows(boxed: boolean, hasPrompt: boolean, hasHint?: boolean): number;
69
+ /**
70
+ * Rows available above the input bar for the slash list. Idle home
71
+ * passes 0 — do not pad a blank gap between header and prompt.
72
+ */
73
+ export declare function overlayReserve(terminalRows: number | undefined, headerRows: number, boxed: boolean, hasPrompt: boolean): number;
74
+ /** Pad so the slash list sits just above the input bar (Claude-style). */
75
+ export declare function padOverlayRows(rows: OverlayRow[], reserve: number): OverlayRow[];
76
+ export interface HomeFrameFit {
77
+ header: string[];
78
+ reserve: number;
79
+ boxed: boolean;
80
+ hasPrompt: boolean;
81
+ hasHint: boolean;
82
+ }
83
+ /**
84
+ * Pick a header density and input chrome that fit the pane.
85
+ * Never drops the mark when a mini/nano header is provided — a short
86
+ * pane unboxes the input instead of painting an overflowing frame.
87
+ * `dockBottom` uses leftover rows so the prompt sits on the last lines.
88
+ */
89
+ export declare function fitHomeFrame(params: {
90
+ fullHeader: string[];
91
+ compactHeader: string[];
92
+ miniHeader?: string[];
93
+ nanoHeader?: string[];
94
+ terminalRows: number | undefined;
95
+ boxed: boolean;
96
+ hasPrompt: boolean;
97
+ dropdownCount: number;
98
+ dockBottom?: boolean;
99
+ }): HomeFrameFit;
54
100
  export declare function dropdownOverlayRows(dropdown: CommandSpec[], index: number, maxRows: number): OverlayRow[];
55
101
  /** Input prefix glyph (U+203A), kept as an escape so editors can't mangle it. */
56
102
  export declare const INPUT_PREFIX_GLYPH = "\u203A";
@@ -73,7 +119,7 @@ export interface InputWindow {
73
119
  */
74
120
  export declare function inputWindow(buffer: string, cursor: number, cells: number, prevStart?: number): InputWindow;
75
121
  /** Cells available to the buffer (after the prefix) on a content row. */
76
- export declare function inputCells(columns: number | undefined): {
122
+ export declare function inputCells(columns: number | undefined, boxedOverride?: boolean): {
77
123
  boxed: boolean;
78
124
  cells: number;
79
125
  };
@@ -86,6 +132,17 @@ export interface PromptFrameInput {
86
132
  /** Previous scroll origin, so left/right only move the window at the edges. */
87
133
  scrollStart?: number;
88
134
  overlay?: OverlayRow[];
135
+ /**
136
+ * When set, the overlay is padded to this many rows and drawn **above**
137
+ * the input bar. Height stays constant so `/` cannot scroll the header.
138
+ */
139
+ overlayReserve?: number;
140
+ /** Trailing footer row (empty or the Ctrl+C hint). */
141
+ hint?: string;
142
+ /** Welcome-panel rows painted with this frame (idle home screen). */
143
+ header?: string[];
144
+ /** Force boxed / plain input. Default follows {@link preferBoxedUi}. */
145
+ boxed?: boolean;
89
146
  }
90
147
  export interface PromptFrame {
91
148
  rows: string[];
package/dist/ui/prompt.js CHANGED
@@ -31,6 +31,12 @@ export function promptRedrawPrefix(inputRow) {
31
31
  export function promptClearOverlay(boxed) {
32
32
  return `${down(boxed ? 2 : 1)}\r${clearDown}`;
33
33
  }
34
+ /**
35
+ * Cursor home + ED 0: clears the whole screen from row 1 and leaves the
36
+ * cursor there. Used to redraw the welcome panel on resize, where the old
37
+ * frame's row count is unknowable after the terminal rewraps.
38
+ */
39
+ export const HEADER_REDRAW_PREFIX = `\u001b[H${clearDown}`;
34
40
  export const DROPDOWN_MAX_COMMAND_ROWS = 8;
35
41
  export function formatSlashLabel(spec) {
36
42
  const extra = spec.aliases?.length
@@ -85,12 +91,108 @@ export function dropdownViewport(count, index, maxRows) {
85
91
  };
86
92
  }
87
93
  /** Overlay rows (no invert). At most `maxRows` commands plus edge markers. */
94
+ /** Prompt label + input chrome + footer hint. */
95
+ export function promptChromeRows(boxed, hasPrompt, hasHint = true) {
96
+ return (hasPrompt ? 1 : 0) + (boxed ? 3 : 1) + (hasHint ? 1 : 0);
97
+ }
98
+ function resolvedRows(terminalRows) {
99
+ return typeof terminalRows === "number" && Number.isFinite(terminalRows) && terminalRows >= 1
100
+ ? terminalRows
101
+ : 24;
102
+ }
103
+ /**
104
+ * Rows available above the input bar for the slash list. Idle home
105
+ * passes 0 — do not pad a blank gap between header and prompt.
106
+ */
107
+ export function overlayReserve(terminalRows, headerRows, boxed, hasPrompt) {
108
+ const available = resolvedRows(terminalRows) - Math.max(0, headerRows) - promptChromeRows(boxed, hasPrompt);
109
+ return Math.max(0, Math.min(DROPDOWN_MAX_COMMAND_ROWS, available));
110
+ }
111
+ /** Pad so the slash list sits just above the input bar (Claude-style). */
112
+ export function padOverlayRows(rows, reserve) {
113
+ const max = Math.max(0, reserve);
114
+ if (max === 0)
115
+ return [];
116
+ const visible = rows.slice(0, max);
117
+ const pad = max - visible.length;
118
+ return [...Array.from({ length: pad }, () => ({ text: " " })), ...visible];
119
+ }
120
+ /**
121
+ * Pick a header density and input chrome that fit the pane.
122
+ * Never drops the mark when a mini/nano header is provided — a short
123
+ * pane unboxes the input instead of painting an overflowing frame.
124
+ * `dockBottom` uses leftover rows so the prompt sits on the last lines.
125
+ */
126
+ export function fitHomeFrame(params) {
127
+ const rows = resolvedRows(params.terminalRows);
128
+ const want = params.dropdownCount > 0
129
+ ? Math.min(DROPDOWN_MAX_COMMAND_ROWS, Math.max(1, params.dropdownCount))
130
+ : 0;
131
+ const headers = [
132
+ params.fullHeader,
133
+ params.compactHeader,
134
+ params.miniHeader ?? [],
135
+ params.nanoHeader ?? [],
136
+ ].filter((h, i, all) => h.length > 0 && all.findIndex((x) => x === h) === i);
137
+ const chromes = [];
138
+ if (params.boxed)
139
+ chromes.push({ boxed: true, hasPrompt: params.hasPrompt, hasHint: true });
140
+ chromes.push({ boxed: false, hasPrompt: params.hasPrompt, hasHint: true });
141
+ chromes.push({ boxed: false, hasPrompt: false, hasHint: true });
142
+ chromes.push({ boxed: false, hasPrompt: false, hasHint: false });
143
+ const tryFit = (header, chrome) => {
144
+ const chromeRows = promptChromeRows(chrome.boxed, chrome.hasPrompt, chrome.hasHint);
145
+ const avail = rows - header.length - chromeRows;
146
+ if (avail < 0)
147
+ return null;
148
+ if (params.dockBottom) {
149
+ return { header, reserve: avail, ...chrome };
150
+ }
151
+ if (want === 0) {
152
+ return { header, reserve: 0, ...chrome };
153
+ }
154
+ const maxList = Math.max(0, Math.min(DROPDOWN_MAX_COMMAND_ROWS, avail));
155
+ if (maxList < 1)
156
+ return null;
157
+ return { header, reserve: Math.min(want, maxList), ...chrome };
158
+ };
159
+ // Prefer keeping the input boxed: try every header density at the
160
+ // current chrome before unboxing. Otherwise a 12-row pane would pick
161
+ // the full banner + a plain prompt over compact + boxed.
162
+ for (const chrome of chromes) {
163
+ for (const header of headers) {
164
+ const hit = tryFit(header, chrome);
165
+ if (hit)
166
+ return hit;
167
+ }
168
+ }
169
+ const fallback = params.nanoHeader?.length
170
+ ? params.nanoHeader
171
+ : params.miniHeader?.length
172
+ ? params.miniHeader
173
+ : [];
174
+ return {
175
+ header: fallback,
176
+ reserve: params.dockBottom ? Math.max(0, rows - fallback.length - 1) : 0,
177
+ boxed: false,
178
+ hasPrompt: false,
179
+ hasHint: false,
180
+ };
181
+ }
88
182
  export function dropdownOverlayRows(dropdown, index, maxRows) {
89
- const view = dropdownViewport(dropdown.length, index, maxRows);
183
+ const max = Math.max(1, maxRows);
184
+ if (dropdown.length <= max) {
185
+ return dropdown.map((spec, i) => ({
186
+ text: ` ${formatSlashLabel(spec).padEnd(22)}${spec.summary}`,
187
+ selected: i === index,
188
+ }));
189
+ }
190
+ const cmdSlots = Math.max(1, max - 2);
191
+ const view = dropdownViewport(dropdown.length, index, cmdSlots);
90
192
  const rows = [];
91
193
  if (view.moreAbove > 0)
92
194
  rows.push({ text: ` \u2191 ${view.moreAbove} more` });
93
- const end = Math.min(dropdown.length, view.start + Math.max(1, maxRows));
195
+ const end = Math.min(dropdown.length, view.start + cmdSlots);
94
196
  for (let i = view.start; i < end; i++) {
95
197
  const spec = dropdown[i];
96
198
  rows.push({
@@ -100,7 +202,7 @@ export function dropdownOverlayRows(dropdown, index, maxRows) {
100
202
  }
101
203
  if (view.moreBelow > 0)
102
204
  rows.push({ text: ` \u2193 ${view.moreBelow} more` });
103
- return rows;
205
+ return rows.slice(0, max);
104
206
  }
105
207
  /** Input prefix glyph (U+203A), kept as an escape so editors can't mangle it. */
106
208
  export const INPUT_PREFIX_GLYPH = "\u203a";
@@ -133,8 +235,8 @@ export function inputWindow(buffer, cursor, cells, prevStart = 0) {
133
235
  };
134
236
  }
135
237
  /** Cells available to the buffer (after the prefix) on a content row. */
136
- export function inputCells(columns) {
137
- const boxed = preferBoxedUi(columns);
238
+ export function inputCells(columns, boxedOverride) {
239
+ const boxed = boxedOverride ?? preferBoxedUi(columns);
138
240
  const contentCells = boxed
139
241
  ? frameWidth(80, columns) - 2 - INPUT_BOX_PADDING * 2
140
242
  : terminalColumns(80, columns) - 1;
@@ -146,16 +248,37 @@ export function inputCells(columns) {
146
248
  * CUU count on the next redraw stays honest.
147
249
  */
148
250
  export function buildPromptFrame(params) {
149
- const { boxed, cells } = inputCells(params.columns);
251
+ const { boxed, cells } = inputCells(params.columns, params.boxed);
150
252
  const cols = terminalColumns(80, params.columns);
151
253
  const win = inputWindow(params.buffer, params.cursor, cells, params.scrollStart ?? 0);
152
- const inputRow = (params.prompt ? 1 : 0) + (boxed ? 1 : 0);
254
+ const header = params.header ?? [];
255
+ const reserve = params.overlayReserve;
256
+ const overlay = params.overlay ?? [];
257
+ const fitted = reserve != null ? padOverlayRows(overlay, reserve) : overlay;
258
+ const inputRow = header.length +
259
+ (reserve ?? 0) +
260
+ (params.prompt ? 1 : 0) +
261
+ (boxed ? 1 : 0);
153
262
  // Nothing we emit may wrap: a wrapped row is one the CUU count can't see.
154
263
  const clip = (text) => visibleWidth(text) > cols - 1 ? sliceVisible(text, 0, cols - 1) : text;
264
+ const paintOverlay = (row) => {
265
+ const text = clip(row.text);
266
+ return row.selected ? ansi.invert(text) : ansi.gray(text);
267
+ };
155
268
  const rows = [];
269
+ if (reserve != null) {
270
+ for (const line of header)
271
+ rows.push(clip(line));
272
+ for (const row of fitted)
273
+ rows.push(paintOverlay(row));
274
+ }
275
+ else {
276
+ for (const line of header)
277
+ rows.push(clip(line));
278
+ }
156
279
  if (params.prompt)
157
- rows.push(ansi.dim(clip(params.prompt)));
158
- const inputLine = `${ansi.green(INPUT_PREFIX_GLYPH)} ${win.text}`;
280
+ rows.push(clip(params.prompt));
281
+ const inputLine = `${ansi.brightGreen(INPUT_PREFIX_GLYPH)} ${win.text}`;
159
282
  if (boxed) {
160
283
  // Full terminal width input frame, not content-sized.
161
284
  rows.push(...roundedBox([inputLine], {
@@ -167,9 +290,12 @@ export function buildPromptFrame(params) {
167
290
  // Plain prompt on narrow TTYs (avoids wrapped borders).
168
291
  rows.push(inputLine);
169
292
  }
170
- for (const row of params.overlay ?? []) {
171
- const text = clip(row.text);
172
- rows.push(row.selected ? ansi.invert(text) : ansi.gray(text));
293
+ if (reserve == null) {
294
+ for (const row of overlay)
295
+ rows.push(paintOverlay(row));
296
+ }
297
+ if (params.hint != null) {
298
+ rows.push(clip(params.hint === "" ? " " : params.hint));
173
299
  }
174
300
  // Boxed: border col + padding; plain: column 1.
175
301
  const targetCol = (boxed ? 1 + INPUT_BOX_PADDING : 0) + INPUT_PREFIX_CELLS + win.cursorCol + 1;
@@ -207,9 +333,9 @@ export async function askLine(opts) {
207
333
  // final overlay clear must use *these*, not a fresh measurement, because
208
334
  // the cursor is parked relative to what was actually drawn.
209
335
  let drawnInputRow = 0;
210
- let drawnBoxed = true;
211
336
  let scrollStart = 0;
212
337
  let finished = false;
338
+ let exitHint = false;
213
339
  const ctrlCExit = new CtrlCExitGate();
214
340
  const wasRaw = Boolean(input.isRaw);
215
341
  readline.emitKeypressEvents(input);
@@ -223,44 +349,97 @@ export async function askLine(opts) {
223
349
  dropdownDismissed = false;
224
350
  buffer = next;
225
351
  }
226
- function render() {
227
- if (linesDrawn > 0) {
228
- output.write(promptRedrawPrefix(drawnInputRow));
229
- }
230
- // Re-measured every frame: the terminal may have been resized since
231
- // the last draw, flipping boxed/plain or changing the frame width.
352
+ let lastRoom = 24;
353
+ function paint() {
232
354
  const columns = opts.columns ?? output.columns;
233
355
  const boxedNow = preferBoxedUi(columns);
234
- const inputRowNow = (opts.prompt ? 1 : 0) + (boxedNow ? 1 : 0);
356
+ const rawRoom = opts.rows ?? output.rows;
357
+ const room = typeof rawRoom === "number" && Number.isFinite(rawRoom) && rawRoom >= 1
358
+ ? rawRoom
359
+ : lastRoom;
360
+ lastRoom = room;
361
+ const fullHeader = opts.header ? opts.header(columns, false) : [];
362
+ const compactHeader = opts.header ? opts.header(columns, true) : [];
363
+ const miniHeader = opts.header ? opts.header(columns, "mini") : [];
364
+ const nanoHeader = opts.header ? opts.header(columns, "nano") : [];
235
365
  const dropdown = currentDropdown();
236
- let overlay = [];
237
366
  if (dropdown.length) {
238
367
  if (dropdownIndex >= dropdown.length)
239
368
  dropdownIndex = dropdown.length - 1;
240
- const maxRows = dropdownMaxRows(opts.rows ?? output.rows, inputRowNow);
241
- overlay = dropdownOverlayRows(dropdown, dropdownIndex, maxRows);
242
369
  }
243
370
  else {
244
371
  dropdownIndex = 0;
245
372
  }
373
+ const roomRows = resolvedRows(room);
374
+ const fit = opts.header
375
+ ? fitHomeFrame({
376
+ fullHeader,
377
+ compactHeader,
378
+ miniHeader,
379
+ nanoHeader,
380
+ terminalRows: room,
381
+ boxed: boxedNow,
382
+ hasPrompt: Boolean(opts.prompt),
383
+ dropdownCount: dropdown.length,
384
+ dockBottom: true,
385
+ })
386
+ : {
387
+ header: [],
388
+ reserve: overlayReserve(room, 0, boxedNow, Boolean(opts.prompt)),
389
+ boxed: boxedNow,
390
+ hasPrompt: Boolean(opts.prompt),
391
+ hasHint: true,
392
+ };
393
+ const overlay = dropdown.length
394
+ ? dropdownOverlayRows(dropdown, dropdownIndex, Math.max(1, Math.min(DROPDOWN_MAX_COMMAND_ROWS, fit.reserve)))
395
+ : [];
246
396
  const frame = buildPromptFrame({
247
- prompt: opts.prompt,
397
+ prompt: fit.hasPrompt ? opts.prompt : undefined,
248
398
  buffer,
249
399
  cursor,
250
400
  columns,
251
401
  scrollStart,
252
402
  overlay,
403
+ overlayReserve: opts.header || dropdown.length ? fit.reserve : undefined,
404
+ header: fit.header,
405
+ hint: fit.hasHint ? (exitHint ? ansi.dim(CTRL_C_EXIT_HINT) : " ") : undefined,
406
+ boxed: fit.boxed,
253
407
  });
254
- output.write(frame.rows.join("\r\n") + "\r\n");
408
+ // Never write more rows than the pane — that scrolls the welcome
409
+ // tail (tips / what's new) into view and looks like a broken frame.
410
+ if (frame.rows.length > roomRows) {
411
+ const keep = frame.rows.slice(frame.rows.length - roomRows);
412
+ const shift = frame.rows.length - keep.length;
413
+ frame.rows = keep;
414
+ frame.inputRow = Math.max(0, frame.inputRow - shift);
415
+ }
416
+ // Home screen owns the pane: always origin + ED 0. Relative CUU after
417
+ // a split-drag is what tore the previous layout (cursor home vs
418
+ // includeHeader flipping mid-resize).
419
+ if (opts.header) {
420
+ output.write(HEADER_REDRAW_PREFIX);
421
+ }
422
+ else if (linesDrawn > 0) {
423
+ output.write(promptRedrawPrefix(drawnInputRow));
424
+ }
425
+ // A full-pane write plus a trailing newline scrolls row 1 into
426
+ // scrollback — that was the missing top border. Stop on the last
427
+ // cell of the last row and CUU from there.
428
+ const fillsPane = Boolean(opts.header) && frame.rows.length >= roomRows;
429
+ output.write(frame.rows.join("\r\n") + (fillsPane ? "" : "\r\n"));
255
430
  linesDrawn = frame.rows.length;
256
431
  drawnInputRow = frame.inputRow;
257
- drawnBoxed = frame.boxed;
258
432
  scrollStart = frame.scrollStart;
259
- output.write(`${up(linesDrawn - frame.inputRow)}${col(frame.targetCol)}`);
433
+ const fromRow = fillsPane ? linesDrawn - 1 : linesDrawn;
434
+ output.write(`${up(Math.max(0, fromRow - frame.inputRow))}${col(frame.targetCol)}`);
435
+ }
436
+ function render() {
437
+ paint();
260
438
  }
261
439
  function onResize() {
262
- if (!finished)
263
- render();
440
+ if (finished)
441
+ return;
442
+ paint();
264
443
  }
265
444
  function cleanup() {
266
445
  input.removeListener("keypress", onKeypress);
@@ -275,9 +454,19 @@ export async function askLine(opts) {
275
454
  return;
276
455
  finished = true;
277
456
  cleanup();
278
- if (linesDrawn > 0)
279
- output.write(promptClearOverlay(drawnBoxed));
280
- output.write("\n");
457
+ // Home chrome already fills the pane. down() + newline scrolls that
458
+ // frame (cursor to the bottom, input jumps up) before /usage paints.
459
+ // Leave the idle screen in place; the next TUI / askLine homes over it.
460
+ // Bare prompts still need the newline so oneshot output starts below.
461
+ // Leaving the shell: one newline so the OS prompt is not on the box.
462
+ if (!opts.header || err instanceof PromptClosedError) {
463
+ if (linesDrawn > 0) {
464
+ const below = linesDrawn - drawnInputRow - 1;
465
+ if (below > 0)
466
+ output.write(down(below));
467
+ }
468
+ output.write("\n");
469
+ }
281
470
  if (err)
282
471
  reject(err);
283
472
  else
@@ -292,23 +481,17 @@ export async function askLine(opts) {
292
481
  try {
293
482
  if (finished || !key)
294
483
  return;
295
- if (!(key.ctrl && key.name === "c"))
484
+ if (!(key.ctrl && key.name === "c")) {
296
485
  ctrlCExit.reset();
486
+ exitHint = false;
487
+ }
297
488
  const dropdown = currentDropdown();
298
489
  if (key.ctrl && key.name === "c") {
299
490
  if (ctrlCExit.press()) {
300
491
  finish(null, new PromptClosedError());
301
492
  return;
302
493
  }
303
- setBuffer("");
304
- cursor = 0;
305
- scrollStart = 0;
306
- historyIndex = history.length;
307
- dropdownIndex = 0;
308
- if (linesDrawn > 0)
309
- output.write(promptClearOverlay(drawnBoxed));
310
- output.write(`\n${ansi.dim(CTRL_C_EXIT_HINT)}\n`);
311
- linesDrawn = 0;
494
+ exitHint = true;
312
495
  render();
313
496
  return;
314
497
  }
package/dist/ui/select.js CHANGED
@@ -38,7 +38,7 @@ export async function selectFromList(opts) {
38
38
  rows.push(ansi.bold(opts.title));
39
39
  items.forEach((item, i) => {
40
40
  const body = opts.render(item, i);
41
- const marker = i === index ? ansi.green("›") : " ";
41
+ const marker = i === index ? ansi.brightGreen("›") : " ";
42
42
  const line = ` ${marker} ${body}`;
43
43
  rows.push(i === index ? ansi.invert(line) : line);
44
44
  });
@@ -0,0 +1,125 @@
1
+ import { type UsagePanelInput } from "./usage.js";
2
+ export type TabId = "status" | "usage" | "jobs" | "config";
3
+ export declare const TAB_ORDER: readonly TabId[];
4
+ export type TabsJob = {
5
+ id: string;
6
+ cells: {
7
+ job: string;
8
+ status: string;
9
+ verdict: string;
10
+ thread: string;
11
+ when: string;
12
+ };
13
+ };
14
+ /** One Config-tab row: a writable on/off toggle or a read-only connection. */
15
+ export type TabsConfigRow = {
16
+ /** Settings key sent on PATCH (writable rows only). */
17
+ key: string;
18
+ label: string;
19
+ value: boolean;
20
+ writable: boolean;
21
+ };
22
+ export type TabsData = {
23
+ /** Prebuilt whoami + credits lines for the Status tab. */
24
+ statusLines: string[];
25
+ /** Right-aligned chrome note (reachability). Lives on the hint row. */
26
+ footerNote?: string;
27
+ /** Usage panel input minus width concerns; null when the API degraded. */
28
+ usage: Omit<UsagePanelInput, "columns" | "color"> | null;
29
+ /** Usage-tab body when the panel is unavailable (overrides the default). */
30
+ usageEmptyText?: string;
31
+ jobs: TabsJob[];
32
+ /** Jobs-tab body when the list is empty (e.g. the list call failed). */
33
+ jobsEmptyText?: string;
34
+ /** Config tab rows; null when GET /api/v1/settings degraded. */
35
+ config: TabsConfigRow[] | null;
36
+ /** Config-tab body when settings are unavailable (overrides the default). */
37
+ configEmptyText?: string;
38
+ /** Transient Config-tab status line (PATCH in flight or rejected). */
39
+ configNote?: string;
40
+ color?: boolean;
41
+ };
42
+ export type TabsDetail = {
43
+ jobId: string;
44
+ rows: string[];
45
+ };
46
+ export type TabsState = {
47
+ tab: TabId;
48
+ /** Body scroll per scrollable view; jobs and config scroll by selection. */
49
+ scroll: {
50
+ status: number;
51
+ usage: number;
52
+ detail: number;
53
+ };
54
+ jobsIndex: number;
55
+ configIndex: number;
56
+ /** In-place `status <id>` view on the Jobs tab; Esc returns to the list. */
57
+ detail: TabsDetail | null;
58
+ };
59
+ export declare function initialTabsState(tab?: TabId): TabsState;
60
+ export type TabsKeyName = "left" | "right" | "up" | "down" | "return" | "space" | "escape" | "q";
61
+ export type TabsStep = {
62
+ kind: "state";
63
+ state: TabsState;
64
+ } | {
65
+ kind: "close";
66
+ } | {
67
+ kind: "open-job";
68
+ index: number;
69
+ state: TabsState;
70
+ } | {
71
+ kind: "toggle-setting";
72
+ key: string;
73
+ value: boolean;
74
+ state: TabsState;
75
+ };
76
+ export type TabsReduceCtx = {
77
+ jobsCount: number;
78
+ /** Max scroll offset of the active view (content rows minus body rows). */
79
+ maxScroll: number;
80
+ /** Config rows so Space knows which key to flip (read-only rows no-op). */
81
+ config?: readonly TabsConfigRow[];
82
+ };
83
+ /**
84
+ * One keypress against the browser state. Pure so tests can drive the whole
85
+ * key map without a TTY. Tabs clamp at the ends (no wrap-around, so "which
86
+ * tab am I on" never gets ambiguous); Esc backs out of a job detail before
87
+ * it closes the browser; `q` always closes.
88
+ */
89
+ export declare function reduceTabsKey(state: TabsState, key: TabsKeyName, ctx: TabsReduceCtx): TabsStep;
90
+ /** Body rows available inside the frame: total rows minus chrome + hint. */
91
+ export declare function tabsBodyRows(rows: number | undefined): number;
92
+ export type TabsLayout = {
93
+ /** Full frame (border, body, hint); no line wider than `columns - 1`. */
94
+ lines: string[];
95
+ bodyRows: number;
96
+ /** Content rows of the active view before clipping (drives max scroll). */
97
+ contentRows: number;
98
+ };
99
+ export declare function tabsMaxScroll(layout: TabsLayout): number;
100
+ /** Key hint on the left, optional reachability note on the right. */
101
+ export declare function tabsHintLine(left: string, right: string | undefined, columns: number, color: boolean): string;
102
+ /**
103
+ * The whole browser frame for one render: tab strip in the top border, the
104
+ * active tab's body clipped to `rows - 4`, and a dim key hint. Pure; the
105
+ * raw-mode loop and tests both consume it.
106
+ */
107
+ export declare function layoutTabs(state: TabsState, data: TabsData, columns?: number, rows?: number): TabsLayout;
108
+ export type TabsBrowserOptions = {
109
+ initial?: TabId;
110
+ data: TabsData;
111
+ /** Fetches `status <id>` rows for the Jobs tab's Enter action. */
112
+ loadJobDetail?: (jobId: string) => Promise<string[]>;
113
+ /** PATCHes one setting for the Config tab's Space action; resolves to the refreshed rows. */
114
+ toggleSetting?: (key: string, value: boolean) => Promise<TabsConfigRow[]>;
115
+ input?: NodeJS.ReadStream;
116
+ output?: NodeJS.WriteStream;
117
+ };
118
+ /**
119
+ * Raw-mode tabbed browser (Status / Usage / Jobs / Config) on the select.ts
120
+ * loop: Left/Right switch tabs, Up/Down scroll or select, Enter opens a job
121
+ * in place, Space toggles a Config setting, `q`/Esc close, resize re-renders.
122
+ * Non-TTY streams fall back to the static panels (callers normally gate on
123
+ * TTY already).
124
+ */
125
+ export declare function runTabsBrowser(opts: TabsBrowserOptions): Promise<void>;