termux-dev 1.8.2 → 1.9.1

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 (51) hide show
  1. package/README.md +16 -6
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +8 -8
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +10 -2
  7. package/dist/cli/headless.js +3 -2
  8. package/dist/cli/index.js +131 -75
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +47 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +54 -7
  14. package/dist/cli/provider-manager.js +197 -0
  15. package/dist/cli/providers.js +0 -5
  16. package/dist/cli/settings.js +38 -17
  17. package/dist/cli/typeahead.js +10 -17
  18. package/dist/cli/ui.js +33 -13
  19. package/dist/cli/update-screen.js +383 -0
  20. package/dist/cli/updater.js +119 -145
  21. package/dist/cli/usage-panel.js +17 -4
  22. package/dist/cli/width.js +18 -0
  23. package/dist/core/background.js +14 -0
  24. package/dist/core/context-stats.js +1 -0
  25. package/dist/core/cost.js +25 -11
  26. package/dist/core/custom-providers.js +161 -0
  27. package/dist/core/images.js +46 -0
  28. package/dist/core/local-config.js +3 -1
  29. package/dist/core/locales/de.js +69 -7
  30. package/dist/core/locales/en.js +69 -7
  31. package/dist/core/locales/es.js +69 -7
  32. package/dist/core/locales/fr.js +69 -7
  33. package/dist/core/locales/ja.js +69 -7
  34. package/dist/core/locales/pt.js +69 -7
  35. package/dist/core/locales/ru.js +69 -7
  36. package/dist/core/locales/uk.js +69 -7
  37. package/dist/core/locales/zh.js +69 -7
  38. package/dist/core/loop.js +32 -1
  39. package/dist/core/prices.js +8 -4
  40. package/dist/core/session-media.js +115 -0
  41. package/dist/core/session.js +17 -2
  42. package/dist/core/title.js +19 -2
  43. package/dist/core/types.js +1 -1
  44. package/dist/core/usage.js +41 -4
  45. package/dist/permissions/guard.js +3 -0
  46. package/dist/providers/anthropic.js +67 -13
  47. package/dist/providers/index.js +16 -3
  48. package/dist/providers/openai.js +99 -41
  49. package/dist/providers/shared.js +83 -0
  50. package/dist/tools/fs.js +32 -2
  51. package/package.json +1 -1
@@ -7,7 +7,8 @@ import { formatPromptHeader } from './modeborder.js';
7
7
  import { filterCommandPalette, estimatePromptTokens, formatPromptTokenBadge } from './prompt_palette.js';
8
8
  import { PromptHistoryNavigator } from './history_search.js';
9
9
  import { buildPromptFrame, moveCursorRow, palette, renderUserEcho, resolveMode } from './ui.js';
10
- import { padEndWidth, truncateWidth } from './width.js';
10
+ import { displayWidth, padEndWidth, rowsToTop, truncateWidth } from './width.js';
11
+ import { EXTENDED_KEYS_OFF, EXTENDED_KEYS_ON, isNewlineKey, normalizeKey } from './keys.js';
11
12
  export const SLASH_COMMANDS = [
12
13
  { cmd: '/new', desc: 'Start a new clean chat session' },
13
14
  { cmd: '/resume', desc: 'Resume a previous chat session' },
@@ -97,12 +98,19 @@ export function askPrompt(opts = {}) {
97
98
  if (process.stdin.isTTY) {
98
99
  process.stdin.setRawMode(true);
99
100
  process.stdout.write('\x1b[?2004h'); // Enable bracketed paste mode
101
+ // Lets the terminal tell Ctrl+Enter and Shift+Enter (a new paragraph) from Enter (send).
102
+ process.stdout.write(EXTENDED_KEYS_ON);
100
103
  }
101
104
  // ── framed prompt (clean layout) ──
102
105
  // The frame is a block of rows; the cursor rests on the input row. Redrawing moves up
103
106
  // to the top of the block, rewrites every row and returns to the cursor position.
104
107
  let frameRowsDrawn = 0;
105
108
  let frameCursorRow = 0;
109
+ // The geometry of what is on screen, to find the top of the frame again after a resize
110
+ // (the terminal re-wraps the rows, so the number of lines is not the number of rows).
111
+ let frameLineWidths = [];
112
+ let frameCursorCol = 0;
113
+ const rowsUpToFrameTop = () => rowsToTop(frameLineWidths, frameCursorRow, frameCursorCol, process.stdout.columns || 80);
106
114
  function cleanDropdownRows(items, cols, rows) {
107
115
  if (items.length === 0)
108
116
  return [];
@@ -134,7 +142,7 @@ export function askPrompt(opts = {}) {
134
142
  const rows = process.stdout.rows || 24;
135
143
  const frame = buildPromptFrame({
136
144
  cols,
137
- mode: resolveMode(!!opts.planMode, !!opts.isYolo),
145
+ mode: resolveMode(!!opts.planMode, !!opts.isYolo, !!opts.isExtra),
138
146
  model: opts.model,
139
147
  cwd: opts.cwd,
140
148
  branch: opts.branch,
@@ -149,8 +157,9 @@ export function askPrompt(opts = {}) {
149
157
  below: cleanDropdownRows(getDropdownItems(), cols, rows),
150
158
  });
151
159
  let buf = '\x1b[0m';
152
- if (frameRowsDrawn > 0 && frameCursorRow > 0)
153
- buf += `\x1b[${frameCursorRow}A`;
160
+ const up = frameRowsDrawn > 0 ? rowsUpToFrameTop() : 0;
161
+ if (up > 0)
162
+ buf += `\x1b[${up}A`;
154
163
  buf += '\r';
155
164
  frame.lines.forEach((line, i) => {
156
165
  buf += '\x1b[2K' + line + '\x1b[0m' + (i < frame.lines.length - 1 ? '\n' : '');
@@ -166,13 +175,18 @@ export function askPrompt(opts = {}) {
166
175
  process.stdout.write(buf);
167
176
  frameRowsDrawn = frame.lines.length;
168
177
  frameCursorRow = frame.cursorRow;
178
+ frameCursorCol = frame.cursorCol;
179
+ frameLineWidths = frame.lines.map((line) => displayWidth(line));
169
180
  }
170
181
  function eraseFrame() {
171
182
  if (frameRowsDrawn === 0)
172
183
  return;
173
- process.stdout.write(`\x1b[0m${frameCursorRow > 0 ? `\x1b[${frameCursorRow}A` : ''}\r\x1b[J`);
184
+ const up = rowsUpToFrameTop();
185
+ process.stdout.write(`\x1b[0m${up > 0 ? `\x1b[${up}A` : ''}\r\x1b[J`);
174
186
  frameRowsDrawn = 0;
175
187
  frameCursorRow = 0;
188
+ frameCursorCol = 0;
189
+ frameLineWidths = [];
176
190
  }
177
191
  const onResize = () => {
178
192
  if (disposed)
@@ -538,7 +552,7 @@ export function askPrompt(opts = {}) {
538
552
  disposed = true;
539
553
  unsubscribeOwner?.();
540
554
  if (process.stdout.isTTY) {
541
- process.stdout.write('\x1b[?2004l');
555
+ process.stdout.write('\x1b[?2004l' + EXTENDED_KEYS_OFF);
542
556
  }
543
557
  process.stdout.removeListener('resize', onResize);
544
558
  process.stdin.removeListener('data', onData);
@@ -580,10 +594,27 @@ export function askPrompt(opts = {}) {
580
594
  cleanup();
581
595
  resolve(fullText);
582
596
  }
597
+ /** Breaks the line at the cursor (a new paragraph). A badge such as [Pasted text #1] is never cut in two. */
598
+ function insertNewline() {
599
+ const inside = getTagSpans().find((s) => cursorPos > s.start && cursorPos < s.end);
600
+ if (inside)
601
+ cursorPos = inside.end;
602
+ input = input.slice(0, cursorPos) + '\n' + input.slice(cursorPos);
603
+ cursorPos++;
604
+ selectedIndex = 0;
605
+ render();
606
+ }
583
607
  function onData(data) {
584
608
  if (disposed)
585
609
  return;
586
- const str = data.toString('utf-8');
610
+ const raw = data.toString('utf-8');
611
+ // Ctrl+Enter, Alt+Enter and Shift+Enter break the line instead of sending it.
612
+ if (cleanLayout && isNewlineKey(raw)) {
613
+ insertNewline();
614
+ return;
615
+ }
616
+ // With the extended keyboard mode on, Ctrl+C, Esc, Ctrl+V... arrive as `CSI … u`: the usual keys again.
617
+ const str = normalizeKey(raw);
587
618
  // Bracketed paste mode handling
588
619
  if (str.includes('\x1b[200~')) {
589
620
  inBracketedPaste = true;
@@ -656,10 +687,26 @@ export function askPrompt(opts = {}) {
656
687
  return;
657
688
  }
658
689
  const items = getDropdownItems();
690
+ // Esc with the command list (or the file list of an @) open: that entry is cancelled, the line is cleared.
691
+ if (str === '\x1b' && cleanLayout && items.length > 0) {
692
+ input = '';
693
+ cursorPos = 0;
694
+ selectedIndex = 0;
695
+ render();
696
+ return;
697
+ }
659
698
  // Enter / Return
660
699
  if (str === '\r' ||
661
700
  str === '\n' ||
662
701
  (str.length === 1 && (str.charCodeAt(0) === 13 || str.charCodeAt(0) === 10))) {
702
+ // A backslash right before the cursor turns Enter into a line break (a terminal that cannot
703
+ // tell Ctrl+Enter from Enter, a phone keyboard for one).
704
+ if (cleanLayout && items.length === 0 && cursorPos > 0 && input[cursorPos - 1] === '\\') {
705
+ input = input.slice(0, cursorPos - 1) + input.slice(cursorPos);
706
+ cursorPos--;
707
+ insertNewline();
708
+ return;
709
+ }
663
710
  // Direct /image command
664
711
  if (input.trim() === '/image' ||
665
712
  (items.length > 0 &&
@@ -0,0 +1,197 @@
1
+ import { runPicker } from './picker.js';
2
+ import { runTextInput } from './finder.js';
3
+ import { askConfirm } from './confirm.js';
4
+ import { renderNotice } from './ui.js';
5
+ import { displayWidth } from './width.js';
6
+ import { t } from '../core/i18n.js';
7
+ import { NO_KEY, addCustomProvider, checkBaseUrl, customProviders, hostOfUrl, keyHint, nameProblem, removeCustomProvider, updateCustomProvider, usuallyNeedsNoKey, } from '../core/custom-providers.js';
8
+ const realUi = { text: runTextInput, picker: runPicker, confirm: askConfirm };
9
+ const defaultSay = (kind, text) => console.log(renderNotice(kind, text));
10
+ const problemText = {
11
+ empty: () => t('provider.name.empty'),
12
+ long: () => t('provider.name.long'),
13
+ taken: () => t('provider.name.taken'),
14
+ };
15
+ /** The name of the provider; asked again with the reason when it cannot be used. undefined: Esc. */
16
+ export async function askName(config, ui, opts = {}) {
17
+ let value = opts.initial ?? '';
18
+ let warning = '';
19
+ for (;;) {
20
+ const typed = await ui.text({
21
+ title: t('provider.new'),
22
+ subtitle: opts.subtitle,
23
+ label: t('provider.name.label'),
24
+ initial: value,
25
+ placeholder: t('provider.name.placeholder'),
26
+ notes: warning ? [warning] : undefined,
27
+ });
28
+ if (typed === undefined)
29
+ return undefined;
30
+ const problem = nameProblem(config, typed, opts.exceptId);
31
+ if (!problem)
32
+ return typed.trim();
33
+ value = typed;
34
+ warning = problemText[problem]();
35
+ }
36
+ }
37
+ /** The address of the API; asked again with the reason when it is not one. undefined: Esc. */
38
+ export async function askUrl(ui, opts = {}) {
39
+ let value = opts.initial ?? '';
40
+ let warning = '';
41
+ for (;;) {
42
+ const typed = await ui.text({
43
+ title: t('onboard.baseUrl.title'),
44
+ subtitle: opts.subtitle,
45
+ label: t('onboard.baseUrl.label'),
46
+ initial: value,
47
+ placeholder: t('onboard.baseUrl.placeholder'),
48
+ notes: [...(warning ? [warning] : []), t('provider.url.note')],
49
+ });
50
+ if (typed === undefined)
51
+ return undefined;
52
+ const checked = checkBaseUrl(typed);
53
+ if (checked.ok)
54
+ return checked.url;
55
+ value = typed;
56
+ warning = checked.reason === 'scheme' ? t('provider.url.scheme') : t('provider.url.invalid');
57
+ }
58
+ }
59
+ /**
60
+ * The key of the provider. A server on this machine or network may need none: that is offered
61
+ * first. Returns the key, NO_KEY for "none", or undefined for Esc.
62
+ */
63
+ export async function askKey(ui, opts) {
64
+ if (usuallyNeedsNoKey(opts.baseUrl)) {
65
+ const choice = await ui.picker({
66
+ title: t('onboard.key.title'),
67
+ subtitle: opts.name,
68
+ items: [
69
+ { value: 'none', label: t('provider.key.none'), detail: t('provider.key.noneDetail') },
70
+ { value: 'key', label: t('provider.key.enter') },
71
+ ],
72
+ current: 'none',
73
+ });
74
+ if (choice === undefined)
75
+ return undefined;
76
+ if (choice === 'none')
77
+ return NO_KEY;
78
+ }
79
+ const key = await ui.text({
80
+ title: t('onboard.key.title'),
81
+ subtitle: opts.subtitle ?? opts.name,
82
+ label: t('onboard.key.label', { provider: opts.name }),
83
+ secret: true,
84
+ notes: [t('onboard.key.note')],
85
+ });
86
+ return key === undefined ? undefined : key.trim();
87
+ }
88
+ /** Adds a provider: name, address, key. Returns its id, or null when the user went back out of the first step. */
89
+ export async function addProviderFlow(config, deps = {}) {
90
+ const ui = deps.ui ?? realUi;
91
+ const say = deps.say ?? defaultSay;
92
+ let stage = 'name';
93
+ let name = '';
94
+ let baseUrl = '';
95
+ for (;;) {
96
+ if (stage === 'name') {
97
+ const got = await askName(config, ui, { initial: name, subtitle: '1/3' });
98
+ if (got === undefined)
99
+ return null;
100
+ name = got;
101
+ stage = 'url';
102
+ }
103
+ else if (stage === 'url') {
104
+ const got = await askUrl(ui, { initial: baseUrl, subtitle: '2/3' });
105
+ if (got === undefined) {
106
+ stage = 'name';
107
+ continue;
108
+ }
109
+ baseUrl = got;
110
+ stage = 'key';
111
+ }
112
+ else {
113
+ const got = await askKey(ui, { name, baseUrl, subtitle: '3/3' });
114
+ if (got === undefined) {
115
+ stage = 'url';
116
+ continue;
117
+ }
118
+ const id = addCustomProvider(config, { name, baseUrl, apiKey: got }, deps.listedIds);
119
+ await deps.save?.(config);
120
+ say('ok', t('provider.saved', { name }));
121
+ return id;
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * The card of one saved provider: use it, change its name, address or key, delete it.
127
+ * Returns `use` when the user wants to work with it, `back` for Esc or after a delete.
128
+ */
129
+ export async function manageProviderFlow(config, id, deps = {}) {
130
+ const ui = deps.ui ?? realUi;
131
+ const say = deps.say ?? defaultSay;
132
+ for (;;) {
133
+ const own = customProviders(config)[id];
134
+ if (!own)
135
+ return 'back';
136
+ const key = config.apiKeys?.[id] ?? '';
137
+ const items = [
138
+ { value: 'use', label: t('provider.menu.use'), detail: hostOfUrl(own.baseUrl) },
139
+ { value: 'rename', label: t('provider.menu.rename'), detail: own.name },
140
+ { value: 'url', label: t('provider.menu.url'), detail: own.baseUrl },
141
+ { value: 'key', label: t('provider.menu.key'), detail: keyHint(key) },
142
+ { value: 'delete', label: t('provider.menu.delete'), detail: t('provider.menu.deleteDetail') },
143
+ ];
144
+ const action = await ui.picker({
145
+ title: t('onboard.provider.title'),
146
+ subtitle: own.name,
147
+ items,
148
+ current: 'use',
149
+ // The labels differ from language to language: the column is as wide as the longest one.
150
+ labelWidth: Math.max(...items.map((i) => displayWidth(i.label))) + 2,
151
+ });
152
+ if (action === undefined)
153
+ return 'back';
154
+ if (action === 'use')
155
+ return 'use';
156
+ if (action === 'rename') {
157
+ const name = await askName(config, ui, { initial: own.name, exceptId: id });
158
+ if (name === undefined || name === own.name)
159
+ continue;
160
+ updateCustomProvider(config, id, { name });
161
+ await deps.save?.(config);
162
+ say('ok', t('provider.updated', { name }));
163
+ }
164
+ else if (action === 'url') {
165
+ const url = await askUrl(ui, { initial: own.baseUrl });
166
+ if (url === undefined || url === own.baseUrl)
167
+ continue;
168
+ updateCustomProvider(config, id, { baseUrl: url });
169
+ await deps.save?.(config);
170
+ say('ok', t('provider.updated', { name: own.name }));
171
+ }
172
+ else if (action === 'key') {
173
+ const got = await askKey(ui, { name: own.name, baseUrl: own.baseUrl });
174
+ if (got === undefined)
175
+ continue;
176
+ updateCustomProvider(config, id, { apiKey: got });
177
+ await deps.save?.(config);
178
+ say('ok', t('provider.updated', { name: own.name }));
179
+ }
180
+ else {
181
+ const active = config.provider === id;
182
+ const yes = await ui.confirm({
183
+ title: t('onboard.provider.title'),
184
+ question: t('provider.delete.question', { name: own.name }),
185
+ details: [hostOfUrl(own.baseUrl), ...(active ? [t('provider.delete.active')] : [])],
186
+ yes: t('provider.menu.delete'),
187
+ initial: false,
188
+ });
189
+ if (!yes)
190
+ continue;
191
+ removeCustomProvider(config, id);
192
+ await deps.save?.(config);
193
+ say('ok', t('provider.deleted', { name: own.name }));
194
+ return 'back';
195
+ }
196
+ }
197
+ }
@@ -883,10 +883,5 @@ export const ALL_PROVIDERS = [
883
883
  "value": "zhipuai-coding-plan",
884
884
  "label": "Zhipu AI Coding Plan",
885
885
  "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4"
886
- },
887
- {
888
- "value": "custom",
889
- "label": "Custom (Any OpenAI-compatible API)",
890
- "baseUrl": ""
891
886
  }
892
887
  ];
@@ -30,6 +30,7 @@ export const ROWS = [
30
30
  row('layout', 'appearance', 'choice'),
31
31
  row('pureBlack', 'appearance', 'toggle'),
32
32
  row('autoApprove', 'agent', 'toggle'),
33
+ row('extra', 'agent', 'toggle'),
33
34
  row('maxIterations', 'agent', 'choice'),
34
35
  row('memory', 'agent', 'toggle'),
35
36
  row('updates', 'agent', 'toggle'),
@@ -37,6 +38,10 @@ export const ROWS = [
37
38
  row('language', 'general', 'choice'),
38
39
  row('about', 'about', 'info'),
39
40
  ];
41
+ /** The rows on screen: Extra is there only while YOLO is on. */
42
+ export function visibleRows(state) {
43
+ return ROWS.filter((r) => r.key !== 'extra' || state.autoApprove);
44
+ }
40
45
  const hintFor = (key) => t(`settings.hint.${key}`);
41
46
  function cycle(items, current, direction) {
42
47
  const index = items.indexOf(current);
@@ -65,7 +70,10 @@ export function changeSetting(state, key, direction) {
65
70
  case 'pureBlack':
66
71
  return { ...state, pureBlack: !state.pureBlack };
67
72
  case 'autoApprove':
68
- return { ...state, autoApprove: !state.autoApprove };
73
+ // Extra goes with YOLO: without it there is nothing to take the questions away from.
74
+ return { ...state, autoApprove: !state.autoApprove, extra: state.autoApprove ? false : !!state.extra };
75
+ case 'extra':
76
+ return state.autoApprove ? { ...state, extra: !state.extra } : state;
69
77
  case 'memory':
70
78
  return { ...state, memory: !state.memory };
71
79
  case 'updates':
@@ -81,8 +89,8 @@ export function changeSetting(state, key, direction) {
81
89
  }
82
90
  }
83
91
  /** Moves the focus one row. It stops at the first and the last row instead of wrapping. */
84
- export function moveFocus(focus, delta) {
85
- return Math.max(0, Math.min(ROWS.length - 1, focus + delta));
92
+ export function moveFocus(focus, delta, count = ROWS.length) {
93
+ return Math.max(0, Math.min(count - 1, focus + delta));
86
94
  }
87
95
  // ───────────────────────── rendering ─────────────────────────
88
96
  const LABEL_WIDTH = 21;
@@ -110,6 +118,8 @@ function valueText(key, state, version, room, withSwatch) {
110
118
  return onOff(state.pureBlack);
111
119
  case 'autoApprove':
112
120
  return onOff(state.autoApprove, palette.yolo, '▲');
121
+ case 'extra':
122
+ return onOff(!!state.extra, palette.err, '▲');
113
123
  case 'maxIterations':
114
124
  return palette.text(formatIterations(state.maxIterations));
115
125
  case 'memory':
@@ -136,7 +146,8 @@ function detailLines(state, key, width, maxLines, version) {
136
146
  }
137
147
  const hint = key === 'about' ? `devx v${version}. ${hintFor('about')}` : hintFor(key);
138
148
  // A warning is never the line that gets cut off: it keeps its row, the explanation gives way.
139
- const warning = key === 'autoApprove' && state.autoApprove ? t('settings.warnYolo') : '';
149
+ const warning = key === 'autoApprove' && state.autoApprove ? t('settings.warnYolo')
150
+ : key === 'extra' && state.extra ? t('settings.warnExtra') : '';
140
151
  const hintRows = Math.max(1, maxLines - (warning ? 1 : 0));
141
152
  let wrapped = wrapText(hint, width);
142
153
  if (wrapped.length > hintRows) {
@@ -145,7 +156,7 @@ function detailLines(state, key, width, maxLines, version) {
145
156
  }
146
157
  const out = wrapped.map((line) => palette.muted(line));
147
158
  if (warning && out.length < maxLines)
148
- out.push(palette.yolo(truncateWidth(warning, width)));
159
+ out.push((key === 'extra' ? palette.err : palette.yolo)(truncateWidth(warning, width)));
149
160
  return out;
150
161
  }
151
162
  /** The key hints, shortened step by step until they fit `width` cells. */
@@ -177,8 +188,9 @@ function renderCard(state, focus, cols, density, version) {
177
188
  if (density === 'roomy')
178
189
  lines.push(box(''));
179
190
  const labelWidth = labelColumn((r) => r.label, LABEL_WIDTH, Math.floor(iw / 2));
191
+ const shown = visibleRows(state);
180
192
  let section = '';
181
- ROWS.forEach((row, index) => {
193
+ shown.forEach((row, index) => {
182
194
  if (row.sectionKey !== section) {
183
195
  if (section !== '' && density === 'roomy')
184
196
  lines.push(box(''));
@@ -199,7 +211,7 @@ function renderCard(state, focus, cols, density, version) {
199
211
  lines.push(box(''));
200
212
  lines.push(rule('├' + '─'.repeat(W - 2) + '┤'));
201
213
  // The detail area always has the same height, so the panel does not jump while you move.
202
- const detail = detailLines(state, ROWS[focus].key, iw - 2, maxDetail, version);
214
+ const detail = detailLines(state, shown[Math.min(focus, shown.length - 1)].key, iw - 2, maxDetail, version);
203
215
  for (let i = 0; i < maxDetail; i++)
204
216
  lines.push(box(detail[i] ? ' ' + detail[i] : ''));
205
217
  lines.push(rule('╰' + '─'.repeat(W - 2) + '╯'));
@@ -211,7 +223,8 @@ function renderCompact(state, focus, cols, rows, version) {
211
223
  const lines = [];
212
224
  const shortWidth = labelColumn((r) => r.short, SHORT_LABEL_WIDTH, Math.floor(width / 2));
213
225
  lines.push(' ' + palette.bold(palette.accent(t('settings.title'))) + palette.faint(` · v${version}`));
214
- ROWS.forEach((row, index) => {
226
+ const shown = visibleRows(state);
227
+ shown.forEach((row, index) => {
215
228
  const focused = index === focus;
216
229
  const mark = focused ? palette.accent('❯') : ' ';
217
230
  const label = focused ? palette.bold(palette.text(row.short)) : palette.muted(row.short);
@@ -221,8 +234,8 @@ function renderCompact(state, focus, cols, rows, version) {
221
234
  lines.push(focused ? palette.shade(padEndWidth(content, width)) : content);
222
235
  });
223
236
  // The title, the rows and the key hints come first; the help text takes what is left of the screen.
224
- const detailCount = Math.max(0, Math.min(rows >= 16 ? 2 : 1, rows - 1 - (ROWS.length + 2)));
225
- const detail = detailLines(state, ROWS[focus].key, width - 2, detailCount, version);
237
+ const detailCount = Math.max(0, Math.min(rows >= 16 ? 2 : 1, rows - 1 - (shown.length + 2)));
238
+ const detail = detailLines(state, shown[Math.min(focus, shown.length - 1)].key, width - 2, detailCount, version);
226
239
  for (let i = 0; i < detailCount; i++)
227
240
  lines.push(detail[i] ? ' ' + detail[i] : '');
228
241
  lines.push(' ' + keyHints(width - 1));
@@ -233,9 +246,11 @@ function renderCompact(state, focus, cols, rows, version) {
233
246
  * depending on the height), narrow ones a plain list.
234
247
  */
235
248
  export function renderSettings(state, focus, cols, rows, version) {
236
- if (cols < 60 || rows < 23)
249
+ // The card needs a line for each row of the list: it is lower when Extra is there.
250
+ const count = visibleRows(state).length;
251
+ if (cols < 60 || rows < count + 13)
237
252
  return renderCompact(state, focus, cols, rows, version);
238
- return renderCard(state, focus, cols, rows >= 29 ? 'roomy' : 'tight', version);
253
+ return renderCard(state, focus, cols, rows >= count + 19 ? 'roomy' : 'tight', version);
239
254
  }
240
255
  /**
241
256
  * Shows the panel and resolves with the final state when it is closed (Esc, q or Ctrl+C).
@@ -251,16 +266,22 @@ export function runSettings(initial, options) {
251
266
  const wasRaw = !!input.isRaw;
252
267
  input.setRawMode?.(true);
253
268
  input.resume();
269
+ const stopWatching = block.watchResize(draw);
270
+ const rowsNow = () => visibleRows(state);
254
271
  const change = (direction) => {
255
- const next = changeSetting(state, ROWS[focus].key, direction);
272
+ const key = rowsNow()[Math.min(focus, rowsNow().length - 1)].key;
273
+ const next = changeSetting(state, key, direction);
256
274
  if (next === state)
257
275
  return;
258
276
  state = next;
259
- options.onChange?.(ROWS[focus].key, state);
277
+ // The Extra row goes away with YOLO: the focus must not be left behind the end of the list.
278
+ focus = Math.min(focus, rowsNow().length - 1);
279
+ options.onChange?.(key, state);
260
280
  draw();
261
281
  };
262
282
  const close = () => {
263
283
  input.off('data', onData);
284
+ stopWatching();
264
285
  block.erase();
265
286
  if (!wasRaw)
266
287
  input.setRawMode?.(false);
@@ -269,7 +290,7 @@ export function runSettings(initial, options) {
269
290
  let closed = false;
270
291
  const handleKey = (key) => {
271
292
  const move = (delta) => {
272
- const next = moveFocus(focus, delta);
293
+ const next = moveFocus(focus, delta, rowsNow().length);
273
294
  if (next !== focus) {
274
295
  focus = next;
275
296
  draw();
@@ -281,9 +302,9 @@ export function runSettings(initial, options) {
281
302
  return move(1);
282
303
  // Home / End are sent differently by different terminals.
283
304
  if (key === '\x1b[H' || key === '\x1b[1~' || key === '\x1b[7~' || key === '\x1bOH' || key === 'g')
284
- return move(-ROWS.length);
305
+ return move(-rowsNow().length);
285
306
  if (key === '\x1b[F' || key === '\x1b[4~' || key === '\x1b[8~' || key === '\x1bOF' || key === 'G')
286
- return move(ROWS.length);
307
+ return move(rowsNow().length);
287
308
  if (key === '\x1b[C' || key === '\x1bOC' || key === 'l' || key === ' ' || key === '\r' || key === '\n')
288
309
  return change(1);
289
310
  if (key === '\x1b[D' || key === '\x1bOD' || key === 'h')
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import { CTRL_ENTER, EXTENDED_KEYS_OFF, EXTENDED_KEYS_ON, normalizeKey, splitKeys } from './keys.js';
4
+ import { EXTENDED_KEYS_OFF, EXTENDED_KEYS_ON, isNewlineKey, normalizeKey, splitKeys } from './keys.js';
5
5
  import { buildPromptFrame, palette } from './ui.js';
6
6
  import { displayWidth, truncateWidth } from './width.js';
7
7
  import { t } from '../core/i18n.js';
@@ -31,13 +31,16 @@ export function applyKey(state, key) {
31
31
  start--;
32
32
  return start === pos ? { state } : set([...chars.slice(0, start), ...chars.slice(pos)], start);
33
33
  };
34
+ // Ctrl+Enter, Alt+Enter, Shift+Enter: a new paragraph, the line is not sent.
35
+ if (isNewlineKey(key))
36
+ return set([...cs.slice(0, at), '\n', ...cs.slice(at)], at + 1);
34
37
  switch (key) {
35
- case CTRL_ENTER: {
36
- const text = state.text.trim();
37
- return { state: text ? { text: '', cursor: 0 } : state, submit: text || undefined, sendNow: true };
38
- }
39
38
  case '\r':
40
39
  case '\n': {
40
+ // A backslash before the cursor makes Enter a line break instead (for terminals that cannot
41
+ // tell Ctrl+Enter from Enter, a phone keyboard for one).
42
+ if (at > 0 && cs[at - 1] === '\\')
43
+ return set([...cs.slice(0, at - 1), '\n', ...cs.slice(at)], at);
41
44
  const text = state.text.trim();
42
45
  return text ? { state: { text: '', cursor: 0 }, submit: text } : { state };
43
46
  }
@@ -103,7 +106,7 @@ export function renderComposer({ state, queued, cols, base }) {
103
106
  const last = queued[queued.length - 1].replace(/\s+/g, ' ');
104
107
  const label = queued.length === 1 ? t('composer.queued') : t('composer.queuedMany', { n: queued.length });
105
108
  const prefix = palette.warn('↳ ' + label) + palette.faint(': ');
106
- // What can be done with it: ctrl+enter sends it now, the down arrow takes it back to edit.
109
+ // What can be done with it: the down arrow takes it back to edit.
107
110
  const hint = t('composer.queuedHint');
108
111
  const suffixWidth = displayWidth(' · ' + hint);
109
112
  const withHint = room - displayWidth(prefix) - suffixWidth >= 12;
@@ -144,7 +147,6 @@ export class TurnComposer {
144
147
  frameBase;
145
148
  onQueue;
146
149
  status;
147
- onSendNow;
148
150
  active = false;
149
151
  suspended = false;
150
152
  shown = false;
@@ -159,14 +161,11 @@ export class TurnComposer {
159
161
  live;
160
162
  constructor(out = process.stdout, frameBase, onQueue,
161
163
  /** The working line shown above the frame for the whole turn; called on every animation tick. */
162
- status,
163
- /** Ctrl+Enter: the user wants the queued messages to reach the agent right now. */
164
- onSendNow) {
164
+ status) {
165
165
  this.out = out;
166
166
  this.frameBase = frameBase;
167
167
  this.onQueue = onQueue;
168
168
  this.status = status;
169
- this.onSendNow = onSendNow;
170
169
  }
171
170
  get enabled() {
172
171
  return this.active;
@@ -266,7 +265,6 @@ export class TurnComposer {
266
265
  return;
267
266
  if (process.env.DEVX_DEBUG_KEYS)
268
267
  logKeys(chunk, this.queue.length, this.state.text);
269
- let sendNow = false;
270
268
  for (const raw of splitKeys(chunk)) {
271
269
  const key = normalizeKey(raw);
272
270
  // Ctrl+C is the app's (it stops the generation); Ctrl+D does nothing.
@@ -292,14 +290,9 @@ export class TurnComposer {
292
290
  this.queue.push(result.submit);
293
291
  this.onQueue?.(result.submit);
294
292
  }
295
- if (result.sendNow && this.queue.length > 0)
296
- sendNow = true;
297
293
  }
298
294
  this.lines = this.render();
299
295
  this.redraw();
300
- // After the redraw: the caller takes the queue and stops the turn.
301
- if (sendNow)
302
- this.onSendNow?.();
303
296
  }
304
297
  // ── terminal output
305
298
  /** The write that replaces the terminal's own for the length of the turn. */