termux-dev 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +92 -10
  5. package/dist/cli/banner.js +1 -1
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/headless.js +23 -11
  15. package/dist/cli/index.js +1377 -480
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +256 -56
  24. package/dist/cli/resume.js +15 -0
  25. package/dist/cli/rewind.js +51 -0
  26. package/dist/cli/runtime_errors.js +6 -0
  27. package/dist/cli/server.js +85 -24
  28. package/dist/cli/settings.js +307 -0
  29. package/dist/cli/statusline.js +1 -1
  30. package/dist/cli/theme.js +126 -108
  31. package/dist/cli/transcript.js +114 -0
  32. package/dist/cli/typeahead.js +371 -0
  33. package/dist/cli/ui.js +1021 -0
  34. package/dist/cli/updater.js +23 -21
  35. package/dist/cli/usage-panel.js +242 -0
  36. package/dist/cli/width.js +178 -0
  37. package/dist/core/capabilities.js +433 -0
  38. package/dist/core/catalog-util.js +27 -0
  39. package/dist/core/checkpoint.js +90 -0
  40. package/dist/core/context-stats.js +29 -0
  41. package/dist/core/cost.js +191 -0
  42. package/dist/core/formats.js +199 -0
  43. package/dist/core/history.js +51 -1
  44. package/dist/core/hooks.js +79 -0
  45. package/dist/core/i18n.js +42 -0
  46. package/dist/core/local-config.js +69 -0
  47. package/dist/core/locales/de.js +210 -0
  48. package/dist/core/locales/en.js +221 -0
  49. package/dist/core/locales/es.js +210 -0
  50. package/dist/core/locales/fr.js +210 -0
  51. package/dist/core/locales/ja.js +210 -0
  52. package/dist/core/locales/pt.js +210 -0
  53. package/dist/core/locales/ru.js +210 -0
  54. package/dist/core/locales/uk.js +210 -0
  55. package/dist/core/locales/zh.js +210 -0
  56. package/dist/core/loop.js +125 -49
  57. package/dist/core/memory.js +39 -12
  58. package/dist/core/models.js +33 -20
  59. package/dist/core/network.js +12 -11
  60. package/dist/core/notify.js +54 -0
  61. package/dist/core/planfile.js +43 -0
  62. package/dist/core/price-catalog.json +1 -0
  63. package/dist/core/prices.js +249 -0
  64. package/dist/core/reasoning-catalog.json +1 -0
  65. package/dist/core/reasoning.js +393 -0
  66. package/dist/core/redact.js +41 -0
  67. package/dist/core/secure-file.js +22 -0
  68. package/dist/core/session.js +16 -5
  69. package/dist/core/snapshot.js +14 -2
  70. package/dist/core/title.js +61 -0
  71. package/dist/core/types.js +1 -1
  72. package/dist/core/usage.js +43 -7
  73. package/dist/core/vision-catalog.json +1 -0
  74. package/dist/core/vision.js +123 -0
  75. package/dist/mcp/client.js +36 -5
  76. package/dist/mcp/http-client.js +174 -0
  77. package/dist/mcp/manager.js +330 -93
  78. package/dist/permissions/guard.js +385 -117
  79. package/dist/prompts/builder.js +46 -38
  80. package/dist/providers/anthropic.js +517 -0
  81. package/dist/providers/index.js +18 -1
  82. package/dist/providers/openai.js +138 -125
  83. package/dist/providers/shared.js +116 -0
  84. package/dist/providers/toolguard.js +101 -0
  85. package/dist/tools/bash.js +141 -51
  86. package/dist/tools/diagnostics.js +76 -48
  87. package/dist/tools/fs.js +77 -5
  88. package/dist/tools/index.js +2 -1
  89. package/dist/tools/mcp.js +72 -0
  90. package/dist/tools/packages.js +16 -1
  91. package/dist/tools/plan.js +8 -8
  92. package/dist/tools/questions.js +50 -54
  93. package/dist/tools/server.js +6 -0
  94. package/dist/tools/todo.js +20 -18
  95. package/dist/tools/web.js +143 -8
  96. package/package.json +1 -1
@@ -0,0 +1,432 @@
1
+ import { LiveBlock } from './liveblock.js';
2
+ import { normalizeKey, splitKeys } from './keys.js';
3
+ import { palette } from './ui.js';
4
+ import { displayWidth, padEndWidth, truncateWidth, wrapText } from './width.js';
5
+ import { t } from '../core/i18n.js';
6
+ /** What can be done with a server in its current state, in the order shown. */
7
+ export function actionsFor(status) {
8
+ if (status.status === 'disabled')
9
+ return ['enable'];
10
+ const list = [];
11
+ if (status.status === 'connected' && status.toolsCount > 0)
12
+ list.push('tools');
13
+ list.push('reconnect', 'disable');
14
+ return list;
15
+ }
16
+ const actionLabel = (a) => t(`mcp.action.${a}`);
17
+ /** The servers in the order of the list: the user's own first, then the project's. */
18
+ export function orderedServers(statuses) {
19
+ return [...statuses.filter((s) => s.scope !== 'project'), ...statuses.filter((s) => s.scope === 'project')];
20
+ }
21
+ /** How many rows the current screen can focus. */
22
+ export function rowCount(view) {
23
+ const { screen } = view;
24
+ const status = (name) => view.statuses.find((s) => s.name === name);
25
+ switch (screen.kind) {
26
+ case 'servers':
27
+ return view.statuses.length;
28
+ case 'server': {
29
+ const s = status(screen.name);
30
+ return s ? actionsFor(s).length : 0;
31
+ }
32
+ case 'tools':
33
+ return status(screen.name)?.toolInfo?.length ?? 0;
34
+ default:
35
+ return 0;
36
+ }
37
+ }
38
+ function statusGlyph(s) {
39
+ switch (s.status) {
40
+ case 'connected': return palette.ok('●');
41
+ case 'connecting': return palette.warn('◌');
42
+ case 'failed': return palette.err('×');
43
+ default: return palette.faint('○');
44
+ }
45
+ }
46
+ function statusText(s) {
47
+ switch (s.status) {
48
+ case 'connected': return palette.ok(t('mcp.status.connected'));
49
+ case 'connecting': return palette.warn(t('mcp.status.connecting'));
50
+ case 'failed': return palette.err(t('mcp.status.failed'));
51
+ default: return palette.faint(t('mcp.status.disabled'));
52
+ }
53
+ }
54
+ function toolTags(tool) {
55
+ const tags = [];
56
+ if (tool.readOnly)
57
+ tags.push(t('mcp.tag.readOnly'));
58
+ if (tool.destructive)
59
+ tags.push(t('mcp.tag.destructive'));
60
+ if (tool.openWorld)
61
+ tags.push(t('mcp.tag.openWorld'));
62
+ return tags.join(' · ');
63
+ }
64
+ /** Shows `max` rows of a long list, always including the anchor, with "↑ n more" / "↓ n more" at the cut edges. */
65
+ function windowRows(rows, max, width) {
66
+ if (rows.length <= max)
67
+ return rows;
68
+ const anchor = Math.max(0, rows.findIndex((r) => r.anchor));
69
+ let start = Math.max(0, Math.min(rows.length - max, anchor - Math.floor(max / 2)));
70
+ const end = start + max;
71
+ const out = rows.slice(start, end);
72
+ const above = start;
73
+ const below = rows.length - end;
74
+ const marker = (arrow, n) => ({ text: ' ' + palette.faint(truncateWidth(`${arrow} ${t('mcp.more', { n })}`, width)) });
75
+ if (above > 0)
76
+ out[0] = marker('↑', above + 1);
77
+ if (below > 0)
78
+ out[out.length - 1] = marker('↓', below + 1);
79
+ // The anchor must survive the markers.
80
+ if (!out.some((r) => r.anchor) && rows[anchor])
81
+ out[Math.min(out.length - 1, Math.max(1, anchor - start))] = rows[anchor];
82
+ return out;
83
+ }
84
+ function hintsFor(view, width) {
85
+ if (view.busy)
86
+ return palette.warn(truncateWidth(t('mcp.status.connecting'), width));
87
+ const move = ['↑↓', t('settings.keys.move')];
88
+ const open = ['enter', t('mcp.keys.open')];
89
+ const back = ['esc', t('mcp.keys.back')];
90
+ const close = ['esc', t('mcp.keys.close')];
91
+ const reload = ['r', t('mcp.keys.reload')];
92
+ const variants = view.screen.kind === 'servers'
93
+ ? [[move, open, reload, close], [move, open, close], [['↑↓', ''], ['enter', ''], ['esc', '']], [['esc', '']]]
94
+ : view.screen.kind === 'tool'
95
+ ? [[back], [['esc', '']]]
96
+ : [[move, open, back], [['↑↓', ''], ['enter', ''], ['esc', '']], [['esc', '']]];
97
+ for (const items of variants) {
98
+ const text = items.map(([k, label]) => palette.text(k) + (label ? ' ' + palette.faint(label) : '')).join(palette.faint(items[0][1] ? ' · ' : ' '));
99
+ if (displayWidth(text) <= width)
100
+ return text;
101
+ }
102
+ return '';
103
+ }
104
+ function serversBody(view, iw) {
105
+ const rows = [];
106
+ const servers = orderedServers(view.statuses);
107
+ if (servers.length === 0) {
108
+ rows.push({ text: ' ' + palette.muted(t('mcp.empty')) });
109
+ for (const line of wrapText(t('mcp.emptyHint'), iw - 3))
110
+ rows.push({ text: ' ' + palette.faint(line) });
111
+ return rows;
112
+ }
113
+ const nameWidth = Math.min(24, Math.max(8, ...servers.map((s) => displayWidth(s.name))));
114
+ let scope;
115
+ servers.forEach((s, i) => {
116
+ const sc = s.scope === 'project' ? 'project' : 'user';
117
+ if (sc !== scope) {
118
+ if (scope !== undefined)
119
+ rows.push({ text: '' });
120
+ scope = sc;
121
+ rows.push({ text: ' ' + palette.faint(t(sc === 'project' ? 'mcp.scope.project' : 'mcp.scope.user')) });
122
+ }
123
+ const focused = i === view.focus;
124
+ const mark = focused ? palette.accent('❯') : ' ';
125
+ const name = focused ? palette.bold(palette.text(truncateWidth(s.name, nameWidth))) : palette.muted(truncateWidth(s.name, nameWidth));
126
+ const right = s.status === 'connected' ? palette.muted(t('mcp.toolsCount', { n: s.toolsCount }))
127
+ : s.status === 'connecting' ? palette.warn(t('mcp.status.connecting'))
128
+ : s.status === 'failed' ? palette.err(t('mcp.status.failed'))
129
+ : palette.faint(t('mcp.status.disabled'));
130
+ const left = ` ${mark} ${statusGlyph(s)} ${padEndWidth(name, nameWidth)}`;
131
+ const gap = Math.max(2, iw - displayWidth(left) - displayWidth(right) - 1);
132
+ rows.push({ text: left + ' '.repeat(gap) + right, shaded: focused, anchor: focused });
133
+ });
134
+ return rows;
135
+ }
136
+ function serverBody(view, name, iw) {
137
+ const s = view.statuses.find((x) => x.name === name);
138
+ if (!s)
139
+ return [];
140
+ const rows = [];
141
+ const labels = ['status', 'command', 'config', 'capabilities', 'tools', 'error'];
142
+ const labelW = Math.min(16, Math.max(...labels.map((l) => displayWidth(t(`mcp.detail.${l}`) + ':'))) + 1);
143
+ const field = (label, value, plainValue) => {
144
+ const head = ' ' + palette.faint(padEndWidth(t(`mcp.detail.${label}`) + ':', labelW));
145
+ const room = Math.max(8, iw - 1 - labelW - 1);
146
+ const lines = wrapText(plainValue, room);
147
+ rows.push({ text: head + (lines.length <= 1 ? value : lines[0]) });
148
+ if (lines.length > 1)
149
+ for (const l of lines.slice(1, 3))
150
+ rows.push({ text: ' ' + ' '.repeat(labelW) + l });
151
+ };
152
+ const connecting = s.status === 'connecting';
153
+ rows.push({ text: ' ' + palette.faint(padEndWidth(t('mcp.detail.status') + ':', labelW)) + statusGlyph(s) + ' ' + statusText(s) });
154
+ field('command', palette.muted(truncateWidth(s.command, iw - 1 - labelW - 1)), s.command);
155
+ if (s.source)
156
+ field('config', palette.muted(s.source), s.source);
157
+ if (s.capabilities && s.capabilities.length > 0)
158
+ field('capabilities', palette.text(s.capabilities.join(' · ')), s.capabilities.join(' · '));
159
+ if (s.status === 'connected')
160
+ field('tools', palette.muted(String(s.toolsCount)), String(s.toolsCount));
161
+ if (s.error) {
162
+ const lines = wrapText(s.error, Math.max(8, iw - 1 - labelW - 1)).slice(0, 3);
163
+ rows.push({ text: ' ' + palette.faint(padEndWidth(t('mcp.detail.error') + ':', labelW)) + palette.err(lines[0] ?? '') });
164
+ for (const l of lines.slice(1))
165
+ rows.push({ text: ' ' + ' '.repeat(labelW) + palette.err(l) });
166
+ }
167
+ rows.push({ text: '' });
168
+ if (!connecting) {
169
+ actionsFor(s).forEach((a, i) => {
170
+ const focused = i === view.focus;
171
+ const mark = focused ? palette.accent('❯') : ' ';
172
+ const label = `${i + 1}. ${actionLabel(a)}`;
173
+ rows.push({ text: ` ${mark} ${focused ? palette.bold(palette.text(label)) : palette.muted(label)}`, shaded: focused, anchor: focused });
174
+ });
175
+ }
176
+ return rows;
177
+ }
178
+ function toolsBody(view, name, iw) {
179
+ const s = view.statuses.find((x) => x.name === name);
180
+ const tools = s?.toolInfo ?? [];
181
+ if (tools.length === 0)
182
+ return [{ text: ' ' + palette.muted(t('mcp.noTools')) }];
183
+ const nameWidth = Math.min(Math.floor(iw * 0.6), Math.max(...tools.map((x) => displayWidth(x.name))) + 1);
184
+ return tools.map((tool, i) => {
185
+ const focused = i === view.focus;
186
+ const mark = focused ? palette.accent('❯') : ' ';
187
+ const left = ` ${mark} ${padEndWidth(focused ? palette.bold(palette.text(truncateWidth(tool.name, nameWidth))) : palette.muted(truncateWidth(tool.name, nameWidth)), nameWidth)}`;
188
+ const room = iw - displayWidth(left) - 1;
189
+ const tags = room > 6 ? palette.faint(truncateWidth(toolTags(tool), room)) : '';
190
+ return { text: left + ' ' + tags, shaded: focused, anchor: focused };
191
+ });
192
+ }
193
+ function toolBody(view, name, toolName, iw) {
194
+ const tool = view.statuses.find((x) => x.name === name)?.toolInfo?.find((x) => x.name === toolName);
195
+ if (!tool)
196
+ return [];
197
+ const rows = [];
198
+ const tags = toolTags(tool);
199
+ if (tags)
200
+ rows.push({ text: ' ' + palette.faint(tags) });
201
+ const desc = tool.description.replace(/\s+/g, ' ').trim();
202
+ if (desc) {
203
+ const lines = wrapText(desc, iw - 2);
204
+ for (const l of lines.slice(0, 6))
205
+ rows.push({ text: ' ' + palette.text(l) });
206
+ if (lines.length > 6)
207
+ rows.push({ text: ' ' + palette.faint('…') });
208
+ }
209
+ rows.push({ text: '' });
210
+ rows.push({ text: ' ' + palette.faint(t('mcp.params')) });
211
+ if (tool.params.length === 0)
212
+ rows.push({ text: ' ' + palette.muted(t('mcp.noParams')) });
213
+ const nameW = Math.min(24, Math.max(4, ...tool.params.map((p) => displayWidth(p.name))));
214
+ for (const p of tool.params) {
215
+ const req = p.required ? palette.warn(' ' + t('mcp.required')) : '';
216
+ const left = ' ' + palette.text(padEndWidth(truncateWidth(p.name, nameW), nameW)) + ' ' + palette.muted(truncateWidth(p.type, 14));
217
+ rows.push({ text: left + req });
218
+ const d = p.description.replace(/\s+/g, ' ').trim();
219
+ if (d)
220
+ rows.push({ text: ' ' + palette.faint(truncateWidth(d, Math.max(8, iw - 7))) });
221
+ }
222
+ return rows;
223
+ }
224
+ function titleFor(view) {
225
+ const { screen, statuses } = view;
226
+ const s = 'name' in screen ? statuses.find((x) => x.name === screen.name) : undefined;
227
+ switch (screen.kind) {
228
+ case 'servers':
229
+ return { title: t('mcp.title'), subtitle: t('mcp.count', { n: statuses.length }) };
230
+ case 'server':
231
+ return { title: screen.name, subtitle: s ? t(s.scope === 'project' ? 'mcp.scope.project' : 'mcp.scope.user') : '' };
232
+ case 'tools':
233
+ return { title: t('mcp.toolsTitle', { name: screen.name }), subtitle: s ? t('mcp.toolsCount', { n: s.toolInfo?.length ?? 0 }) : '' };
234
+ default:
235
+ return { title: screen.tool, subtitle: screen.name };
236
+ }
237
+ }
238
+ /** The whole panel as terminal rows: a framed card, or a plain list on a narrow screen. */
239
+ export function renderMcpPanel(view, cols, termRows = 30) {
240
+ const compact = cols < 60;
241
+ const W = compact ? Math.max(20, cols - 1) : Math.min(cols - 1, 76);
242
+ const iw = compact ? W - 1 : W - 4;
243
+ const { screen } = view;
244
+ let body;
245
+ switch (screen.kind) {
246
+ case 'servers':
247
+ body = serversBody(view, iw);
248
+ break;
249
+ case 'server':
250
+ body = serverBody(view, screen.name, iw);
251
+ break;
252
+ case 'tools':
253
+ body = toolsBody(view, screen.name, iw);
254
+ break;
255
+ default: body = toolBody(view, screen.name, screen.tool, iw);
256
+ }
257
+ // The frame, the blank rows and the hints take 6 rows of the screen; the rest is for the list.
258
+ const room = Math.max(3, termRows - (compact ? 3 : 6));
259
+ body = windowRows(body, room, iw);
260
+ if (body.length > room)
261
+ body = body.slice(0, room);
262
+ const { title, subtitle } = titleFor(view);
263
+ const hints = hintsFor(view, cols - 2);
264
+ if (compact) {
265
+ const head = ' ' + palette.bold(palette.accent(truncateWidth(title, W - 2))) + (subtitle ? palette.faint(' · ' + truncateWidth(subtitle, Math.max(4, W - displayWidth(title) - 6))) : '');
266
+ return [head, ...body.map((r) => (r.shaded ? palette.shade(padEndWidth(r.text, W)) : r.text)), ' ' + hints];
267
+ }
268
+ const rule = palette.rule;
269
+ const box = (content, shaded = false) => rule('│') + (shaded ? palette.shade(' ' + padEndWidth(content, iw) + ' ') : ' ' + padEndWidth(content, iw) + ' ') + rule('│');
270
+ const titleText = ' ' + palette.bold(palette.accent(truncateWidth(title, W - 14))) + ' ';
271
+ const subText = subtitle ? ' ' + palette.muted(truncateWidth(subtitle, Math.max(4, W - 8 - displayWidth(titleText)))) + ' ' : '';
272
+ const fill = Math.max(0, W - 4 - displayWidth(titleText) - displayWidth(subText));
273
+ return [
274
+ rule('╭─') + titleText + rule('─'.repeat(fill)) + subText + rule('─╮'),
275
+ box(''),
276
+ ...body.map((r) => box(r.text, r.shaded)),
277
+ box(''),
278
+ rule('╰' + '─'.repeat(W - 2) + '╯'),
279
+ ' ' + hints,
280
+ ];
281
+ }
282
+ /** Shows the panel and resolves when it is closed (Esc on the first screen, q, Ctrl+C). The panel is erased on close. */
283
+ export function runMcpPanel(controller, options = {}) {
284
+ return new Promise((resolve) => {
285
+ const { input, output } = options.io ?? { input: process.stdin, output: process.stdout };
286
+ const block = new LiveBlock(output);
287
+ let stack = [{ kind: 'servers' }];
288
+ const focusByScreen = new Map();
289
+ let busy = false;
290
+ let closed = false;
291
+ const screen = () => stack[stack.length - 1];
292
+ const screenKey = () => JSON.stringify(screen());
293
+ const view = () => ({ statuses: controller.getStatuses(), screen: screen(), focus: focusByScreen.get(screenKey()) ?? 0, busy });
294
+ const draw = () => {
295
+ if (closed)
296
+ return;
297
+ // A server that is gone after a reload: back to the list.
298
+ while (stack.length > 1) {
299
+ const top = screen();
300
+ if ('name' in top && !controller.getStatuses().some((x) => x.name === top.name))
301
+ stack.pop();
302
+ else
303
+ break;
304
+ }
305
+ const v = view();
306
+ // Statuses change while a server connects: keep the focus inside the list.
307
+ const max = Math.max(0, rowCount(v) - 1);
308
+ if (v.focus > max) {
309
+ focusByScreen.set(screenKey(), max);
310
+ v.focus = max;
311
+ }
312
+ block.draw(renderMcpPanel(v, output.columns || 80, output.rows || 30));
313
+ };
314
+ const wasRaw = !!input.isRaw;
315
+ input.setRawMode?.(true);
316
+ input.resume();
317
+ const close = () => {
318
+ if (closed)
319
+ return;
320
+ closed = true;
321
+ input.off('data', onData);
322
+ block.erase();
323
+ if (!wasRaw)
324
+ input.setRawMode?.(false);
325
+ resolve();
326
+ };
327
+ const push = (next) => {
328
+ stack.push(next);
329
+ draw();
330
+ };
331
+ const back = () => {
332
+ if (stack.length <= 1)
333
+ return close();
334
+ stack.pop();
335
+ draw();
336
+ };
337
+ const setFocus = (delta) => {
338
+ const v = view();
339
+ const count = rowCount(v);
340
+ if (count === 0)
341
+ return;
342
+ const next = Math.max(0, Math.min(count - 1, v.focus + delta));
343
+ if (next !== v.focus) {
344
+ focusByScreen.set(screenKey(), next);
345
+ draw();
346
+ }
347
+ };
348
+ /** Runs a slow action with the panel showing "connecting…" and stays on the screen afterwards. */
349
+ const run = async (action) => {
350
+ busy = true;
351
+ draw();
352
+ try {
353
+ await action();
354
+ }
355
+ catch { }
356
+ busy = false;
357
+ draw();
358
+ };
359
+ const enter = () => {
360
+ const v = view();
361
+ const s = screen();
362
+ if (s.kind === 'servers') {
363
+ const server = orderedServers(v.statuses)[v.focus];
364
+ if (server)
365
+ push({ kind: 'server', name: server.name });
366
+ }
367
+ else if (s.kind === 'server') {
368
+ const status = v.statuses.find((x) => x.name === s.name);
369
+ if (!status)
370
+ return;
371
+ choose(actionsFor(status)[v.focus], status);
372
+ }
373
+ else if (s.kind === 'tools') {
374
+ const tool = v.statuses.find((x) => x.name === s.name)?.toolInfo?.[v.focus];
375
+ if (tool)
376
+ push({ kind: 'tool', name: s.name, tool: tool.name });
377
+ }
378
+ };
379
+ const choose = (action, status) => {
380
+ if (action === 'tools')
381
+ push({ kind: 'tools', name: status.name });
382
+ else if (action === 'reconnect')
383
+ void run(() => controller.reconnect(status.name));
384
+ else if (action === 'disable')
385
+ void run(() => controller.setEnabled(status.name, false));
386
+ else if (action === 'enable')
387
+ void run(() => controller.setEnabled(status.name, true));
388
+ };
389
+ const handleKey = (key) => {
390
+ if (key === '\x03')
391
+ return close();
392
+ if (busy)
393
+ return;
394
+ if (key === '\x1b[A' || key === '\x1bOA' || key === 'k')
395
+ return setFocus(-1);
396
+ if (key === '\x1b[B' || key === '\x1bOB' || key === 'j')
397
+ return setFocus(1);
398
+ if (key === '\x1b[H' || key === '\x1b[1~' || key === '\x1bOH' || key === 'g')
399
+ return setFocus(-1e6);
400
+ if (key === '\x1b[F' || key === '\x1b[4~' || key === '\x1bOF' || key === 'G')
401
+ return setFocus(1e6);
402
+ if (key === '\r' || key === '\n' || key === '\x1b[C' || key === '\x1bOC' || key === 'l')
403
+ return enter();
404
+ if (key === '\x1b' || key === '\x1b[D' || key === '\x1bOD' || key === 'h')
405
+ return back();
406
+ if (key === 'q')
407
+ return close();
408
+ if (key === 'r') {
409
+ const s = screen();
410
+ if (s.kind === 'servers')
411
+ return void run(() => controller.reload());
412
+ if (s.kind === 'server')
413
+ return void run(() => controller.reconnect(s.name));
414
+ }
415
+ if (key >= '1' && key <= '9' && screen().kind === 'server') {
416
+ const s = screen();
417
+ const status = controller.getStatuses().find((x) => x.name === s.name);
418
+ if (status)
419
+ choose(actionsFor(status)[Number(key) - 1], status);
420
+ }
421
+ };
422
+ const onData = (chunk) => {
423
+ for (const key of splitKeys(chunk.toString())) {
424
+ if (closed)
425
+ return;
426
+ handleKey(normalizeKey(key));
427
+ }
428
+ };
429
+ input.on('data', onData);
430
+ draw();
431
+ });
432
+ }
@@ -0,0 +1,84 @@
1
+ import { splitKeys } from './keys.js';
2
+ import { pauseSpinners, renderPermissionCard, renderPermissionChoice, } from './ui.js';
3
+ /** The answers in the order the arrow keys go through them. */
4
+ function choices(offer) {
5
+ return ['allow', ...(offer.session ? ['session'] : []), ...(offer.always ? ['always'] : []), 'deny'];
6
+ }
7
+ /**
8
+ * Shows the approval card and waits for a decision:
9
+ * y / Enter on Allow → allow once n / Esc / Ctrl+C → deny
10
+ * s → allow for this session a → allow always (only when the card offers them)
11
+ * ← → Tab h l ↑ ↓ → move the selection
12
+ * The card is erased once the answer is given (the tool line above stays), and any
13
+ * running spinner is silenced meanwhile so the two never draw over each other.
14
+ *
15
+ * @param defaultAnswer preselected answer; risky requests should start on Deny.
16
+ */
17
+ export function askPermissionDecision(request, defaultAnswer = 'allow', io = { input: process.stdin, output: process.stdout }, offer = {}) {
18
+ return new Promise((resolve) => {
19
+ const { input, output } = io;
20
+ const resumeSpinner = pauseSpinners();
21
+ const cols = output.columns || 80;
22
+ const card = renderPermissionCard(request, cols);
23
+ const order = choices(offer);
24
+ let selected = order.includes(defaultAnswer) ? defaultAnswer : 'deny';
25
+ const wasRaw = !!input.isRaw;
26
+ input.setRawMode?.(true);
27
+ input.resume();
28
+ output.write('\x1b[?25l' + card.join('\n') + '\n' + renderPermissionChoice(selected, cols, offer));
29
+ const redraw = () => output.write('\r\x1b[2K' + renderPermissionChoice(selected, cols, offer));
30
+ const finish = (answer) => {
31
+ input.off('data', onData);
32
+ // Back to the first row of the card and wipe everything below it.
33
+ output.write(`\r\x1b[2K${card.length > 0 ? `\x1b[${card.length}A` : ''}\r\x1b[J\x1b[?25h`);
34
+ if (!wasRaw)
35
+ input.setRawMode?.(false);
36
+ resumeSpinner();
37
+ resolve(answer);
38
+ };
39
+ let done = false;
40
+ const handleKey = (key) => {
41
+ if (key === '\r' || key === '\n') {
42
+ done = true;
43
+ return finish(selected);
44
+ }
45
+ if (key === 'y' || key === 'Y') {
46
+ done = true;
47
+ return finish('allow');
48
+ }
49
+ if ((key === 's' || key === 'S') && offer.session) {
50
+ done = true;
51
+ return finish('session');
52
+ }
53
+ if ((key === 'a' || key === 'A') && offer.always) {
54
+ done = true;
55
+ return finish('always');
56
+ }
57
+ if (key === 'n' || key === 'N' || key === 'q' || key === '\x1b' || key === '\x03') {
58
+ done = true;
59
+ return finish('deny');
60
+ }
61
+ const back = key === '\x1b[D' || key === '\x1bOD' || key === 'h' || key === '\x1b[A' || key === '\x1bOA';
62
+ const forward = key === '\t' || key === 'l' || key === '\x1b[C' || key === '\x1bOC' || key === '\x1b[B' || key === '\x1bOB';
63
+ if (back || forward) {
64
+ const i = order.indexOf(selected);
65
+ selected = order[(i + (back ? -1 : 1) + order.length) % order.length];
66
+ redraw();
67
+ }
68
+ };
69
+ // Several key presses can arrive in one chunk (key repeat, batched input); the first
70
+ // decisive key wins and the rest of the chunk is dropped.
71
+ const onData = (chunk) => {
72
+ for (const key of splitKeys(chunk.toString())) {
73
+ if (done)
74
+ return;
75
+ handleKey(key);
76
+ }
77
+ };
78
+ input.on('data', onData);
79
+ });
80
+ }
81
+ /** The two-answer form: true when the action is allowed (once). */
82
+ export function askPermission(request, defaultAllow = true, io = { input: process.stdin, output: process.stdout }) {
83
+ return askPermissionDecision(request, defaultAllow ? 'allow' : 'deny', io).then((answer) => answer !== 'deny');
84
+ }
@@ -0,0 +1,135 @@
1
+ import { LiveBlock } from './liveblock.js';
2
+ import { splitKeys } from './keys.js';
3
+ import { palette } from './ui.js';
4
+ import { displayWidth, padEndWidth, truncateWidth, wrapWords } from './width.js';
5
+ function hints(width) {
6
+ const variants = [
7
+ [['↑↓', 'move'], ['enter', 'select'], ['esc', 'cancel']],
8
+ [['↑↓', ''], ['enter', ''], ['esc', '']],
9
+ [['enter', ''], ['esc', '']],
10
+ ];
11
+ for (const items of variants) {
12
+ const text = items.map(([k, label]) => palette.text(k) + (label ? ' ' + palette.faint(label) : '')).join(palette.faint(items[0][1] ? ' · ' : ' '));
13
+ if (displayWidth(text) <= width)
14
+ return text;
15
+ }
16
+ return '';
17
+ }
18
+ /** Of a long list only the rows around the focus are shown, with "↑ N more" / "↓ N more" at the cut edges. */
19
+ function windowItems(rows, focus, max, width) {
20
+ if (rows.length <= max)
21
+ return rows;
22
+ const start = Math.max(0, Math.min(rows.length - max, focus - Math.floor(max / 2)));
23
+ const out = rows.slice(start, start + max);
24
+ const above = start;
25
+ const below = rows.length - (start + max);
26
+ if (above > 0)
27
+ out[0] = { text: ' ' + palette.faint(truncateWidth(`↑ ${above + 1} more`, width)), index: -1 };
28
+ if (below > 0)
29
+ out[out.length - 1] = { text: ' ' + palette.faint(truncateWidth(`↓ ${below + 1} more`, width)), index: -1 };
30
+ return out;
31
+ }
32
+ export function renderPicker(options, focus, cols, termRows = 40) {
33
+ const labelWidth = options.labelWidth ?? Math.min(10, Math.max(...options.items.map((i) => displayWidth(i.label)), 4)) + 2;
34
+ const maxItems = Math.max(3, termRows - 7 - (options.notes?.length ?? 0));
35
+ const row = (item, index, width) => {
36
+ const focused = index === focus;
37
+ const isCurrent = options.current !== undefined && item.value === options.current;
38
+ const mark = focused ? palette.accent('❯') : ' ';
39
+ const dot = isCurrent ? palette.accent('●') : palette.faint('○');
40
+ const label = focused ? palette.bold(palette.text(item.label)) : palette.muted(item.label);
41
+ const prefix = ` ${mark} ${dot} ${padEndWidth(label, labelWidth)}`;
42
+ const room = Math.max(0, width - displayWidth(prefix) - 1);
43
+ const detail = item.detail && room > 6 ? palette.faint(truncateWidth(item.detail, room)) : '';
44
+ return prefix + detail;
45
+ };
46
+ // ── phone: no frame
47
+ if (cols < 60) {
48
+ const width = Math.max(20, cols - 1);
49
+ const lines = [' ' + palette.bold(palette.accent(options.title)) + (options.subtitle ? palette.faint(' · ' + truncateWidth(options.subtitle, Math.max(6, width - displayWidth(options.title) - 6))) : '')];
50
+ const all = options.items.map((item, i) => ({ text: row(item, i, width), index: i }));
51
+ for (const r of windowItems(all, focus, maxItems, width))
52
+ lines.push(r.index === focus ? palette.shade(padEndWidth(r.text, width)) : r.text);
53
+ for (const note of options.notes ?? []) {
54
+ for (const line of wrapWords(note, width - 2))
55
+ lines.push(' ' + palette.faint(line));
56
+ }
57
+ lines.push(' ' + hints(width - 1));
58
+ return lines;
59
+ }
60
+ // ── card
61
+ const W = Math.min(cols - 1, 76);
62
+ const iw = W - 4;
63
+ const rule = palette.rule;
64
+ const box = (content, shaded = false) => rule('│') + (shaded ? palette.shade(' ' + padEndWidth(content, iw) + ' ') : ' ' + padEndWidth(content, iw) + ' ') + rule('│');
65
+ const title = ' ' + palette.bold(palette.accent(options.title)) + ' ';
66
+ const subtitle = options.subtitle ? ' ' + palette.muted(truncateWidth(options.subtitle, Math.max(6, W - 12 - displayWidth(options.title)))) + ' ' : '';
67
+ const lines = [rule('╭─') + title + rule('─'.repeat(Math.max(0, W - 4 - displayWidth(title) - displayWidth(subtitle)))) + subtitle + rule('─╮')];
68
+ lines.push(box(''));
69
+ const allRows = options.items.map((item, i) => ({ text: row(item, i, iw), index: i }));
70
+ for (const r of windowItems(allRows, focus, maxItems, iw))
71
+ lines.push(box(r.text, r.index === focus));
72
+ const notes = (options.notes ?? []).flatMap((note) => wrapWords(note, iw - 3));
73
+ if (notes.length > 0) {
74
+ lines.push(box(''));
75
+ for (const line of notes)
76
+ lines.push(box(' ' + palette.faint(line)));
77
+ }
78
+ lines.push(rule('╰' + '─'.repeat(W - 2) + '╯'));
79
+ lines.push(' ' + hints(cols - 2));
80
+ return lines;
81
+ }
82
+ /** Shows the list and resolves with the chosen value, or undefined when cancelled. */
83
+ export function runPicker(options, io = { input: process.stdin, output: process.stdout }) {
84
+ return new Promise((resolve) => {
85
+ const { input, output } = io;
86
+ const block = new LiveBlock(output);
87
+ const count = options.items.length;
88
+ let focus = Math.max(0, options.items.findIndex((i) => options.current !== undefined && i.value === options.current));
89
+ let done = false;
90
+ const draw = () => block.draw(renderPicker(options, focus, output.columns || 80, output.rows || 40));
91
+ const wasRaw = !!input.isRaw;
92
+ input.setRawMode?.(true);
93
+ input.resume();
94
+ const finish = (value) => {
95
+ done = true;
96
+ input.off('data', onData);
97
+ block.erase();
98
+ if (!wasRaw)
99
+ input.setRawMode?.(false);
100
+ resolve(value);
101
+ };
102
+ const move = (delta) => {
103
+ const next = Math.max(0, Math.min(count - 1, focus + delta));
104
+ if (next !== focus) {
105
+ focus = next;
106
+ draw();
107
+ }
108
+ };
109
+ const handleKey = (key) => {
110
+ if (key === '\x1b[A' || key === '\x1bOA' || key === 'k')
111
+ return move(-1);
112
+ if (key === '\x1b[B' || key === '\x1bOB' || key === 'j')
113
+ return move(1);
114
+ if (key === '\x1b[H' || key === '\x1b[1~' || key === '\x1bOH' || key === 'g')
115
+ return move(-count);
116
+ if (key === '\x1b[F' || key === '\x1b[4~' || key === '\x1bOF' || key === 'G')
117
+ return move(count);
118
+ if (key === '\r' || key === '\n' || key === ' ')
119
+ return finish(options.items[focus].value);
120
+ if (key >= '1' && key <= '9' && Number(key) <= count)
121
+ return finish(options.items[Number(key) - 1].value);
122
+ if (key === '\x1b' || key === 'q' || key === '\x03')
123
+ return finish(undefined);
124
+ };
125
+ const onData = (chunk) => {
126
+ for (const key of splitKeys(chunk.toString())) {
127
+ if (done)
128
+ return;
129
+ handleKey(key);
130
+ }
131
+ };
132
+ input.on('data', onData);
133
+ draw();
134
+ });
135
+ }