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.
- package/README.md +16 -6
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +8 -8
- package/dist/cli/files.js +2 -12
- package/dist/cli/finder.js +10 -2
- package/dist/cli/headless.js +3 -2
- package/dist/cli/index.js +131 -75
- package/dist/cli/keys.js +12 -3
- package/dist/cli/liveblock.js +47 -9
- package/dist/cli/mcp-panel.js +2 -0
- package/dist/cli/picker.js +2 -0
- package/dist/cli/prompt.js +54 -7
- package/dist/cli/provider-manager.js +197 -0
- package/dist/cli/providers.js +0 -5
- package/dist/cli/settings.js +38 -17
- package/dist/cli/typeahead.js +10 -17
- package/dist/cli/ui.js +33 -13
- package/dist/cli/update-screen.js +383 -0
- package/dist/cli/updater.js +119 -145
- package/dist/cli/usage-panel.js +17 -4
- package/dist/cli/width.js +18 -0
- package/dist/core/background.js +14 -0
- package/dist/core/context-stats.js +1 -0
- package/dist/core/cost.js +25 -11
- package/dist/core/custom-providers.js +161 -0
- package/dist/core/images.js +46 -0
- package/dist/core/local-config.js +3 -1
- package/dist/core/locales/de.js +69 -7
- package/dist/core/locales/en.js +69 -7
- package/dist/core/locales/es.js +69 -7
- package/dist/core/locales/fr.js +69 -7
- package/dist/core/locales/ja.js +69 -7
- package/dist/core/locales/pt.js +69 -7
- package/dist/core/locales/ru.js +69 -7
- package/dist/core/locales/uk.js +69 -7
- package/dist/core/locales/zh.js +69 -7
- package/dist/core/loop.js +32 -1
- package/dist/core/prices.js +8 -4
- package/dist/core/session-media.js +115 -0
- package/dist/core/session.js +17 -2
- package/dist/core/title.js +19 -2
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +41 -4
- package/dist/permissions/guard.js +3 -0
- package/dist/providers/anthropic.js +67 -13
- package/dist/providers/index.js +16 -3
- package/dist/providers/openai.js +99 -41
- package/dist/providers/shared.js +83 -0
- package/dist/tools/fs.js +32 -2
- package/package.json +1 -1
package/dist/cli/prompt.js
CHANGED
|
@@ -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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/cli/providers.js
CHANGED
|
@@ -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
|
];
|
package/dist/cli/settings.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 - (
|
|
225
|
-
const detail = detailLines(state,
|
|
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
|
-
|
|
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 >=
|
|
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
|
|
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
|
-
|
|
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(-
|
|
305
|
+
return move(-rowsNow().length);
|
|
285
306
|
if (key === '\x1b[F' || key === '\x1b[4~' || key === '\x1b[8~' || key === '\x1bOF' || key === 'G')
|
|
286
|
-
return move(
|
|
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')
|
package/dist/cli/typeahead.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {
|
|
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:
|
|
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. */
|