termux-dev 1.8.1 → 1.9.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.
- package/README.md +17 -4
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +116 -0
- package/dist/cli/files.js +2 -12
- package/dist/cli/finder.js +4 -0
- package/dist/cli/headless.js +6 -3
- package/dist/cli/index.js +259 -74
- package/dist/cli/keys.js +12 -3
- package/dist/cli/liveblock.js +38 -9
- package/dist/cli/mcp-panel.js +2 -0
- package/dist/cli/picker.js +2 -0
- package/dist/cli/prompt.js +93 -8
- 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 +189 -32
- package/dist/cli/updater.js +10 -8
- package/dist/cli/usage-panel.js +17 -4
- package/dist/cli/width.js +18 -0
- package/dist/core/background.js +390 -0
- package/dist/core/config-merge.js +67 -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 +36 -7
- package/dist/core/locales/en.js +36 -7
- package/dist/core/locales/es.js +36 -7
- package/dist/core/locales/fr.js +36 -7
- package/dist/core/locales/ja.js +36 -7
- package/dist/core/locales/pt.js +36 -7
- package/dist/core/locales/ru.js +36 -7
- package/dist/core/locales/uk.js +36 -7
- package/dist/core/locales/zh.js +36 -7
- package/dist/core/loop.js +50 -2
- 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 +6 -1
- package/dist/prompts/builder.js +2 -1
- 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/background.js +118 -0
- package/dist/tools/bash.js +23 -1
- package/dist/tools/fs.js +32 -2
- package/dist/tools/index.js +2 -1
- package/package.json +1 -1
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. */
|
package/dist/cli/ui.js
CHANGED
|
@@ -36,9 +36,12 @@ export const palette = {
|
|
|
36
36
|
accent: (s) => tokens().accent(s),
|
|
37
37
|
bold: (s) => (colorOn() ? `\x1b[1m${s}\x1b[22m` : s),
|
|
38
38
|
};
|
|
39
|
-
|
|
39
|
+
/** `extra` is YOLO without the safety net (nothing is asked); it only counts while YOLO is on. */
|
|
40
|
+
export function resolveMode(planMode, yolo, extra = false) {
|
|
40
41
|
if (planMode)
|
|
41
42
|
return 'plan';
|
|
43
|
+
if (yolo && extra)
|
|
44
|
+
return 'extra';
|
|
42
45
|
return yolo ? 'yolo' : 'agent';
|
|
43
46
|
}
|
|
44
47
|
export function modeStyle(mode) {
|
|
@@ -47,6 +50,8 @@ export function modeStyle(mode) {
|
|
|
47
50
|
return { label: 'plan', glyph: '◆', color: palette.plan };
|
|
48
51
|
case 'yolo':
|
|
49
52
|
return { label: 'yolo', glyph: '▲', color: palette.yolo };
|
|
53
|
+
case 'extra':
|
|
54
|
+
return { label: 'extra', glyph: '▲', color: palette.err };
|
|
50
55
|
default:
|
|
51
56
|
return { label: 'agent', glyph: '●', color: palette.accent };
|
|
52
57
|
}
|
|
@@ -136,7 +141,7 @@ function brand(version) {
|
|
|
136
141
|
}
|
|
137
142
|
export function renderHeader(info, cols) {
|
|
138
143
|
const W = contentWidth(cols);
|
|
139
|
-
const modePill = info.yolo ? palette.yolo(palette.bold('▲ auto-approve')) : '';
|
|
144
|
+
const modePill = info.extra ? palette.err(palette.bold('▲ extra: nothing asked')) : info.yolo ? palette.yolo(palette.bold('▲ auto-approve')) : '';
|
|
140
145
|
const branch = info.branch
|
|
141
146
|
? `${palette.faint('git:')}${palette.muted(info.branch)}${info.dirty ? palette.warn('*') : ''}`
|
|
142
147
|
: '';
|
|
@@ -146,7 +151,7 @@ export function renderHeader(info, cols) {
|
|
|
146
151
|
const limit = Math.max(8, cols - 1);
|
|
147
152
|
const room = Math.max(6, cols - 3);
|
|
148
153
|
const titleOptions = info.yolo
|
|
149
|
-
? [`${brand(info.version)} ${modePill}`, `${brand(info.version)} ${palette.yolo('▲ yolo')}`, `${brand(info.version)} ${palette.yolo('▲')}`, brand(info.version)]
|
|
154
|
+
? [`${brand(info.version)} ${modePill}`, `${brand(info.version)} ${info.extra ? palette.err('▲ extra') : palette.yolo('▲ yolo')}`, `${brand(info.version)} ${info.extra ? palette.err('▲') : palette.yolo('▲')}`, brand(info.version)]
|
|
150
155
|
: [brand(info.version)];
|
|
151
156
|
lines.push(titleOptions.find((t) => displayWidth(t) <= limit) ?? truncateWidth(stripAnsi(brand(info.version)), limit));
|
|
152
157
|
lines.push(` ${palette.text(truncateWidth(info.model, room))}`);
|
|
@@ -181,6 +186,17 @@ export function renderHeader(info, cols) {
|
|
|
181
186
|
}
|
|
182
187
|
return lines;
|
|
183
188
|
}
|
|
189
|
+
// ───────────────────────── prompt frame ─────────────────────────
|
|
190
|
+
/** `◈ 2 bg` (running tasks); a failed one turns it red: `◈ 2 bg · 1 failed`. Empty when there is nothing to say. */
|
|
191
|
+
export function backgroundBadge(bg, short = false) {
|
|
192
|
+
if (!bg || (bg.running <= 0 && !(bg.failed && bg.failed > 0)))
|
|
193
|
+
return '';
|
|
194
|
+
const failed = bg.failed && bg.failed > 0 ? bg.failed : 0;
|
|
195
|
+
const text = short
|
|
196
|
+
? `◈${bg.running}${failed ? `!${failed}` : ''}`
|
|
197
|
+
: `◈ ${bg.running} bg${failed ? ` · ${failed} failed` : ''}`;
|
|
198
|
+
return failed ? palette.err(text) : palette.accent(text);
|
|
199
|
+
}
|
|
184
200
|
/** The part of the input that fits into `cells`, scrolled so that the cursor stays visible. */
|
|
185
201
|
export function visibleWindow(input, cursorPos, cells) {
|
|
186
202
|
const cps = Array.from(input);
|
|
@@ -242,6 +258,128 @@ export function visibleWindow(input, cursorPos, cells) {
|
|
|
242
258
|
cursorCells: cellsBefore(Math.min(cursorCp, win.end), win.start),
|
|
243
259
|
};
|
|
244
260
|
}
|
|
261
|
+
/** Cells of text per row inside the frame (the compact phone row has no side borders). */
|
|
262
|
+
export function inputCells(cols) {
|
|
263
|
+
const W = frameWidth(cols);
|
|
264
|
+
return Math.max(4, isCompact(cols) ? W - 4 : W - 6);
|
|
265
|
+
}
|
|
266
|
+
/** Control characters but the line break (`\n` is a real break in the input, it is laid out, not shown). */
|
|
267
|
+
const CONTROL_EXCEPT_NEWLINE = /[\x00-\x09\x0b-\x1f\x7f]/g;
|
|
268
|
+
/** Rows the input may take before it scrolls: a quarter of the terminal, at most 8. */
|
|
269
|
+
export function maxInputRows(termRows = process.stdout.rows || 24) {
|
|
270
|
+
return Math.max(1, Math.min(8, Math.floor(termRows / 4)));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Cuts the input into rows of `cells` cells. A line breaks after a space when it can; an
|
|
274
|
+
* attachment tag such as `[Pasted text #1 +22 lines]` is never split unless it is wider than a row.
|
|
275
|
+
*/
|
|
276
|
+
export function wrapInput(input, cells) {
|
|
277
|
+
const cps = Array.from(input);
|
|
278
|
+
if (cps.length === 0)
|
|
279
|
+
return [{ start: 0, end: 0 }];
|
|
280
|
+
const widths = cps.map((c) => codePointWidth(c.codePointAt(0)));
|
|
281
|
+
// Where a tag starts (in code points) -> where it ends.
|
|
282
|
+
const offsets = [];
|
|
283
|
+
let unit = 0;
|
|
284
|
+
for (const c of cps) {
|
|
285
|
+
offsets.push(unit);
|
|
286
|
+
unit += c.length;
|
|
287
|
+
}
|
|
288
|
+
const atoms = new Map();
|
|
289
|
+
for (const m of input.matchAll(BADGE)) {
|
|
290
|
+
const from = offsets.indexOf(m.index);
|
|
291
|
+
const to = offsets.indexOf(m.index + m[0].length);
|
|
292
|
+
if (from >= 0)
|
|
293
|
+
atoms.set(from, to >= 0 ? to : cps.length);
|
|
294
|
+
}
|
|
295
|
+
const rows = [];
|
|
296
|
+
let i = 0;
|
|
297
|
+
while (i < cps.length) {
|
|
298
|
+
const start = i;
|
|
299
|
+
let used = 0;
|
|
300
|
+
let afterSpace = -1;
|
|
301
|
+
let hardBreak = false;
|
|
302
|
+
while (i < cps.length) {
|
|
303
|
+
// A line break ends the row; it stays at the end of that row, so the cursor behind it is on the next one.
|
|
304
|
+
if (cps[i] === '\n') {
|
|
305
|
+
i++;
|
|
306
|
+
hardBreak = true;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
let next = atoms.get(i) ?? i + 1;
|
|
310
|
+
let w = widths.slice(i, next).reduce((a, b) => a + b, 0);
|
|
311
|
+
if (next > i + 1 && w > cells) {
|
|
312
|
+
next = i + 1;
|
|
313
|
+
w = widths[i];
|
|
314
|
+
} // a tag wider than a row is split
|
|
315
|
+
if (used + w > cells)
|
|
316
|
+
break;
|
|
317
|
+
used += w;
|
|
318
|
+
i = next;
|
|
319
|
+
if (cps[i - 1] === ' ')
|
|
320
|
+
afterSpace = i;
|
|
321
|
+
}
|
|
322
|
+
if (!hardBreak) {
|
|
323
|
+
if (i < cps.length && cps[i] !== ' ' && afterSpace > start)
|
|
324
|
+
i = afterSpace; // break between words
|
|
325
|
+
if (i === start)
|
|
326
|
+
i = start + 1; // a cell wider than the row
|
|
327
|
+
}
|
|
328
|
+
rows.push({ start, end: i });
|
|
329
|
+
}
|
|
330
|
+
// Text that ends with a line break: the cursor waits on a new, empty row.
|
|
331
|
+
if (cps[cps.length - 1] === '\n')
|
|
332
|
+
rows.push({ start: cps.length, end: cps.length });
|
|
333
|
+
return rows;
|
|
334
|
+
}
|
|
335
|
+
function layoutInput(input, cursorPos, cells) {
|
|
336
|
+
const cps = Array.from(input);
|
|
337
|
+
const widths = cps.map((c) => codePointWidth(c.codePointAt(0)));
|
|
338
|
+
let cursorCp = 0;
|
|
339
|
+
let units = 0;
|
|
340
|
+
while (cursorCp < cps.length && units < cursorPos) {
|
|
341
|
+
units += cps[cursorCp].length;
|
|
342
|
+
cursorCp++;
|
|
343
|
+
}
|
|
344
|
+
const rows = wrapInput(input, cells);
|
|
345
|
+
let cursorRow = rows.findIndex((r) => cursorCp < r.end);
|
|
346
|
+
if (cursorRow < 0)
|
|
347
|
+
cursorRow = rows.length - 1;
|
|
348
|
+
const row = rows[cursorRow];
|
|
349
|
+
const cursorCell = widths.slice(row.start, Math.min(cursorCp, row.end)).reduce((a, b) => a + b, 0);
|
|
350
|
+
return { cps, rows, widths, cursorRow, cursorCell };
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The cursor one row up or down inside a wrapped input, or null when there is no such row
|
|
354
|
+
* (the arrow keys then walk the prompt history as before). Returns a UTF-16 index.
|
|
355
|
+
*/
|
|
356
|
+
export function moveCursorRow(input, cursorPos, cols, dir) {
|
|
357
|
+
const clean = input.replace(CONTROL_EXCEPT_NEWLINE, ' ');
|
|
358
|
+
const { cps, rows, widths, cursorRow, cursorCell } = layoutInput(clean, cursorPos, inputCells(cols));
|
|
359
|
+
const target = rows[cursorRow + dir];
|
|
360
|
+
if (!target)
|
|
361
|
+
return null;
|
|
362
|
+
// The last cell of a row that is not the last one belongs to the next row, so stop before it.
|
|
363
|
+
const limit = cursorRow + dir === rows.length - 1 ? target.end : Math.max(target.start, target.end - 1);
|
|
364
|
+
let at = target.start;
|
|
365
|
+
let used = 0;
|
|
366
|
+
while (at < limit && used + widths[at] <= cursorCell) {
|
|
367
|
+
used += widths[at];
|
|
368
|
+
at++;
|
|
369
|
+
}
|
|
370
|
+
return cps.slice(0, at).join('').length;
|
|
371
|
+
}
|
|
372
|
+
/** The rows of the input as drawn, with the cursor; scrolled so that the cursor row is visible. */
|
|
373
|
+
function inputRows(f, cells) {
|
|
374
|
+
if (!f.input) {
|
|
375
|
+
return { rows: [palette.muted(truncateWidth(f.placeholder, cells))], firstRow: 0, cursorRow: 0, cursorCol: 0 };
|
|
376
|
+
}
|
|
377
|
+
const { cps, rows, cursorRow, cursorCell } = layoutInput(f.input, f.cursorPos, cells);
|
|
378
|
+
const limit = f.maxInputRows ?? maxInputRows();
|
|
379
|
+
const firstRow = rows.length <= limit ? 0 : Math.max(0, Math.min(cursorRow - limit + 1, rows.length - limit));
|
|
380
|
+
const shown = rows.slice(firstRow, firstRow + limit).map((r) => formatBadges(cps.slice(r.start, r.end).join('').replace(/\n$/, '')));
|
|
381
|
+
return { rows: shown, firstRow, cursorRow: cursorRow - firstRow, cursorCol: cursorCell };
|
|
382
|
+
}
|
|
245
383
|
function keyHints(cols, mode) {
|
|
246
384
|
const next = mode === 'plan' ? 'agent' : 'plan';
|
|
247
385
|
const all = [
|
|
@@ -279,8 +417,9 @@ export function fitModelLabel(label, max) {
|
|
|
279
417
|
}
|
|
280
418
|
export function buildPromptFrame(input) {
|
|
281
419
|
// Control characters would move the cursor and tear the frame apart. They are shown as
|
|
282
|
-
// spaces; one character stays one character, so cursor positions keep matching.
|
|
283
|
-
|
|
420
|
+
// spaces; one character stays one character, so cursor positions keep matching. A line break
|
|
421
|
+
// is kept: it starts a new row of the input.
|
|
422
|
+
const f = { ...input, input: input.input.replace(CONTROL_EXCEPT_NEWLINE, ' ') };
|
|
284
423
|
if (isCompact(f.cols))
|
|
285
424
|
return buildCompactFrame(f);
|
|
286
425
|
const W = frameWidth(f.cols);
|
|
@@ -295,16 +434,14 @@ export function buildPromptFrame(input) {
|
|
|
295
434
|
topRight = ' ' + palette.muted(fitModelLabel(f.model, Math.min(48, topFree - 4))) + ' ';
|
|
296
435
|
}
|
|
297
436
|
const top = rule('╭─') + pill + rule('─'.repeat(Math.max(0, topFree - displayWidth(topRight)))) + topRight + rule('─╮');
|
|
298
|
-
// ── input
|
|
437
|
+
// ── input rows: │ ❯ text │, the text wraps onto further rows instead of scrolling sideways
|
|
299
438
|
const contentCells = W - 6;
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
? (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const inputRow = rule('│') + ' ' + palette.accent('❯') + ' ' + padEndWidth(content, contentCells) + ' ' + rule('│');
|
|
307
|
-
const cursorCol = 4 + (f.input ? (win.leftEllipsis ? 1 : 0) + win.cursorCells : 0);
|
|
439
|
+
const body = inputRows(f, contentCells);
|
|
440
|
+
const inputLines = body.rows.map((content, i) => {
|
|
441
|
+
const glyph = i === 0 ? (body.firstRow > 0 ? palette.faint('…') : palette.accent('❯')) : ' ';
|
|
442
|
+
return rule('│') + ' ' + glyph + ' ' + padEndWidth(content, contentCells) + ' ' + rule('│');
|
|
443
|
+
});
|
|
444
|
+
const cursorCol = 4 + body.cursorCol;
|
|
308
445
|
// ── bottom border: ╰─ ~/project git:main* ──── ━━━━──── 12% · $0.0123 ─╯
|
|
309
446
|
const bottomFree = W - 4;
|
|
310
447
|
const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
|
|
@@ -316,7 +453,10 @@ export function buildPromptFrame(input) {
|
|
|
316
453
|
: bottomFree >= displayWidth(stat) + 4
|
|
317
454
|
? ' ' + palette.muted(stat) + ' '
|
|
318
455
|
: '';
|
|
319
|
-
|
|
456
|
+
// ◈ 2 bg: tasks that keep running while the conversation goes on (/tasks lists them)
|
|
457
|
+
const bgBadge = backgroundBadge(f.background);
|
|
458
|
+
const bgWidth = bgBadge ? displayWidth(stripAnsi(bgBadge)) + 2 : 0;
|
|
459
|
+
const leftRoom = bottomFree - displayWidth(bottomRight) - 2 - bgWidth;
|
|
320
460
|
let bottomLeft = '';
|
|
321
461
|
if (f.cwd && leftRoom >= 10) {
|
|
322
462
|
const branch = f.branch ? `${palette.faint('git:')}${palette.muted(f.branch)}${f.dirty ? palette.warn('*') : ''}` : '';
|
|
@@ -325,6 +465,9 @@ export function buildPromptFrame(input) {
|
|
|
325
465
|
const pathRoom = leftRoom - (showBranch ? branchW : 0);
|
|
326
466
|
bottomLeft = ' ' + palette.muted(shortenPath(f.cwd, pathRoom)) + (showBranch ? ' ' + branch : '') + ' ';
|
|
327
467
|
}
|
|
468
|
+
if (bgBadge && bottomFree - displayWidth(bottomLeft) - displayWidth(bottomRight) - 2 >= bgWidth) {
|
|
469
|
+
bottomLeft += (bottomLeft ? '' : ' ') + bgBadge + ' ';
|
|
470
|
+
}
|
|
328
471
|
const bottom = rule('╰─') +
|
|
329
472
|
bottomLeft +
|
|
330
473
|
rule('─'.repeat(Math.max(0, bottomFree - displayWidth(bottomLeft) - displayWidth(bottomRight)))) +
|
|
@@ -332,7 +475,7 @@ export function buildPromptFrame(input) {
|
|
|
332
475
|
rule('─╯');
|
|
333
476
|
// ── footer: key hints, or the suggestion list while one is open
|
|
334
477
|
const footer = f.below && f.below.length > 0 ? f.below.map((l) => ' ' + l) : [hintsRow(f.cols, f.mode)];
|
|
335
|
-
return { lines: [top,
|
|
478
|
+
return { lines: [top, ...inputLines, bottom, ...footer], cursorRow: 1 + body.cursorRow, cursorCol };
|
|
336
479
|
}
|
|
337
480
|
/**
|
|
338
481
|
* Phone layout: one row, no borders, no hints.
|
|
@@ -345,23 +488,26 @@ function buildCompactFrame(f) {
|
|
|
345
488
|
const style = modeStyle(f.mode);
|
|
346
489
|
const prefix = style.color(style.glyph) + ' ' + palette.accent('❯') + ' ';
|
|
347
490
|
const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
|
|
348
|
-
const
|
|
491
|
+
const bgShort = backgroundBadge(f.background, true);
|
|
492
|
+
const statText = `${bgShort ? stripAnsi(bgShort) + ' ' : ''}${pct}%`;
|
|
349
493
|
const statColor = pct >= 90 ? palette.err : pct >= 75 ? palette.warn : palette.faint;
|
|
350
494
|
const statWidth = displayWidth(statText) + 1;
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
495
|
+
const cells = W - 4;
|
|
496
|
+
// The count sits at the end of the last row, and only when the text there leaves it room.
|
|
497
|
+
const withStat = cells - statWidth >= 14;
|
|
498
|
+
const body = inputRows(f, cells);
|
|
499
|
+
const statShown = bgShort && withStat ? bgShort + ' ' + statColor(`${pct}%`) : statColor(statText);
|
|
500
|
+
const rows = body.rows.map((content, i) => {
|
|
501
|
+
const lead = i === 0 ? (body.firstRow > 0 ? style.color(style.glyph) + ' ' + palette.faint('…') + ' ' : prefix) : ' ';
|
|
502
|
+
const room = cells - displayWidth(stripAnsi(content));
|
|
503
|
+
const stat = i === body.rows.length - 1 && withStat && room >= statWidth ? ' '.repeat(room - statWidth + 1) + statShown : '';
|
|
504
|
+
return lead + (stat ? content + stat : padEndWidth(content, cells));
|
|
505
|
+
});
|
|
360
506
|
const below = (f.below ?? []).map((l) => ' ' + l);
|
|
361
507
|
return {
|
|
362
|
-
lines: [
|
|
363
|
-
cursorRow:
|
|
364
|
-
cursorCol: 4 +
|
|
508
|
+
lines: [...rows, ...below],
|
|
509
|
+
cursorRow: body.cursorRow,
|
|
510
|
+
cursorCol: 4 + body.cursorCol,
|
|
365
511
|
};
|
|
366
512
|
}
|
|
367
513
|
function hintsRow(cols, mode) {
|
|
@@ -614,7 +760,7 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
|
|
|
614
760
|
}
|
|
615
761
|
if (expanded && t !== 'bash' && !(t === 'edit_file' || t === 'write_file')) {
|
|
616
762
|
// For these the first line is part of the output, not a summary of it.
|
|
617
|
-
const own = t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url';
|
|
763
|
+
const own = t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url' || t === 'background';
|
|
618
764
|
const first = own ? [] : summarizeToolResult(name, result, cols, false);
|
|
619
765
|
return [...first, ...fullRows(text, cols)];
|
|
620
766
|
}
|
|
@@ -648,6 +794,17 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
|
|
|
648
794
|
}
|
|
649
795
|
catch { }
|
|
650
796
|
}
|
|
797
|
+
// Background tasks: what happened on the first line (started / failed / matched), the rest folded.
|
|
798
|
+
if (t === 'background') {
|
|
799
|
+
const nonEmpty = outputRows(text).filter((l) => l.trim());
|
|
800
|
+
if (nonEmpty.length === 0)
|
|
801
|
+
return [renderResultLine('(no output)', 'muted', cols)];
|
|
802
|
+
const bad = /^(Error:|No background task|.* failed \(exit|.*ended right after)/.test(nonEmpty[0]);
|
|
803
|
+
const good = /^(Started bg|Stopped\.)/.test(nonEmpty[0]);
|
|
804
|
+
const rest = nonEmpty.length - 1;
|
|
805
|
+
const row = renderResultLine(nonEmpty[0].trim(), bad ? 'error' : good ? 'ok' : 'muted', cols);
|
|
806
|
+
return rest > 0 ? [row, moreHint(rest)] : [row];
|
|
807
|
+
}
|
|
651
808
|
// Output of an MCP tool or a web fetch: the first line and how many more there are.
|
|
652
809
|
if (t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url') {
|
|
653
810
|
const nonEmpty = outputRows(text).filter((l) => l.trim());
|
|
@@ -860,7 +1017,7 @@ export function easeToward(current, target, dtMs, tauMs = 400) {
|
|
|
860
1017
|
return current + distance * (1 - Math.exp(-dtMs / tauMs));
|
|
861
1018
|
}
|
|
862
1019
|
/**
|
|
863
|
-
* The working line: ` ⠹ Read src/x.ts (12s · ↓ 364 tokens ·
|
|
1020
|
+
* The working line: ` ⠹ Read src/x.ts (12s · ↓ 364 tokens · esc to stop)`.
|
|
864
1021
|
* On a narrow screen the details are dropped step by step, the label is cut last.
|
|
865
1022
|
*/
|
|
866
1023
|
export function renderStatusLine({ label: rawLabel, elapsedMs, meta = '', frame, columns }) {
|
|
@@ -889,7 +1046,7 @@ export function renderStatusLine({ label: rawLabel, elapsedMs, meta = '', frame,
|
|
|
889
1046
|
return ` ${glyph} ${palette.text(text)}${' '.repeat(GAP)}${suffix}`;
|
|
890
1047
|
}
|
|
891
1048
|
/**
|
|
892
|
-
* Animated "working" line: ` ⠹ Read src/x.ts (12s · 1.2k tok ·
|
|
1049
|
+
* Animated "working" line: ` ⠹ Read src/x.ts (12s · 1.2k tok · esc to stop)`.
|
|
893
1050
|
* Same start / message / stop API as the clack spinner it replaces.
|
|
894
1051
|
*/
|
|
895
1052
|
let activeSpinner = null;
|
package/dist/cli/updater.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import pc from 'picocolors';
|
|
5
5
|
import { select } from '@inquirer/prompts';
|
|
6
|
+
import { palette } from './ui.js';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
const GITHUB_REPO_URL = 'https://github.com/apvcode/Termux-Dev';
|
|
8
9
|
const REMOTE_PKG_URL = 'https://raw.githubusercontent.com/apvcode/Termux-Dev/main/package.json';
|
|
@@ -208,8 +209,9 @@ export async function runStartupUpdateCheck(config) {
|
|
|
208
209
|
const spinner = setInterval(() => {
|
|
209
210
|
if (isDone)
|
|
210
211
|
return;
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
// The colours of the theme, like the rest of the interface.
|
|
213
|
+
const frame = palette.accent(frames[fIdx++ % frames.length]);
|
|
214
|
+
process.stdout.write(`\r ${frame} ${palette.muted('Checking for updates from')} ${palette.accent('apvcode/Termux-Dev')} ${palette.faint(`(v${currentVersion})...`)} `);
|
|
213
215
|
}, 75);
|
|
214
216
|
const result = await checkForUpdates(10000);
|
|
215
217
|
isDone = true;
|
|
@@ -218,14 +220,14 @@ export async function runStartupUpdateCheck(config) {
|
|
|
218
220
|
process.stdout.write('\r\x1b[K');
|
|
219
221
|
if (result.updateAvailable) {
|
|
220
222
|
console.log();
|
|
221
|
-
console.log(
|
|
222
|
-
console.log(
|
|
223
|
-
console.log(
|
|
224
|
-
console.log(
|
|
223
|
+
console.log(palette.rule(' ┌───────────────────────────────────────────────────────────┐'));
|
|
224
|
+
console.log(palette.rule(' │') + palette.bold(palette.accent(` A new update is available: `)) + palette.muted(`v${result.currentVersion}`) + palette.bold(palette.ok(` → v${result.latestVersion}`)) + ' '.repeat(Math.max(1, 23 - result.currentVersion.length - result.latestVersion.length)) + palette.rule('│'));
|
|
225
|
+
console.log(palette.rule(' │') + palette.faint(` Repository: https://github.com/apvcode/Termux-Dev `) + palette.rule('│'));
|
|
226
|
+
console.log(palette.rule(' └───────────────────────────────────────────────────────────┘'));
|
|
225
227
|
console.log();
|
|
226
228
|
try {
|
|
227
229
|
const choice = await select({
|
|
228
|
-
message:
|
|
230
|
+
message: palette.bold('Do you want to update devx now?'),
|
|
229
231
|
loop: false,
|
|
230
232
|
choices: [
|
|
231
233
|
{
|
|
@@ -250,7 +252,7 @@ export async function runStartupUpdateCheck(config) {
|
|
|
250
252
|
}
|
|
251
253
|
else {
|
|
252
254
|
// Briefly show up to date message
|
|
253
|
-
process.stdout.write(` ${
|
|
255
|
+
process.stdout.write(` ${palette.ok('✓')} ${palette.muted(`devx is up to date (v${result.currentVersion})`)}\n`);
|
|
254
256
|
await new Promise(r => setTimeout(r, 450));
|
|
255
257
|
// Clean up line
|
|
256
258
|
process.stdout.write('\x1b[1A\x1b[2K');
|