mergestorm 0.3.9 → 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 (76) hide show
  1. package/README.md +67 -24
  2. package/dist/api.d.ts +106 -40
  3. package/dist/api.js +245 -9
  4. package/dist/client/index.d.ts +5 -0
  5. package/dist/client/index.js +5 -0
  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 +30 -12
  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 +32 -10
  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.d.ts +1 -1
  22. package/dist/commands/registry.js +115 -33
  23. package/dist/commands/review-client.d.ts +106 -0
  24. package/dist/commands/review-client.js +399 -0
  25. package/dist/commands/review.d.ts +36 -6
  26. package/dist/commands/review.js +395 -145
  27. package/dist/commands/settings.d.ts +17 -0
  28. package/dist/commands/settings.js +89 -0
  29. package/dist/commands/skill.d.ts +20 -0
  30. package/dist/commands/skill.js +145 -0
  31. package/dist/commands/stack.d.ts +36 -2
  32. package/dist/commands/stack.js +341 -113
  33. package/dist/commands/status.d.ts +18 -2
  34. package/dist/commands/status.js +236 -21
  35. package/dist/commands/whoami.d.ts +3 -1
  36. package/dist/commands/whoami.js +21 -7
  37. package/dist/errors.d.ts +18 -2
  38. package/dist/errors.js +22 -1
  39. package/dist/git-stack.d.ts +2 -0
  40. package/dist/git-stack.js +4 -0
  41. package/dist/git.d.ts +12 -1
  42. package/dist/git.js +52 -7
  43. package/dist/shell.d.ts +1 -1
  44. package/dist/shell.js +39 -28
  45. package/dist/stack-dto.d.ts +266 -0
  46. package/dist/stack-dto.js +7 -0
  47. package/dist/submit-pr-base.d.ts +31 -0
  48. package/dist/submit-pr-base.js +69 -0
  49. package/dist/ui/ansi.d.ts +2 -0
  50. package/dist/ui/ansi.js +2 -0
  51. package/dist/ui/banner.d.ts +34 -2
  52. package/dist/ui/banner.js +170 -79
  53. package/dist/ui/box.d.ts +13 -9
  54. package/dist/ui/box.js +28 -44
  55. package/dist/ui/findings.d.ts +4 -1
  56. package/dist/ui/findings.js +14 -7
  57. package/dist/ui/job-envelope.d.ts +68 -0
  58. package/dist/ui/job-envelope.js +35 -0
  59. package/dist/ui/line-tabs.d.ts +27 -0
  60. package/dist/ui/line-tabs.js +170 -0
  61. package/dist/ui/logo.d.ts +10 -2
  62. package/dist/ui/logo.js +13 -9
  63. package/dist/ui/present.d.ts +10 -0
  64. package/dist/ui/present.js +13 -0
  65. package/dist/ui/prompt.d.ts +148 -0
  66. package/dist/ui/prompt.js +433 -71
  67. package/dist/ui/select.js +1 -1
  68. package/dist/ui/tabs.d.ts +125 -0
  69. package/dist/ui/tabs.js +480 -0
  70. package/dist/ui/usage.d.ts +29 -1
  71. package/dist/ui/usage.js +71 -2
  72. package/dist/ui/width.d.ts +26 -0
  73. package/dist/ui/width.js +262 -0
  74. package/package.json +6 -1
  75. package/skill/SKILL.md +98 -0
  76. package/skill/mergestorm-pr-loop/SKILL.md +76 -0
package/dist/ui/prompt.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as readline from "node:readline";
2
2
  import { createInterface } from "node:readline/promises";
3
- import { stdin as input, stdout as output } from "node:process";
3
+ import { stdin, stdout } from "node:process";
4
4
  import { ansi } from "./ansi.js";
5
- import { frameWidth, preferBoxedUi, roundedBox, visibleWidth } from "./box.js";
5
+ import { frameWidth, preferBoxedUi, roundedBox, sliceVisible, terminalColumns, visibleWidth, } from "./box.js";
6
6
  import { CTRL_C_EXIT_HINT, CtrlCExitGate } from "./ctrl-c-exit.js";
7
7
  /** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
8
8
  export class PromptClosedError extends Error {
@@ -11,11 +11,297 @@ export class PromptClosedError extends Error {
11
11
  this.name = "PromptClosedError";
12
12
  }
13
13
  }
14
- const up = (n) => `[${n}A`;
15
- const down = (n) => `[${n}B`;
16
- const col = (n) => `[${n}G`;
17
- const clearDown = "";
18
- async function fallbackAskLine(promptLabel) {
14
+ const up = (n) => `\u001b[${n}A`;
15
+ const down = (n) => `\u001b[${n}B`;
16
+ const col = (n) => `\u001b[${n}G`;
17
+ const clearDown = "\u001b[0J";
18
+ /**
19
+ * Redraw prefix from the parked content-row cursor.
20
+ * CUU keeps the column, so CR must come before ED 0 or leftover
21
+ * prompt cells survive (`merg` + `mergestorm` -> `mergmmmmmergestorm`).
22
+ */
23
+ export function promptRedrawPrefix(inputRow) {
24
+ return `${up(inputRow)}\r${clearDown}`;
25
+ }
26
+ /**
27
+ * From the content row, move to the first row after the input chrome
28
+ * and erase the slash overlay. Boxed chrome is top + content + bottom
29
+ * (`down(2)`). Plain chrome is the content line (`down(1)`).
30
+ */
31
+ export function promptClearOverlay(boxed) {
32
+ return `${down(boxed ? 2 : 1)}\r${clearDown}`;
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}`;
40
+ export const DROPDOWN_MAX_COMMAND_ROWS = 8;
41
+ export function formatSlashLabel(spec) {
42
+ const extra = spec.aliases?.length
43
+ ? ` \u00b7 ${spec.aliases.join(" \u00b7 ")}`
44
+ : "";
45
+ return `/${spec.name}${extra}`;
46
+ }
47
+ export function specMatchesTerm(spec, term) {
48
+ if (spec.name.toLowerCase().startsWith(term))
49
+ return true;
50
+ return (spec.aliases ?? []).some((alias) => alias.toLowerCase().startsWith(term));
51
+ }
52
+ export function slashToken(buffer) {
53
+ if (!buffer.startsWith("/"))
54
+ return null;
55
+ return buffer.slice(1).split(/\s/, 1)[0].toLowerCase();
56
+ }
57
+ export function matchSlashCommands(buffer, specs, dismissed = false) {
58
+ if (dismissed)
59
+ return [];
60
+ const token = slashToken(buffer);
61
+ if (token == null)
62
+ return [];
63
+ return specs.filter((spec) => specMatchesTerm(spec, token));
64
+ }
65
+ export function isExactSlashCommand(buffer, specs) {
66
+ const token = slashToken(buffer);
67
+ if (!token)
68
+ return false;
69
+ return specs.some((spec) => spec.name === token || (spec.aliases ?? []).includes(token));
70
+ }
71
+ export function dropdownMaxRows(terminalRows, inputRow) {
72
+ const rows = typeof terminalRows === "number" && Number.isFinite(terminalRows) && terminalRows >= 1
73
+ ? terminalRows
74
+ : 24;
75
+ return Math.max(1, Math.min(DROPDOWN_MAX_COMMAND_ROWS, rows - inputRow - 3));
76
+ }
77
+ export function dropdownViewport(count, index, maxRows) {
78
+ const max = Math.max(1, maxRows);
79
+ if (count <= max)
80
+ return { start: 0, moreAbove: 0, moreBelow: 0 };
81
+ const clampedIndex = Math.max(0, Math.min(index, count - 1));
82
+ let start = 0;
83
+ if (clampedIndex >= max)
84
+ start = clampedIndex - max + 1;
85
+ if (start + max > count)
86
+ start = Math.max(0, count - max);
87
+ return {
88
+ start,
89
+ moreAbove: start,
90
+ moreBelow: Math.max(0, count - start - max),
91
+ };
92
+ }
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
+ }
182
+ export function dropdownOverlayRows(dropdown, 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);
192
+ const rows = [];
193
+ if (view.moreAbove > 0)
194
+ rows.push({ text: ` \u2191 ${view.moreAbove} more` });
195
+ const end = Math.min(dropdown.length, view.start + cmdSlots);
196
+ for (let i = view.start; i < end; i++) {
197
+ const spec = dropdown[i];
198
+ rows.push({
199
+ text: ` ${formatSlashLabel(spec).padEnd(22)}${spec.summary}`,
200
+ selected: i === index,
201
+ });
202
+ }
203
+ if (view.moreBelow > 0)
204
+ rows.push({ text: ` \u2193 ${view.moreBelow} more` });
205
+ return rows.slice(0, max);
206
+ }
207
+ /** Input prefix glyph (U+203A), kept as an escape so editors can't mangle it. */
208
+ export const INPUT_PREFIX_GLYPH = "\u203a";
209
+ /** Cells the `> ` prefix occupies before the buffer. */
210
+ export const INPUT_PREFIX_CELLS = 2;
211
+ /** Padding inside the boxed input frame. */
212
+ export const INPUT_BOX_PADDING = 1;
213
+ /**
214
+ * Horizontal scroll for a one-row editor: pick the `cells`-wide window of
215
+ * `buffer` that keeps the cursor on screen, moving the previous window only
216
+ * when the cursor leaves it. Never wraps onto a second physical row.
217
+ */
218
+ export function inputWindow(buffer, cursor, cells, prevStart = 0) {
219
+ if (cells <= 0)
220
+ return { start: 0, text: "", cursorCol: 0 };
221
+ const cursorCells = visibleWidth(buffer.slice(0, Math.max(0, cursor)));
222
+ const totalCells = visibleWidth(buffer);
223
+ // The cursor needs a cell of its own at end-of-line, so a full window
224
+ // shows at most `cells - 1` glyphs past the scroll origin.
225
+ const maxStart = Math.max(0, totalCells - (cells - 1));
226
+ let start = Math.min(Math.max(0, prevStart), maxStart);
227
+ if (cursorCells < start)
228
+ start = cursorCells;
229
+ if (cursorCells > start + cells - 1)
230
+ start = cursorCells - (cells - 1);
231
+ return {
232
+ start,
233
+ text: sliceVisible(buffer, start, cells),
234
+ cursorCol: cursorCells - start,
235
+ };
236
+ }
237
+ /** Cells available to the buffer (after the prefix) on a content row. */
238
+ export function inputCells(columns, boxedOverride) {
239
+ const boxed = boxedOverride ?? preferBoxedUi(columns);
240
+ const contentCells = boxed
241
+ ? frameWidth(80, columns) - 2 - INPUT_BOX_PADDING * 2
242
+ : terminalColumns(80, columns) - 1;
243
+ return { boxed, cells: Math.max(0, contentCells - INPUT_PREFIX_CELLS) };
244
+ }
245
+ /**
246
+ * Everything one prompt redraw writes, computed from the current terminal
247
+ * width. Every row fits in `columns - 1` cells so nothing wraps and the
248
+ * CUU count on the next redraw stays honest.
249
+ */
250
+ export function buildPromptFrame(params) {
251
+ const { boxed, cells } = inputCells(params.columns, params.boxed);
252
+ const cols = terminalColumns(80, params.columns);
253
+ const win = inputWindow(params.buffer, params.cursor, cells, params.scrollStart ?? 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);
262
+ // Nothing we emit may wrap: a wrapped row is one the CUU count can't see.
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
+ };
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
+ }
279
+ if (params.prompt)
280
+ rows.push(clip(params.prompt));
281
+ const inputLine = `${ansi.brightGreen(INPUT_PREFIX_GLYPH)} ${win.text}`;
282
+ if (boxed) {
283
+ // Full terminal width input frame, not content-sized.
284
+ rows.push(...roundedBox([inputLine], {
285
+ padding: INPUT_BOX_PADDING,
286
+ width: frameWidth(80, params.columns),
287
+ }));
288
+ }
289
+ else {
290
+ // Plain prompt on narrow TTYs (avoids wrapped borders).
291
+ rows.push(inputLine);
292
+ }
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));
299
+ }
300
+ // Boxed: border col + padding; plain: column 1.
301
+ const targetCol = (boxed ? 1 + INPUT_BOX_PADDING : 0) + INPUT_PREFIX_CELLS + win.cursorCol + 1;
302
+ return { rows, boxed, inputRow, targetCol, scrollStart: win.start };
303
+ }
304
+ async function fallbackAskLine(promptLabel, input, output) {
19
305
  const rl = createInterface({ input, output, terminal: false });
20
306
  try {
21
307
  return await rl.question(promptLabel ? `${promptLabel}> ` : "> ");
@@ -31,68 +317,133 @@ async function fallbackAskLine(promptLabel) {
31
317
  export async function askLine(opts) {
32
318
  const commands = opts.commands ?? [];
33
319
  const history = opts.history ?? [];
320
+ const input = opts.input ?? stdin;
321
+ const output = opts.output ?? stdout;
34
322
  if (!input.isTTY || !output.isTTY) {
35
- return fallbackAskLine(opts.prompt);
323
+ return fallbackAskLine(opts.prompt, input, output);
36
324
  }
37
325
  return new Promise((resolve, reject) => {
38
326
  let buffer = "";
39
327
  let cursor = 0;
40
328
  let historyIndex = history.length;
41
329
  let dropdownIndex = 0;
330
+ let dropdownDismissed = false;
42
331
  let linesDrawn = 0;
332
+ // Geometry of the frame currently on screen. The redraw prefix and the
333
+ // final overlay clear must use *these*, not a fresh measurement, because
334
+ // the cursor is parked relative to what was actually drawn.
335
+ let drawnInputRow = 0;
336
+ let scrollStart = 0;
43
337
  let finished = false;
338
+ let exitHint = false;
44
339
  const ctrlCExit = new CtrlCExitGate();
45
340
  const wasRaw = Boolean(input.isRaw);
46
341
  readline.emitKeypressEvents(input);
47
342
  input.setRawMode(true);
48
343
  input.resume();
49
- const boxed = preferBoxedUi();
50
- // Row (0-based) of the "› buffer" content line among the rows we draw each frame.
51
- // Boxed: optional prompt + top border + content. Plain: optional prompt + content.
52
- const inputRow = (opts.prompt ? 1 : 0) + (boxed ? 1 : 0);
53
344
  function currentDropdown() {
54
- if (!buffer.startsWith("/"))
55
- return [];
56
- const term = buffer.slice(1).toLowerCase();
57
- return commands.filter((c) => c.name.toLowerCase().startsWith(term));
345
+ return matchSlashCommands(buffer, commands, dropdownDismissed);
58
346
  }
59
- function render() {
60
- if (linesDrawn > 0) {
61
- output.write(`${up(inputRow)}${clearDown}`);
62
- }
63
- const rows = [];
64
- if (opts.prompt)
65
- rows.push(ansi.dim(opts.prompt));
66
- const inputLine = `${ansi.green("›")} ${buffer}`;
67
- if (boxed) {
68
- // Full terminal width input frame, not content-sized.
69
- rows.push(...roundedBox([inputLine], { padding: 1, width: frameWidth() }));
70
- }
71
- else {
72
- // Plain prompt on narrow TTYs (avoids wrapped borders).
73
- rows.push(inputLine);
74
- }
347
+ function setBuffer(next) {
348
+ if (next !== buffer)
349
+ dropdownDismissed = false;
350
+ buffer = next;
351
+ }
352
+ let lastRoom = 24;
353
+ function paint() {
354
+ const columns = opts.columns ?? output.columns;
355
+ const boxedNow = preferBoxedUi(columns);
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") : [];
75
365
  const dropdown = currentDropdown();
76
366
  if (dropdown.length) {
77
367
  if (dropdownIndex >= dropdown.length)
78
368
  dropdownIndex = dropdown.length - 1;
79
- dropdown.forEach((c, i) => {
80
- const label = ` /${c.name.padEnd(12)}${c.summary}`;
81
- rows.push(i === dropdownIndex ? ansi.invert(label) : ansi.gray(label));
82
- });
83
369
  }
84
370
  else {
85
371
  dropdownIndex = 0;
86
372
  }
87
- output.write(rows.join("\r\n") + "\r\n");
88
- linesDrawn = rows.length;
89
- const prefixWidth = visibleWidth(`${ansi.green("›")} `);
90
- // Boxed: border col + padding; plain: column 1.
91
- const targetCol = (boxed ? 1 + 1 : 0) + prefixWidth + cursor + 1;
92
- output.write(`${up(linesDrawn - inputRow)}${col(targetCol)}`);
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
+ : [];
396
+ const frame = buildPromptFrame({
397
+ prompt: fit.hasPrompt ? opts.prompt : undefined,
398
+ buffer,
399
+ cursor,
400
+ columns,
401
+ scrollStart,
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,
407
+ });
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"));
430
+ linesDrawn = frame.rows.length;
431
+ drawnInputRow = frame.inputRow;
432
+ scrollStart = frame.scrollStart;
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();
438
+ }
439
+ function onResize() {
440
+ if (finished)
441
+ return;
442
+ paint();
93
443
  }
94
444
  function cleanup() {
95
445
  input.removeListener("keypress", onKeypress);
446
+ output.removeListener("resize", onResize);
96
447
  input.setRawMode(wasRaw);
97
448
  // emitKeypressEvents leaves stdin flowing; pause it so an idle shell
98
449
  // (no command running) doesn't keep the process alive after exit/EOF.
@@ -103,17 +454,26 @@ export async function askLine(opts) {
103
454
  return;
104
455
  finished = true;
105
456
  cleanup();
106
- const rowsBelow = Math.max(0, linesDrawn - inputRow - 1);
107
- if (rowsBelow > 0)
108
- output.write(down(rowsBelow));
109
- 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
+ }
110
470
  if (err)
111
471
  reject(err);
112
472
  else
113
473
  resolve(value ?? "");
114
474
  }
115
475
  function acceptDropdown(dropdown) {
116
- buffer = `/${dropdown[dropdownIndex].name} `;
476
+ setBuffer(`/${dropdown[dropdownIndex].name} `);
117
477
  cursor = buffer.length;
118
478
  dropdownIndex = 0;
119
479
  }
@@ -121,22 +481,17 @@ export async function askLine(opts) {
121
481
  try {
122
482
  if (finished || !key)
123
483
  return;
124
- if (!(key.ctrl && key.name === "c"))
484
+ if (!(key.ctrl && key.name === "c")) {
125
485
  ctrlCExit.reset();
486
+ exitHint = false;
487
+ }
126
488
  const dropdown = currentDropdown();
127
489
  if (key.ctrl && key.name === "c") {
128
490
  if (ctrlCExit.press()) {
129
491
  finish(null, new PromptClosedError());
130
492
  return;
131
493
  }
132
- buffer = "";
133
- cursor = 0;
134
- historyIndex = history.length;
135
- const rowsBelow = Math.max(0, linesDrawn - inputRow - 1);
136
- if (rowsBelow > 0)
137
- output.write(down(rowsBelow));
138
- output.write(`\n${ansi.dim(CTRL_C_EXIT_HINT)}\n`);
139
- linesDrawn = 0;
494
+ exitHint = true;
140
495
  render();
141
496
  return;
142
497
  }
@@ -145,9 +500,18 @@ export async function askLine(opts) {
145
500
  finish(null, new PromptClosedError());
146
501
  return;
147
502
  }
148
- if (key.name === "return") {
503
+ if (key.name === "escape") {
149
504
  if (dropdown.length)
150
- buffer = `/${dropdown[dropdownIndex].name}`;
505
+ dropdownDismissed = true;
506
+ render();
507
+ return;
508
+ }
509
+ if (key.name === "return") {
510
+ if (dropdown.length &&
511
+ !dropdownDismissed &&
512
+ !isExactSlashCommand(buffer, dropdown)) {
513
+ setBuffer(`/${dropdown[dropdownIndex].name}`);
514
+ }
151
515
  finish(buffer);
152
516
  return;
153
517
  }
@@ -158,10 +522,7 @@ export async function askLine(opts) {
158
522
  return;
159
523
  }
160
524
  if (key.name === "right") {
161
- if (dropdown.length)
162
- acceptDropdown(dropdown);
163
- else
164
- cursor = Math.min(buffer.length, cursor + 1);
525
+ cursor = Math.min(buffer.length, cursor + 1);
165
526
  render();
166
527
  return;
167
528
  }
@@ -172,11 +533,11 @@ export async function askLine(opts) {
172
533
  }
173
534
  if (key.name === "up") {
174
535
  if (dropdown.length) {
175
- dropdownIndex = (dropdownIndex - 1 + dropdown.length) % dropdown.length;
536
+ dropdownIndex = Math.max(0, dropdownIndex - 1);
176
537
  }
177
538
  else if (history.length) {
178
539
  historyIndex = Math.max(0, historyIndex - 1);
179
- buffer = history[historyIndex] ?? "";
540
+ setBuffer(history[historyIndex] ?? "");
180
541
  cursor = buffer.length;
181
542
  }
182
543
  render();
@@ -184,11 +545,11 @@ export async function askLine(opts) {
184
545
  }
185
546
  if (key.name === "down") {
186
547
  if (dropdown.length) {
187
- dropdownIndex = (dropdownIndex + 1) % dropdown.length;
548
+ dropdownIndex = Math.min(dropdown.length - 1, dropdownIndex + 1);
188
549
  }
189
550
  else if (history.length) {
190
551
  historyIndex = Math.min(history.length, historyIndex + 1);
191
- buffer = historyIndex >= history.length ? "" : history[historyIndex] ?? "";
552
+ setBuffer(historyIndex >= history.length ? "" : history[historyIndex] ?? "");
192
553
  cursor = buffer.length;
193
554
  }
194
555
  render();
@@ -205,31 +566,31 @@ export async function askLine(opts) {
205
566
  return;
206
567
  }
207
568
  if (key.ctrl && key.name === "u") {
208
- buffer = buffer.slice(cursor);
569
+ setBuffer(buffer.slice(cursor));
209
570
  cursor = 0;
210
571
  render();
211
572
  return;
212
573
  }
213
574
  if (key.ctrl && key.name === "k") {
214
- buffer = buffer.slice(0, cursor);
575
+ setBuffer(buffer.slice(0, cursor));
215
576
  render();
216
577
  return;
217
578
  }
218
579
  if (key.name === "backspace") {
219
580
  if (cursor > 0) {
220
- buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
581
+ setBuffer(buffer.slice(0, cursor - 1) + buffer.slice(cursor));
221
582
  cursor -= 1;
222
583
  }
223
584
  render();
224
585
  return;
225
586
  }
226
587
  if (key.name === "delete") {
227
- buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1);
588
+ setBuffer(buffer.slice(0, cursor) + buffer.slice(cursor + 1));
228
589
  render();
229
590
  return;
230
591
  }
231
592
  if (str && !key.ctrl && !key.meta) {
232
- buffer = buffer.slice(0, cursor) + str + buffer.slice(cursor);
593
+ setBuffer(buffer.slice(0, cursor) + str + buffer.slice(cursor));
233
594
  cursor += str.length;
234
595
  render();
235
596
  }
@@ -239,6 +600,7 @@ export async function askLine(opts) {
239
600
  }
240
601
  }
241
602
  input.on("keypress", onKeypress);
603
+ output.on("resize", onResize);
242
604
  render();
243
605
  });
244
606
  }
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
  });