@nbtca/prompt 1.5.0 → 1.5.2

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/dist/app/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ansi, ensureCursorRestored } from '../core/canvas.js';
2
- import { composeFrame, computeBodyRows } from './frame.js';
3
- import { routeGlobalKey } from './keys.js';
2
+ import { composeFrameLines, computeBodyRows, diffFrame } from './frame.js';
3
+ import { isPrintableKey, KeyStreamDecoder, routeGlobalKey } from './keys.js';
4
4
  import { renderHeader, renderFooter, resolveChromeLayout } from './chrome.js';
5
5
  import { homeView } from './views/home.js';
6
6
  import { scheduleView } from './views/schedule.js';
@@ -8,6 +8,7 @@ import { docsView } from './views/docs.js';
8
8
  import { eventsView } from './views/events.js';
9
9
  import { settingsView } from './views/settings.js';
10
10
  import { getAppTabs } from './tabs.js';
11
+ import { SPINNER_FRAME_MS } from '../core/components/spinner.js';
11
12
  export async function runApp() {
12
13
  if (!process.stdin.isTTY || !process.stdout.isTTY)
13
14
  return;
@@ -15,6 +16,14 @@ export async function runApp() {
15
16
  let scroll = 0;
16
17
  let running = true;
17
18
  let suspended = false;
19
+ let entered = false;
20
+ const lifecycle = new AbortController();
21
+ const keyDecoder = new KeyStreamDecoder();
22
+ let keyFlushTimer;
23
+ let clockTimer;
24
+ let busyTimer;
25
+ let painted;
26
+ let lastBody = { length: 0, height: 0 };
18
27
  const viewIds = getAppTabs().map((tab) => tab.id);
19
28
  const nativeViews = {
20
29
  home: homeView,
@@ -38,6 +47,7 @@ export async function runApp() {
38
47
  return { rows: process.stdout.rows || 24, cols: process.stdout.columns || 80 };
39
48
  }
40
49
  const ctx = {
50
+ signal: lifecycle.signal,
41
51
  get size() {
42
52
  return size();
43
53
  },
@@ -67,14 +77,47 @@ export async function runApp() {
67
77
  const tabs = getAppTabs();
68
78
  const chrome = resolveChromeLayout(rows);
69
79
  const header = renderHeader(tabs, view, cols, chrome.headerLines);
70
- const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols), chrome.footerLines);
71
80
  const body = active?.render(ctx) ?? [];
72
81
  const bodyScroll = active?.capturesInput?.() ? Number.MAX_SAFE_INTEGER : scroll;
73
- process.stdout.write(ansi.home + composeFrame(header, body, footer, rows, cols, bodyScroll) + ansi.eraseDown);
82
+ const height = computeBodyRows(rows, chrome.headerLines, chrome.footerLines);
83
+ lastBody = { length: body.length, height };
84
+ const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols), chrome.footerLines, active?.scrollsBody?.() === true ? scrollPercent() : undefined);
85
+ const lines = composeFrameLines(header, body, footer, rows, cols, bodyScroll);
86
+ const patch = diffFrame(painted?.cols === cols ? painted.lines : undefined, lines);
87
+ painted = { cols, lines };
88
+ if (patch)
89
+ process.stdout.write(patch);
90
+ scheduleBusyTick(active?.isBusy?.() === true);
74
91
  }
75
- function onKey(data) {
76
- const key = data.toString();
92
+ function maxScroll() {
93
+ return Math.max(0, lastBody.length - lastBody.height);
94
+ }
95
+ function scrollPercent() {
96
+ const max = maxScroll();
97
+ if (max === 0)
98
+ return undefined;
99
+ return `${Math.round((Math.min(scroll, max) / max) * 100)}%`;
100
+ }
101
+ function scrollTo(next) {
102
+ scroll = Math.min(Math.max(0, next), maxScroll());
103
+ render();
104
+ }
105
+ function scheduleBusyTick(busy) {
106
+ if (busy && running && busyTimer === undefined) {
107
+ busyTimer = setTimeout(() => {
108
+ busyTimer = undefined;
109
+ render();
110
+ }, SPINNER_FRAME_MS);
111
+ return;
112
+ }
113
+ if (busy || busyTimer === undefined)
114
+ return;
115
+ clearTimeout(busyTimer);
116
+ busyTimer = undefined;
117
+ }
118
+ function dispatchKey(key) {
77
119
  if (key === '\x03') {
120
+ process.exitCode = 130;
78
121
  quit();
79
122
  return;
80
123
  } // Ctrl-C always quits, even mid-capture.
@@ -112,22 +155,88 @@ export async function runApp() {
112
155
  return;
113
156
  }
114
157
  const page = Math.max(1, ctx.bodyRows - 2);
115
- scroll = Math.max(0, scroll + g.scrollBy * page);
116
- render();
158
+ scrollTo(scroll + g.scrollBy * page);
159
+ return;
160
+ }
161
+ if ((g.scrollLines !== undefined || g.scrollTo !== undefined) && active?.scrollsBody?.()) {
162
+ if (g.scrollTo === 'top')
163
+ scrollTo(0);
164
+ else if (g.scrollTo === 'end')
165
+ scrollTo(maxScroll());
166
+ else
167
+ scrollTo(scroll + (g.scrollLines ?? 0));
117
168
  return;
118
169
  }
119
170
  active?.handleKey?.(key, ctx);
120
171
  render();
121
172
  }
173
+ function dispatchKeys(keys) {
174
+ for (let index = 0; index < keys.length && running && !suspended; index += 1) {
175
+ let key = keys[index];
176
+ if (key === undefined)
177
+ continue;
178
+ if (nativeViews[view]?.capturesInput?.() && isPrintableKey(key)) {
179
+ while (index + 1 < keys.length) {
180
+ const next = keys[index + 1];
181
+ if (next === undefined || !isPrintableKey(next))
182
+ break;
183
+ key += next;
184
+ index += 1;
185
+ }
186
+ }
187
+ dispatchKey(key);
188
+ }
189
+ }
190
+ function scheduleClock() {
191
+ if (!running)
192
+ return;
193
+ clockTimer = setTimeout(() => {
194
+ clockTimer = undefined;
195
+ render();
196
+ scheduleClock();
197
+ }, 60_000 - (Date.now() % 60_000));
198
+ }
199
+ function clearKeyFlush() {
200
+ if (keyFlushTimer === undefined)
201
+ return;
202
+ clearTimeout(keyFlushTimer);
203
+ keyFlushTimer = undefined;
204
+ }
205
+ function onKey(data) {
206
+ clearKeyFlush();
207
+ dispatchKeys(keyDecoder.write(data));
208
+ if (!running || suspended || !keyDecoder.hasPending)
209
+ return;
210
+ keyFlushTimer = setTimeout(() => {
211
+ keyFlushTimer = undefined;
212
+ dispatchKeys(keyDecoder.flush());
213
+ }, 20);
214
+ }
122
215
  function enter() {
216
+ if (entered || lifecycle.signal.aborted)
217
+ return;
218
+ entered = true;
219
+ painted = undefined;
220
+ keyDecoder.reset();
123
221
  ensureCursorRestored();
124
- process.stdout.write(ansi.enterAlt + ansi.hideCursor);
125
- if (process.stdin.isTTY)
126
- process.stdin.setRawMode(true);
127
- process.stdin.resume();
128
- process.stdin.on('data', onKey);
222
+ try {
223
+ process.stdout.write(ansi.enterAlt + ansi.hideCursor);
224
+ if (process.stdin.isTTY)
225
+ process.stdin.setRawMode(true);
226
+ process.stdin.resume();
227
+ process.stdin.on('data', onKey);
228
+ }
229
+ catch (error) {
230
+ leave();
231
+ throw error;
232
+ }
129
233
  }
130
234
  function leave() {
235
+ if (!entered)
236
+ return;
237
+ entered = false;
238
+ clearKeyFlush();
239
+ keyDecoder.reset();
131
240
  process.stdin.removeListener('data', onKey);
132
241
  if (process.stdin.isTTY)
133
242
  process.stdin.setRawMode(false);
@@ -162,6 +271,15 @@ export async function runApp() {
162
271
  leave();
163
272
  }
164
273
  function onSigint() {
274
+ process.exitCode = 130;
275
+ quit();
276
+ }
277
+ function onSigterm() {
278
+ process.exitCode = 143;
279
+ quit();
280
+ }
281
+ function onSighup() {
282
+ process.exitCode = 129;
165
283
  quit();
166
284
  }
167
285
  let resolveRun;
@@ -172,6 +290,12 @@ export async function runApp() {
172
290
  if (!running)
173
291
  return;
174
292
  running = false;
293
+ lifecycle.abort();
294
+ if (clockTimer !== undefined) {
295
+ clearTimeout(clockTimer);
296
+ clockTimer = undefined;
297
+ }
298
+ scheduleBusyTick(false);
175
299
  try {
176
300
  leave();
177
301
  }
@@ -179,16 +303,21 @@ export async function runApp() {
179
303
  process.stdout.removeListener('resize', onResize);
180
304
  process.removeListener('exit', onExit);
181
305
  process.removeListener('SIGINT', onSigint);
306
+ process.removeListener('SIGTERM', onSigterm);
307
+ process.removeListener('SIGHUP', onSighup);
182
308
  resolveRun();
183
309
  }
184
310
  }
185
311
  process.on('exit', onExit);
186
312
  process.stdout.on('resize', onResize);
187
313
  process.once('SIGINT', onSigint);
314
+ process.once('SIGTERM', onSigterm);
315
+ process.once('SIGHUP', onSighup);
188
316
  try {
189
317
  enter();
190
318
  loadView('home');
191
319
  render();
320
+ scheduleClock();
192
321
  await done;
193
322
  }
194
323
  finally {
@@ -110,12 +110,23 @@ function interactiveFooterHint(tabCount, cols) {
110
110
  ];
111
111
  return fitFooterHint(cols, ...candidates);
112
112
  }
113
- export function renderFooter(_active, cols, tabCount, overrideHint, lineCount = FOOTER_LINES) {
113
+ export function renderFooter(_active, cols, tabCount, overrideHint, lineCount = FOOTER_LINES, position) {
114
114
  if (lineCount === 0)
115
115
  return [];
116
116
  const rule = renderRule(cols);
117
117
  const hintText = overrideHint ?? interactiveFooterHint(tabCount, cols);
118
118
  const indent = visualWidth(space.indent + hintText) <= cols ? space.indent : '';
119
119
  const hint = indent + type.hint(hintText);
120
- return lineCount === 1 ? [hint] : [rule, hint];
120
+ return lineCount === 1
121
+ ? [withPosition(hint, hintText, indent, position, cols)]
122
+ : [rule, withPosition(hint, hintText, indent, position, cols)];
123
+ }
124
+ function withPosition(hint, hintText, indent, position, cols) {
125
+ if (position === undefined)
126
+ return hint;
127
+ const margin = visualWidth(space.indent) < cols ? space.indent : '';
128
+ const gap = cols - visualWidth(indent + hintText) - visualWidth(position) - visualWidth(margin);
129
+ if (gap < 2)
130
+ return hint;
131
+ return hint + ' '.repeat(gap) + type.hint(position) + margin;
121
132
  }
package/dist/app/frame.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ansi } from '../core/canvas.js';
1
2
  import { clipAnsiToVisualWidth, visualWidth } from '../core/text.js';
2
3
  export function clipToWidth(line, cols) {
3
4
  if (visualWidth(line) <= cols)
@@ -17,12 +18,23 @@ export function fitBody(lines, height, scroll, cols) {
17
18
  out.push(' '.repeat(cols));
18
19
  return out;
19
20
  }
20
- export function composeFrame(header, body, footer, rows, cols, scroll) {
21
+ export function composeFrameLines(header, body, footer, rows, cols, scroll) {
21
22
  const h = header.map((l) => fitLine(l, cols));
22
23
  const f = footer.map((l) => fitLine(l, cols));
23
24
  const bodyH = Math.max(0, rows - h.length - f.length);
24
25
  const b = fitBody(body, bodyH, scroll, cols);
25
- return [...h, ...b, ...f].slice(0, rows).join('\n');
26
+ return [...h, ...b, ...f].slice(0, rows);
27
+ }
28
+ export function diffFrame(prev, next) {
29
+ if (prev?.length !== next.length)
30
+ return ansi.home + next.join('\n') + ansi.eraseDown;
31
+ let out = '';
32
+ for (let row = 0; row < next.length; row += 1) {
33
+ const line = next[row];
34
+ if (line !== undefined && prev[row] !== line)
35
+ out += ansi.cursorToRow(row + 1) + line;
36
+ }
37
+ return out;
26
38
  }
27
39
  export function computeBodyRows(rows, headerLines, footerLines) {
28
40
  return Math.max(0, rows - headerLines - footerLines);
package/dist/app/keys.js CHANGED
@@ -1,3 +1,103 @@
1
+ const ESC = '\x1b';
2
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3
+ function isControl(value) {
4
+ const code = value.charCodeAt(0);
5
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
6
+ }
7
+ export function isPrintableKey(value) {
8
+ if (value.length === 0 || value.startsWith(ESC))
9
+ return false;
10
+ return Array.from(value).every((char) => !isControl(char));
11
+ }
12
+ function escapeSequenceLength(value) {
13
+ if (value.length === 1)
14
+ return null;
15
+ const introducer = value[1];
16
+ if (introducer === ESC || (introducer !== undefined && isControl(introducer)))
17
+ return 1;
18
+ if (introducer === '[' || introducer === 'O') {
19
+ for (let index = 2; index < value.length; index += 1) {
20
+ const code = value.charCodeAt(index);
21
+ if (code >= 0x40 && code <= 0x7e)
22
+ return index + 1;
23
+ if (code < 0x20 || code > 0x3f)
24
+ return 1;
25
+ }
26
+ return null;
27
+ }
28
+ if (introducer === ']' || introducer === 'P' || introducer === '^' || introducer === '_') {
29
+ for (let index = 2; index < value.length; index += 1) {
30
+ if (value[index] === '\x07')
31
+ return index + 1;
32
+ if (value[index] === ESC && value[index + 1] === '\\')
33
+ return index + 2;
34
+ }
35
+ return null;
36
+ }
37
+ const segment = GRAPHEME_SEGMENTER.segment(value.slice(1))[Symbol.iterator]().next();
38
+ return segment.done ? null : 1 + segment.value.segment.length;
39
+ }
40
+ export class KeyStreamDecoder {
41
+ decoder = new TextDecoder();
42
+ pending = '';
43
+ get hasPending() {
44
+ return this.pending.length > 0;
45
+ }
46
+ write(data) {
47
+ this.pending += typeof data === 'string' ? data : this.decoder.decode(data, { stream: true });
48
+ return this.drain(false);
49
+ }
50
+ flush() {
51
+ return this.drain(true);
52
+ }
53
+ reset() {
54
+ this.pending = '';
55
+ this.decoder = new TextDecoder();
56
+ }
57
+ drain(flush) {
58
+ const keys = [];
59
+ let offset = 0;
60
+ while (offset < this.pending.length) {
61
+ const value = this.pending.slice(offset);
62
+ const first = value[0];
63
+ if (first === undefined)
64
+ break;
65
+ if (first === ESC) {
66
+ const length = escapeSequenceLength(value);
67
+ if (length === null) {
68
+ if (flush) {
69
+ keys.push(value);
70
+ offset = this.pending.length;
71
+ }
72
+ break;
73
+ }
74
+ keys.push(value.slice(0, length));
75
+ offset += length;
76
+ continue;
77
+ }
78
+ if (isControl(first)) {
79
+ keys.push(first);
80
+ offset += 1;
81
+ continue;
82
+ }
83
+ let end = offset;
84
+ while (end < this.pending.length) {
85
+ const char = this.pending[end];
86
+ if (char === undefined || char === ESC || isControl(char))
87
+ break;
88
+ end += char.length;
89
+ }
90
+ const run = this.pending.slice(offset, end);
91
+ const segments = Array.from(GRAPHEME_SEGMENTER.segment(run), ({ segment }) => segment);
92
+ for (const segment of segments) {
93
+ keys.push(segment);
94
+ offset += segment.length;
95
+ }
96
+ }
97
+ this.pending = this.pending.slice(offset);
98
+ return keys;
99
+ }
100
+ }
1
101
  function switchResult(target) {
2
102
  return target === undefined ? { handled: true } : { switchTo: target, handled: true };
3
103
  }
@@ -17,8 +117,16 @@ export function routeGlobalKey(key, viewIds, current) {
17
117
  }
18
118
  if (key === '\x1b[5~')
19
119
  return { scrollBy: -1, handled: true };
20
- if (key === '\x1b[6~')
120
+ if (key === '\x1b[6~' || key === ' ')
21
121
  return { scrollBy: 1, handled: true };
122
+ if (key === '\x1b[A')
123
+ return { scrollLines: -1, handled: true };
124
+ if (key === '\x1b[B')
125
+ return { scrollLines: 1, handled: true };
126
+ if (key === '\x1b[H')
127
+ return { scrollTo: 'top', handled: true };
128
+ if (key === '\x1b[F')
129
+ return { scrollTo: 'end', handled: true };
22
130
  if (/^[1-9]$/.test(key)) {
23
131
  const idx = Number(key) - 1;
24
132
  if (idx < viewIds.length)
@@ -2,6 +2,7 @@ import { type, space } from '../../core/theme.js';
2
2
  import { t } from '../../i18n/index.js';
3
3
  import { renderListFieldWithContext } from '../fields/list-field.js';
4
4
  import { visualWidth, wrapAnsiToVisualWidth, wrapAnsiWithIndent } from '../../core/text.js';
5
+ import { loadingLines } from '../../core/components/spinner.js';
5
6
  function hintLines(label, cols) {
6
7
  return wrapAnsiWithIndent(type.hint(label), cols, space.indent);
7
8
  }
@@ -36,7 +37,7 @@ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY
36
37
  let lines;
37
38
  switch (state.mode) {
38
39
  case 'loading':
39
- lines = hintLines(trans.common.loading, cols);
40
+ lines = loadingLines(trans.common.loading, cols);
40
41
  break;
41
42
  case 'sections':
42
43
  lines = state.sectionsField?.render(bodyRows, cols) ?? [];
@@ -66,7 +67,7 @@ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY
66
67
  : [];
67
68
  break;
68
69
  case 'readerLoading':
69
- lines = hintLines(trans.docs.loadingFile, cols);
70
+ lines = loadingLines(trans.docs.loadingFile, cols);
70
71
  break;
71
72
  case 'reader':
72
73
  lines = state.readerLinksField