mergestorm 0.3.8 → 0.3.13
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 +59 -14
- package/dist/api.d.ts +27 -38
- package/dist/api.js +81 -4
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/commands/credits.js +33 -12
- package/dist/commands/jobs.js +1 -1
- package/dist/commands/login.js +29 -8
- package/dist/commands/registry.d.ts +1 -1
- package/dist/commands/registry.js +26 -19
- package/dist/commands/review-client.d.ts +93 -0
- package/dist/commands/review-client.js +289 -0
- package/dist/commands/review.d.ts +30 -6
- package/dist/commands/review.js +385 -146
- package/dist/commands/stack.d.ts +34 -2
- package/dist/commands/stack.js +256 -62
- package/dist/commands/status.d.ts +16 -2
- package/dist/commands/status.js +221 -21
- package/dist/errors.d.ts +18 -2
- package/dist/errors.js +22 -1
- package/dist/git-stack.d.ts +2 -0
- package/dist/git-stack.js +4 -0
- package/dist/git.d.ts +12 -1
- package/dist/git.js +52 -7
- package/dist/shell.d.ts +1 -1
- package/dist/shell.js +7 -4
- package/dist/stack-dto.d.ts +185 -0
- package/dist/stack-dto.js +7 -0
- package/dist/submit-pr-base.d.ts +31 -0
- package/dist/submit-pr-base.js +69 -0
- package/dist/ui/banner.js +12 -0
- package/dist/ui/box.d.ts +13 -9
- package/dist/ui/box.js +28 -44
- package/dist/ui/findings.d.ts +2 -0
- package/dist/ui/findings.js +2 -1
- package/dist/ui/job-envelope.d.ts +68 -0
- package/dist/ui/job-envelope.js +35 -0
- package/dist/ui/prompt.d.ts +91 -0
- package/dist/ui/prompt.js +241 -62
- package/dist/ui/usage.d.ts +25 -1
- package/dist/ui/usage.js +50 -1
- package/dist/ui/width.d.ts +26 -0
- package/dist/ui/width.js +262 -0
- package/package.json +6 -2
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
|
|
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,171 @@ export class PromptClosedError extends Error {
|
|
|
11
11
|
this.name = "PromptClosedError";
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
const up = (n) =>
|
|
15
|
-
const down = (n) =>
|
|
16
|
-
const col = (n) =>
|
|
17
|
-
const clearDown = "
|
|
18
|
-
|
|
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
|
+
export const DROPDOWN_MAX_COMMAND_ROWS = 8;
|
|
35
|
+
export function formatSlashLabel(spec) {
|
|
36
|
+
const extra = spec.aliases?.length
|
|
37
|
+
? ` \u00b7 ${spec.aliases.join(" \u00b7 ")}`
|
|
38
|
+
: "";
|
|
39
|
+
return `/${spec.name}${extra}`;
|
|
40
|
+
}
|
|
41
|
+
export function specMatchesTerm(spec, term) {
|
|
42
|
+
if (spec.name.toLowerCase().startsWith(term))
|
|
43
|
+
return true;
|
|
44
|
+
return (spec.aliases ?? []).some((alias) => alias.toLowerCase().startsWith(term));
|
|
45
|
+
}
|
|
46
|
+
export function slashToken(buffer) {
|
|
47
|
+
if (!buffer.startsWith("/"))
|
|
48
|
+
return null;
|
|
49
|
+
return buffer.slice(1).split(/\s/, 1)[0].toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
export function matchSlashCommands(buffer, specs, dismissed = false) {
|
|
52
|
+
if (dismissed)
|
|
53
|
+
return [];
|
|
54
|
+
const token = slashToken(buffer);
|
|
55
|
+
if (token == null)
|
|
56
|
+
return [];
|
|
57
|
+
return specs.filter((spec) => specMatchesTerm(spec, token));
|
|
58
|
+
}
|
|
59
|
+
export function isExactSlashCommand(buffer, specs) {
|
|
60
|
+
const token = slashToken(buffer);
|
|
61
|
+
if (!token)
|
|
62
|
+
return false;
|
|
63
|
+
return specs.some((spec) => spec.name === token || (spec.aliases ?? []).includes(token));
|
|
64
|
+
}
|
|
65
|
+
export function dropdownMaxRows(terminalRows, inputRow) {
|
|
66
|
+
const rows = typeof terminalRows === "number" && Number.isFinite(terminalRows) && terminalRows >= 1
|
|
67
|
+
? terminalRows
|
|
68
|
+
: 24;
|
|
69
|
+
return Math.max(1, Math.min(DROPDOWN_MAX_COMMAND_ROWS, rows - inputRow - 3));
|
|
70
|
+
}
|
|
71
|
+
export function dropdownViewport(count, index, maxRows) {
|
|
72
|
+
const max = Math.max(1, maxRows);
|
|
73
|
+
if (count <= max)
|
|
74
|
+
return { start: 0, moreAbove: 0, moreBelow: 0 };
|
|
75
|
+
const clampedIndex = Math.max(0, Math.min(index, count - 1));
|
|
76
|
+
let start = 0;
|
|
77
|
+
if (clampedIndex >= max)
|
|
78
|
+
start = clampedIndex - max + 1;
|
|
79
|
+
if (start + max > count)
|
|
80
|
+
start = Math.max(0, count - max);
|
|
81
|
+
return {
|
|
82
|
+
start,
|
|
83
|
+
moreAbove: start,
|
|
84
|
+
moreBelow: Math.max(0, count - start - max),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Overlay rows (no invert). At most `maxRows` commands plus edge markers. */
|
|
88
|
+
export function dropdownOverlayRows(dropdown, index, maxRows) {
|
|
89
|
+
const view = dropdownViewport(dropdown.length, index, maxRows);
|
|
90
|
+
const rows = [];
|
|
91
|
+
if (view.moreAbove > 0)
|
|
92
|
+
rows.push({ text: ` \u2191 ${view.moreAbove} more` });
|
|
93
|
+
const end = Math.min(dropdown.length, view.start + Math.max(1, maxRows));
|
|
94
|
+
for (let i = view.start; i < end; i++) {
|
|
95
|
+
const spec = dropdown[i];
|
|
96
|
+
rows.push({
|
|
97
|
+
text: ` ${formatSlashLabel(spec).padEnd(22)}${spec.summary}`,
|
|
98
|
+
selected: i === index,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (view.moreBelow > 0)
|
|
102
|
+
rows.push({ text: ` \u2193 ${view.moreBelow} more` });
|
|
103
|
+
return rows;
|
|
104
|
+
}
|
|
105
|
+
/** Input prefix glyph (U+203A), kept as an escape so editors can't mangle it. */
|
|
106
|
+
export const INPUT_PREFIX_GLYPH = "\u203a";
|
|
107
|
+
/** Cells the `> ` prefix occupies before the buffer. */
|
|
108
|
+
export const INPUT_PREFIX_CELLS = 2;
|
|
109
|
+
/** Padding inside the boxed input frame. */
|
|
110
|
+
export const INPUT_BOX_PADDING = 1;
|
|
111
|
+
/**
|
|
112
|
+
* Horizontal scroll for a one-row editor: pick the `cells`-wide window of
|
|
113
|
+
* `buffer` that keeps the cursor on screen, moving the previous window only
|
|
114
|
+
* when the cursor leaves it. Never wraps onto a second physical row.
|
|
115
|
+
*/
|
|
116
|
+
export function inputWindow(buffer, cursor, cells, prevStart = 0) {
|
|
117
|
+
if (cells <= 0)
|
|
118
|
+
return { start: 0, text: "", cursorCol: 0 };
|
|
119
|
+
const cursorCells = visibleWidth(buffer.slice(0, Math.max(0, cursor)));
|
|
120
|
+
const totalCells = visibleWidth(buffer);
|
|
121
|
+
// The cursor needs a cell of its own at end-of-line, so a full window
|
|
122
|
+
// shows at most `cells - 1` glyphs past the scroll origin.
|
|
123
|
+
const maxStart = Math.max(0, totalCells - (cells - 1));
|
|
124
|
+
let start = Math.min(Math.max(0, prevStart), maxStart);
|
|
125
|
+
if (cursorCells < start)
|
|
126
|
+
start = cursorCells;
|
|
127
|
+
if (cursorCells > start + cells - 1)
|
|
128
|
+
start = cursorCells - (cells - 1);
|
|
129
|
+
return {
|
|
130
|
+
start,
|
|
131
|
+
text: sliceVisible(buffer, start, cells),
|
|
132
|
+
cursorCol: cursorCells - start,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Cells available to the buffer (after the prefix) on a content row. */
|
|
136
|
+
export function inputCells(columns) {
|
|
137
|
+
const boxed = preferBoxedUi(columns);
|
|
138
|
+
const contentCells = boxed
|
|
139
|
+
? frameWidth(80, columns) - 2 - INPUT_BOX_PADDING * 2
|
|
140
|
+
: terminalColumns(80, columns) - 1;
|
|
141
|
+
return { boxed, cells: Math.max(0, contentCells - INPUT_PREFIX_CELLS) };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Everything one prompt redraw writes, computed from the current terminal
|
|
145
|
+
* width. Every row fits in `columns - 1` cells so nothing wraps and the
|
|
146
|
+
* CUU count on the next redraw stays honest.
|
|
147
|
+
*/
|
|
148
|
+
export function buildPromptFrame(params) {
|
|
149
|
+
const { boxed, cells } = inputCells(params.columns);
|
|
150
|
+
const cols = terminalColumns(80, params.columns);
|
|
151
|
+
const win = inputWindow(params.buffer, params.cursor, cells, params.scrollStart ?? 0);
|
|
152
|
+
const inputRow = (params.prompt ? 1 : 0) + (boxed ? 1 : 0);
|
|
153
|
+
// Nothing we emit may wrap: a wrapped row is one the CUU count can't see.
|
|
154
|
+
const clip = (text) => visibleWidth(text) > cols - 1 ? sliceVisible(text, 0, cols - 1) : text;
|
|
155
|
+
const rows = [];
|
|
156
|
+
if (params.prompt)
|
|
157
|
+
rows.push(ansi.dim(clip(params.prompt)));
|
|
158
|
+
const inputLine = `${ansi.green(INPUT_PREFIX_GLYPH)} ${win.text}`;
|
|
159
|
+
if (boxed) {
|
|
160
|
+
// Full terminal width input frame, not content-sized.
|
|
161
|
+
rows.push(...roundedBox([inputLine], {
|
|
162
|
+
padding: INPUT_BOX_PADDING,
|
|
163
|
+
width: frameWidth(80, params.columns),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// Plain prompt on narrow TTYs (avoids wrapped borders).
|
|
168
|
+
rows.push(inputLine);
|
|
169
|
+
}
|
|
170
|
+
for (const row of params.overlay ?? []) {
|
|
171
|
+
const text = clip(row.text);
|
|
172
|
+
rows.push(row.selected ? ansi.invert(text) : ansi.gray(text));
|
|
173
|
+
}
|
|
174
|
+
// Boxed: border col + padding; plain: column 1.
|
|
175
|
+
const targetCol = (boxed ? 1 + INPUT_BOX_PADDING : 0) + INPUT_PREFIX_CELLS + win.cursorCol + 1;
|
|
176
|
+
return { rows, boxed, inputRow, targetCol, scrollStart: win.start };
|
|
177
|
+
}
|
|
178
|
+
async function fallbackAskLine(promptLabel, input, output) {
|
|
19
179
|
const rl = createInterface({ input, output, terminal: false });
|
|
20
180
|
try {
|
|
21
181
|
return await rl.question(promptLabel ? `${promptLabel}> ` : "> ");
|
|
@@ -31,68 +191,80 @@ async function fallbackAskLine(promptLabel) {
|
|
|
31
191
|
export async function askLine(opts) {
|
|
32
192
|
const commands = opts.commands ?? [];
|
|
33
193
|
const history = opts.history ?? [];
|
|
194
|
+
const input = opts.input ?? stdin;
|
|
195
|
+
const output = opts.output ?? stdout;
|
|
34
196
|
if (!input.isTTY || !output.isTTY) {
|
|
35
|
-
return fallbackAskLine(opts.prompt);
|
|
197
|
+
return fallbackAskLine(opts.prompt, input, output);
|
|
36
198
|
}
|
|
37
199
|
return new Promise((resolve, reject) => {
|
|
38
200
|
let buffer = "";
|
|
39
201
|
let cursor = 0;
|
|
40
202
|
let historyIndex = history.length;
|
|
41
203
|
let dropdownIndex = 0;
|
|
204
|
+
let dropdownDismissed = false;
|
|
42
205
|
let linesDrawn = 0;
|
|
206
|
+
// Geometry of the frame currently on screen. The redraw prefix and the
|
|
207
|
+
// final overlay clear must use *these*, not a fresh measurement, because
|
|
208
|
+
// the cursor is parked relative to what was actually drawn.
|
|
209
|
+
let drawnInputRow = 0;
|
|
210
|
+
let drawnBoxed = true;
|
|
211
|
+
let scrollStart = 0;
|
|
43
212
|
let finished = false;
|
|
44
213
|
const ctrlCExit = new CtrlCExitGate();
|
|
45
214
|
const wasRaw = Boolean(input.isRaw);
|
|
46
215
|
readline.emitKeypressEvents(input);
|
|
47
216
|
input.setRawMode(true);
|
|
48
217
|
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
218
|
function currentDropdown() {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
219
|
+
return matchSlashCommands(buffer, commands, dropdownDismissed);
|
|
220
|
+
}
|
|
221
|
+
function setBuffer(next) {
|
|
222
|
+
if (next !== buffer)
|
|
223
|
+
dropdownDismissed = false;
|
|
224
|
+
buffer = next;
|
|
58
225
|
}
|
|
59
226
|
function render() {
|
|
60
227
|
if (linesDrawn > 0) {
|
|
61
|
-
output.write(
|
|
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);
|
|
228
|
+
output.write(promptRedrawPrefix(drawnInputRow));
|
|
74
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.
|
|
232
|
+
const columns = opts.columns ?? output.columns;
|
|
233
|
+
const boxedNow = preferBoxedUi(columns);
|
|
234
|
+
const inputRowNow = (opts.prompt ? 1 : 0) + (boxedNow ? 1 : 0);
|
|
75
235
|
const dropdown = currentDropdown();
|
|
236
|
+
let overlay = [];
|
|
76
237
|
if (dropdown.length) {
|
|
77
238
|
if (dropdownIndex >= dropdown.length)
|
|
78
239
|
dropdownIndex = dropdown.length - 1;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
rows.push(i === dropdownIndex ? ansi.invert(label) : ansi.gray(label));
|
|
82
|
-
});
|
|
240
|
+
const maxRows = dropdownMaxRows(opts.rows ?? output.rows, inputRowNow);
|
|
241
|
+
overlay = dropdownOverlayRows(dropdown, dropdownIndex, maxRows);
|
|
83
242
|
}
|
|
84
243
|
else {
|
|
85
244
|
dropdownIndex = 0;
|
|
86
245
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
246
|
+
const frame = buildPromptFrame({
|
|
247
|
+
prompt: opts.prompt,
|
|
248
|
+
buffer,
|
|
249
|
+
cursor,
|
|
250
|
+
columns,
|
|
251
|
+
scrollStart,
|
|
252
|
+
overlay,
|
|
253
|
+
});
|
|
254
|
+
output.write(frame.rows.join("\r\n") + "\r\n");
|
|
255
|
+
linesDrawn = frame.rows.length;
|
|
256
|
+
drawnInputRow = frame.inputRow;
|
|
257
|
+
drawnBoxed = frame.boxed;
|
|
258
|
+
scrollStart = frame.scrollStart;
|
|
259
|
+
output.write(`${up(linesDrawn - frame.inputRow)}${col(frame.targetCol)}`);
|
|
260
|
+
}
|
|
261
|
+
function onResize() {
|
|
262
|
+
if (!finished)
|
|
263
|
+
render();
|
|
93
264
|
}
|
|
94
265
|
function cleanup() {
|
|
95
266
|
input.removeListener("keypress", onKeypress);
|
|
267
|
+
output.removeListener("resize", onResize);
|
|
96
268
|
input.setRawMode(wasRaw);
|
|
97
269
|
// emitKeypressEvents leaves stdin flowing; pause it so an idle shell
|
|
98
270
|
// (no command running) doesn't keep the process alive after exit/EOF.
|
|
@@ -103,9 +275,8 @@ export async function askLine(opts) {
|
|
|
103
275
|
return;
|
|
104
276
|
finished = true;
|
|
105
277
|
cleanup();
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
output.write(down(rowsBelow));
|
|
278
|
+
if (linesDrawn > 0)
|
|
279
|
+
output.write(promptClearOverlay(drawnBoxed));
|
|
109
280
|
output.write("\n");
|
|
110
281
|
if (err)
|
|
111
282
|
reject(err);
|
|
@@ -113,7 +284,7 @@ export async function askLine(opts) {
|
|
|
113
284
|
resolve(value ?? "");
|
|
114
285
|
}
|
|
115
286
|
function acceptDropdown(dropdown) {
|
|
116
|
-
|
|
287
|
+
setBuffer(`/${dropdown[dropdownIndex].name} `);
|
|
117
288
|
cursor = buffer.length;
|
|
118
289
|
dropdownIndex = 0;
|
|
119
290
|
}
|
|
@@ -129,12 +300,13 @@ export async function askLine(opts) {
|
|
|
129
300
|
finish(null, new PromptClosedError());
|
|
130
301
|
return;
|
|
131
302
|
}
|
|
132
|
-
|
|
303
|
+
setBuffer("");
|
|
133
304
|
cursor = 0;
|
|
305
|
+
scrollStart = 0;
|
|
134
306
|
historyIndex = history.length;
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
output.write(
|
|
307
|
+
dropdownIndex = 0;
|
|
308
|
+
if (linesDrawn > 0)
|
|
309
|
+
output.write(promptClearOverlay(drawnBoxed));
|
|
138
310
|
output.write(`\n${ansi.dim(CTRL_C_EXIT_HINT)}\n`);
|
|
139
311
|
linesDrawn = 0;
|
|
140
312
|
render();
|
|
@@ -145,9 +317,18 @@ export async function askLine(opts) {
|
|
|
145
317
|
finish(null, new PromptClosedError());
|
|
146
318
|
return;
|
|
147
319
|
}
|
|
148
|
-
if (key.name === "
|
|
320
|
+
if (key.name === "escape") {
|
|
149
321
|
if (dropdown.length)
|
|
150
|
-
|
|
322
|
+
dropdownDismissed = true;
|
|
323
|
+
render();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (key.name === "return") {
|
|
327
|
+
if (dropdown.length &&
|
|
328
|
+
!dropdownDismissed &&
|
|
329
|
+
!isExactSlashCommand(buffer, dropdown)) {
|
|
330
|
+
setBuffer(`/${dropdown[dropdownIndex].name}`);
|
|
331
|
+
}
|
|
151
332
|
finish(buffer);
|
|
152
333
|
return;
|
|
153
334
|
}
|
|
@@ -158,10 +339,7 @@ export async function askLine(opts) {
|
|
|
158
339
|
return;
|
|
159
340
|
}
|
|
160
341
|
if (key.name === "right") {
|
|
161
|
-
|
|
162
|
-
acceptDropdown(dropdown);
|
|
163
|
-
else
|
|
164
|
-
cursor = Math.min(buffer.length, cursor + 1);
|
|
342
|
+
cursor = Math.min(buffer.length, cursor + 1);
|
|
165
343
|
render();
|
|
166
344
|
return;
|
|
167
345
|
}
|
|
@@ -172,11 +350,11 @@ export async function askLine(opts) {
|
|
|
172
350
|
}
|
|
173
351
|
if (key.name === "up") {
|
|
174
352
|
if (dropdown.length) {
|
|
175
|
-
dropdownIndex = (dropdownIndex - 1
|
|
353
|
+
dropdownIndex = Math.max(0, dropdownIndex - 1);
|
|
176
354
|
}
|
|
177
355
|
else if (history.length) {
|
|
178
356
|
historyIndex = Math.max(0, historyIndex - 1);
|
|
179
|
-
|
|
357
|
+
setBuffer(history[historyIndex] ?? "");
|
|
180
358
|
cursor = buffer.length;
|
|
181
359
|
}
|
|
182
360
|
render();
|
|
@@ -184,11 +362,11 @@ export async function askLine(opts) {
|
|
|
184
362
|
}
|
|
185
363
|
if (key.name === "down") {
|
|
186
364
|
if (dropdown.length) {
|
|
187
|
-
dropdownIndex = (dropdownIndex + 1)
|
|
365
|
+
dropdownIndex = Math.min(dropdown.length - 1, dropdownIndex + 1);
|
|
188
366
|
}
|
|
189
367
|
else if (history.length) {
|
|
190
368
|
historyIndex = Math.min(history.length, historyIndex + 1);
|
|
191
|
-
|
|
369
|
+
setBuffer(historyIndex >= history.length ? "" : history[historyIndex] ?? "");
|
|
192
370
|
cursor = buffer.length;
|
|
193
371
|
}
|
|
194
372
|
render();
|
|
@@ -205,31 +383,31 @@ export async function askLine(opts) {
|
|
|
205
383
|
return;
|
|
206
384
|
}
|
|
207
385
|
if (key.ctrl && key.name === "u") {
|
|
208
|
-
buffer
|
|
386
|
+
setBuffer(buffer.slice(cursor));
|
|
209
387
|
cursor = 0;
|
|
210
388
|
render();
|
|
211
389
|
return;
|
|
212
390
|
}
|
|
213
391
|
if (key.ctrl && key.name === "k") {
|
|
214
|
-
buffer
|
|
392
|
+
setBuffer(buffer.slice(0, cursor));
|
|
215
393
|
render();
|
|
216
394
|
return;
|
|
217
395
|
}
|
|
218
396
|
if (key.name === "backspace") {
|
|
219
397
|
if (cursor > 0) {
|
|
220
|
-
buffer
|
|
398
|
+
setBuffer(buffer.slice(0, cursor - 1) + buffer.slice(cursor));
|
|
221
399
|
cursor -= 1;
|
|
222
400
|
}
|
|
223
401
|
render();
|
|
224
402
|
return;
|
|
225
403
|
}
|
|
226
404
|
if (key.name === "delete") {
|
|
227
|
-
buffer
|
|
405
|
+
setBuffer(buffer.slice(0, cursor) + buffer.slice(cursor + 1));
|
|
228
406
|
render();
|
|
229
407
|
return;
|
|
230
408
|
}
|
|
231
409
|
if (str && !key.ctrl && !key.meta) {
|
|
232
|
-
buffer
|
|
410
|
+
setBuffer(buffer.slice(0, cursor) + str + buffer.slice(cursor));
|
|
233
411
|
cursor += str.length;
|
|
234
412
|
render();
|
|
235
413
|
}
|
|
@@ -239,6 +417,7 @@ export async function askLine(opts) {
|
|
|
239
417
|
}
|
|
240
418
|
}
|
|
241
419
|
input.on("keypress", onKeypress);
|
|
420
|
+
output.on("resize", onResize);
|
|
242
421
|
render();
|
|
243
422
|
});
|
|
244
423
|
}
|
package/dist/ui/usage.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type UsageBarOpts = {
|
|
|
9
9
|
* `[████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 7% used`
|
|
10
10
|
*
|
|
11
11
|
* No used/limit clutter. Edge cases:
|
|
12
|
-
* - `limit === 0` → dim "not included"
|
|
12
|
+
* - `limit === 0` → dim "not included"
|
|
13
13
|
* - `limit == null` → "unlimited" (dev-bypass)
|
|
14
14
|
*/
|
|
15
15
|
export declare function usageBar(used: number, limit: number | null | undefined, opts?: UsageBarOpts): string;
|
|
@@ -22,3 +22,27 @@ export declare function nextUtcMonthStart(now?: Date): string;
|
|
|
22
22
|
export declare function formatResetLabel(iso: string | null | undefined, now?: Date): string;
|
|
23
23
|
/** Alias kept for callers that still import `formatResetDate`. */
|
|
24
24
|
export declare function formatResetDate(iso: string | null | undefined): string;
|
|
25
|
+
/** Bar cells so ` [bar] 100% used` stays inside the terminal. */
|
|
26
|
+
export declare function usageBarWidth(columns: number): number;
|
|
27
|
+
export type UsagePanelJob = {
|
|
28
|
+
job: string;
|
|
29
|
+
verdict: string;
|
|
30
|
+
thread: string;
|
|
31
|
+
credits: string;
|
|
32
|
+
when: string;
|
|
33
|
+
};
|
|
34
|
+
export type UsagePanelInput = {
|
|
35
|
+
keyPrefix: string;
|
|
36
|
+
plan: string;
|
|
37
|
+
used: number;
|
|
38
|
+
limit: number | null;
|
|
39
|
+
resetsAt: string | null | undefined;
|
|
40
|
+
jobs: UsagePanelJob[];
|
|
41
|
+
columns: number;
|
|
42
|
+
color?: boolean;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Static `/usage` panel: identity, a bar sized from `frameWidth()`, reset
|
|
46
|
+
* line, then the last five jobs. Every row is clipped to `columns`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function formatUsagePanel(input: UsagePanelInput): string[];
|
package/dist/ui/usage.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ansi } from "./ansi.js";
|
|
2
|
+
import { frameWidth } from "./box.js";
|
|
3
|
+
import { padVisible, visibleWidth } from "./width.js";
|
|
2
4
|
/**
|
|
3
5
|
* Usage meter — long thin bar + percent only:
|
|
4
6
|
* `[████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 7% used`
|
|
5
7
|
*
|
|
6
8
|
* No used/limit clutter. Edge cases:
|
|
7
|
-
* - `limit === 0` → dim "not included"
|
|
9
|
+
* - `limit === 0` → dim "not included"
|
|
8
10
|
* - `limit == null` → "unlimited" (dev-bypass)
|
|
9
11
|
*/
|
|
10
12
|
export function usageBar(used, limit, opts = {}) {
|
|
@@ -65,3 +67,50 @@ export function formatResetLabel(iso, now = new Date()) {
|
|
|
65
67
|
export function formatResetDate(iso) {
|
|
66
68
|
return formatResetLabel(iso);
|
|
67
69
|
}
|
|
70
|
+
/** Bar cells so ` [bar] 100% used` stays inside the terminal. */
|
|
71
|
+
export function usageBarWidth(columns) {
|
|
72
|
+
return Math.max(8, frameWidth(80, columns) - 13);
|
|
73
|
+
}
|
|
74
|
+
function clipLine(line, columns) {
|
|
75
|
+
if (visibleWidth(line) <= columns)
|
|
76
|
+
return line;
|
|
77
|
+
return padVisible(line, columns);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Static `/usage` panel: identity, a bar sized from `frameWidth()`, reset
|
|
81
|
+
* line, then the last five jobs. Every row is clipped to `columns`.
|
|
82
|
+
*/
|
|
83
|
+
export function formatUsagePanel(input) {
|
|
84
|
+
const columns = Math.max(20, input.columns);
|
|
85
|
+
const color = input.color ?? false;
|
|
86
|
+
const identity = `● ${input.keyPrefix} · ${input.plan}`;
|
|
87
|
+
const bar = usageBar(input.used, input.limit, {
|
|
88
|
+
width: usageBarWidth(columns),
|
|
89
|
+
color,
|
|
90
|
+
});
|
|
91
|
+
const lines = [
|
|
92
|
+
` ${identity}`,
|
|
93
|
+
` ${bar}`,
|
|
94
|
+
` ${formatResetLabel(input.resetsAt)}`,
|
|
95
|
+
"",
|
|
96
|
+
];
|
|
97
|
+
if (input.jobs.length === 0) {
|
|
98
|
+
lines.push(" No review jobs yet.");
|
|
99
|
+
return lines.map((line) => clipLine(line, columns));
|
|
100
|
+
}
|
|
101
|
+
const inner = Math.max(20, columns - 2);
|
|
102
|
+
const jobW = 8;
|
|
103
|
+
const verdictW = 15;
|
|
104
|
+
const creditsW = 7;
|
|
105
|
+
const whenW = 8;
|
|
106
|
+
const gaps = 8;
|
|
107
|
+
const threadW = Math.max(6, inner - jobW - verdictW - creditsW - whenW - gaps);
|
|
108
|
+
const header = ["JOB", "VERDICT", "THREAD", "CREDITS", "WHEN"];
|
|
109
|
+
const widths = [jobW, verdictW, threadW, creditsW, whenW];
|
|
110
|
+
const fmt = (cols) => " " + cols.map((c, i) => padVisible(c, widths[i])).join(" ");
|
|
111
|
+
lines.push(fmt(header));
|
|
112
|
+
for (const job of input.jobs.slice(0, 5)) {
|
|
113
|
+
lines.push(fmt([job.job, job.verdict, job.thread, job.credits, job.when]));
|
|
114
|
+
}
|
|
115
|
+
return lines.map((line) => clipLine(line, columns));
|
|
116
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal cell arithmetic. Everything that frames, pads, or parks a cursor
|
|
3
|
+
* must count *cells*, not UTF-16 units: CJK is two cells, combining marks
|
|
4
|
+
* and ZWJ are zero, an astral emoji is one grapheme. Getting this wrong is
|
|
5
|
+
* why the right border of the prompt box drifted on non-Latin input.
|
|
6
|
+
*/
|
|
7
|
+
export declare function stripSgr(str: string): string;
|
|
8
|
+
/** Cells for one code point on its own (no cluster context). */
|
|
9
|
+
export declare function codePointWidth(cp: number): number;
|
|
10
|
+
/**
|
|
11
|
+
* Cells one grapheme cluster occupies. A cluster is as wide as its widest
|
|
12
|
+
* base (a ZWJ family emoji is 2, not 6); VS16 promotes a text-style symbol
|
|
13
|
+
* to emoji presentation (2 cells); a cluster of nothing but marks is 0.
|
|
14
|
+
*/
|
|
15
|
+
export declare function graphemeWidth(cluster: string): number;
|
|
16
|
+
/** Terminal cells a string occupies, ignoring SGR colour codes. */
|
|
17
|
+
export declare function visibleWidth(str: string): number;
|
|
18
|
+
/**
|
|
19
|
+
* Take the cells `[start, start + cells)` of `str`, keeping every SGR code
|
|
20
|
+
* (so colours survive) and substituting spaces for a wide glyph cut by
|
|
21
|
+
* either edge, so the result's geometry is exactly what the caller asked
|
|
22
|
+
* for even when a CJK character straddles the window.
|
|
23
|
+
*/
|
|
24
|
+
export declare function sliceVisible(str: string, start: number, cells: number): string;
|
|
25
|
+
/** Pad or truncate to an exact cell width (ANSI-safe). */
|
|
26
|
+
export declare function padVisible(line: string, width: number): string;
|