termux-dev 1.9.5 → 1.9.8
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 +30 -1
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/index.js +29 -10
- package/dist/cli/keys.js +43 -4
- package/dist/cli/prompt.js +3 -3
- package/dist/cli/typeahead.js +9 -7
- package/dist/cli/update-screen.js +8 -5
- package/dist/cli/updater.js +77 -6
- package/dist/core/attention.js +63 -0
- package/dist/core/types.js +1 -1
- package/dist/permissions/guard.js +4 -0
- package/dist/tools/questions.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,8 @@ The terminal interface is built around a framed prompt that carries the status i
|
|
|
67
67
|
Built-in support for TypeScript (`tsc`), JavaScript (`node --check`), Python (`py_compile`), and Rust (`cargo check`). The AI automatically finds and fixes errors before finishing turns.
|
|
68
68
|
- **Project Memory Bank (`.devx/memory.md`):**
|
|
69
69
|
Persistent project context across sessions with `/memory add <rule>`.
|
|
70
|
+
- **Notifications when devx waits for you:**
|
|
71
|
+
When the agent needs an approval or asks you a question (`◆ Clarify`) and the terminal is not in front, you get a notification (kitty's own protocol, `notify-send` on Linux, `osascript` on macOS; on Termux an Android notification with a short vibration). A finished task is announced too, with the start of the answer. The terminal has to report focus changes (most modern ones do); when devx cannot tell whether you are looking, it stays quiet instead of popping up in front of you.
|
|
70
72
|
- **Past chats (`search_chats`):**
|
|
71
73
|
When you say "as we decided last time" or "in that chat about the parser", the agent can look through your saved chats by keywords (any language, other word forms match: page / pages) and read the one it needs, a few messages at a time. It is read-only and local, so it asks nothing; the chat that is open is not searched; tool output is not indexed; and what it reads is handed to the model as data, not as instructions. The agent does not use it on a normal task.
|
|
72
74
|
- **18 Colour Themes on a Pure Black Background (`#0a0a0c`):**
|
|
@@ -92,6 +94,33 @@ The terminal interface is built around a framed prompt that carries the status i
|
|
|
92
94
|
- **Node.js**: `v20.0.0` or higher
|
|
93
95
|
- **npm**, **pnpm**, or **yarn**
|
|
94
96
|
|
|
97
|
+
### Installing Node.js and npm
|
|
98
|
+
|
|
99
|
+
devx needs Node.js 20 or newer (npm comes with it). Pick your system:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Windows (PowerShell)
|
|
103
|
+
winget install OpenJS.NodeJS.LTS
|
|
104
|
+
|
|
105
|
+
# macOS (Homebrew)
|
|
106
|
+
brew install node
|
|
107
|
+
|
|
108
|
+
# Ubuntu / Debian (NodeSource: the version in the distribution's own repository is often older than 20)
|
|
109
|
+
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
|
|
110
|
+
sudo apt-get install -y nodejs
|
|
111
|
+
|
|
112
|
+
# Fedora
|
|
113
|
+
sudo dnf install -y nodejs
|
|
114
|
+
|
|
115
|
+
# Arch Linux
|
|
116
|
+
sudo pacman -S nodejs npm
|
|
117
|
+
|
|
118
|
+
# Android (Termux)
|
|
119
|
+
pkg update && pkg install -y nodejs-lts
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Reopen the terminal and check: `node -v` (should be `v20` or higher) and `npm -v`. Any system: the installer at [nodejs.org](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm).
|
|
123
|
+
|
|
95
124
|
### Fast Global Install (Recommended)
|
|
96
125
|
|
|
97
126
|
```bash
|
|
@@ -206,7 +235,7 @@ Type `/` in the prompt to open the autocomplete command palette:
|
|
|
206
235
|
- **`@filename`** — Fast fuzzy file autocomplete and full file embedding.
|
|
207
236
|
- **`Backspace`** — Atomically deletes entire badges `[Pasted text #1]` and `[1.png 203kb]` in one press.
|
|
208
237
|
- **`Ctrl+Backspace`** / **`Alt+Backspace`** — Erase the text word by word (also while the agent works).
|
|
209
|
-
- **`Ctrl+Enter`** — While typing: start a new line (a new paragraph) instead of sending. `Alt+Enter` and `Shift+Enter` do the same where the terminal reports them; on a terminal that cannot tell them from `Enter` (a phone keyboard, for one) type `\` before `Enter`.
|
|
238
|
+
- **`Ctrl+Enter`** — While typing: start a new line (a new paragraph) instead of sending. `Alt+Enter` and `Shift+Enter` do the same where the terminal reports them; on a terminal that cannot tell them from `Enter` (a phone keyboard, for one) type `\` before `Enter`. devx asks the terminal to tell them apart only where that is known to work (kitty, Ghostty, WezTerm, iTerm2, foot, Alacritty); Termux and other terminals that read that request as a cursor or style code never get it. `DEVX_EXTENDED_KEYS=1` (or `0`) forces it on (or off).
|
|
210
239
|
- **`Ctrl+P`** / **`Ctrl+O`** — Unfold or fold the reasoning of the model (`Ctrl+P`) and the commands with their output (`Ctrl+O`) in the whole chat, also what is already above. Both are folded by default; works while the agent works and at the prompt.
|
|
211
240
|
- **`Esc`** — Stop the current answer and whatever it started (a running command); what you typed waits in the next prompt. Esc also closes every list and card.
|
|
212
241
|
- **`Ctrl+C`** — Stop current generation immediately or exit.
|
package/assets/banner.svg
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<rect width="1000" height="280" fill="url(#bg)"/>
|
|
17
17
|
</g>
|
|
18
18
|
<rect x="1.5" y="1.5" width="997" height="277" rx="18" fill="none" stroke="#23252c" stroke-width="1.5"/>
|
|
19
|
-
<text x="500.0" y="70" text-anchor="middle" font-family="'Inter',system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif" font-size="13" font-weight="600" letter-spacing="5" fill="#7d8896">THE TERMINAL-NATIVE AI CODING AGENT<tspan dx="16" fill="#2fe3ee" font-weight="700">v1.9.
|
|
19
|
+
<text x="500.0" y="70" text-anchor="middle" font-family="'Inter',system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif" font-size="13" font-weight="600" letter-spacing="5" fill="#7d8896">THE TERMINAL-NATIVE AI CODING AGENT<tspan dx="16" fill="#2fe3ee" font-weight="700">v1.9.8</tspan></text>
|
|
20
20
|
<g>
|
|
21
21
|
<text x="500.0" y="178" text-anchor="middle" font-family="'JetBrains Mono','Fira Code','SF Mono',ui-monospace,Menlo,Consolas,'DejaVu Sans Mono',monospace" font-size="112" font-weight="800" letter-spacing="14" fill="#2fe3ee">DEVX</text>
|
|
22
22
|
</g>
|
package/assets/preview.svg
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
<rect x="595.2" y="574.0" width="243.2" height="19" fill="#000000"/>
|
|
33
33
|
<text x="44.8" y="62.0" class="t" fill="#b794f6">▌</text>
|
|
34
34
|
<text x="61.6" y="62.0" class="t b" fill="#f4f7fb">devx</text>
|
|
35
|
-
<text x="112.0" y="62.0" class="t" fill="#5f6b7a">v1.9.
|
|
35
|
+
<text x="112.0" y="62.0" class="t" fill="#5f6b7a">v1.9.8</text>
|
|
36
36
|
<text x="61.6" y="106.0" class="t" fill="#5f6b7a">model</text>
|
|
37
37
|
<text x="128.8" y="106.0" class="t" fill="#e5eaf1">deepseek-v4.1-flash · think high</text>
|
|
38
38
|
<text x="406.0" y="106.0" class="t" fill="#5f6b7a">/model</text>
|
package/dist/cli/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { globalSnapshotManager } from '../core/snapshot.js';
|
|
|
17
17
|
import { MemoryManager, memoryForPrompt } from '../core/memory.js';
|
|
18
18
|
import { startServer, stopServer, displayServerBanner } from './server.js';
|
|
19
19
|
import { notifyDevice, acquireWakeLock, isTermux, notificationBody, sendDesktopNotification } from '../core/notify.js';
|
|
20
|
+
import { isTerminalFocused, setFocusTracking, setTerminalFocused } from '../core/attention.js';
|
|
20
21
|
import { copyToClipboard } from './clipboard-write.js';
|
|
21
22
|
import { REWIND_CURRENT, messageDraft, rewindItems, userEntries } from './rewind.js';
|
|
22
23
|
import { runPicker } from './picker.js';
|
|
@@ -68,7 +69,7 @@ import { modelAcceptsImages } from '../core/vision.js';
|
|
|
68
69
|
import { contextBreakdown } from '../core/context-stats.js';
|
|
69
70
|
import { runUsagePanel } from './usage-panel.js';
|
|
70
71
|
import { TurnComposer } from './typeahead.js';
|
|
71
|
-
import {
|
|
72
|
+
import { extendedKeysOff, normalizeKey, splitKeys } from './keys.js';
|
|
72
73
|
import { LANGUAGES, getLanguage, setLanguage, t } from '../core/i18n.js';
|
|
73
74
|
import { anthropicOnlyModels, formatFromBaseUrl, refreshFormats } from '../core/formats.js';
|
|
74
75
|
import { providerFormat } from '../providers/index.js';
|
|
@@ -593,7 +594,7 @@ process.on('exit', () => {
|
|
|
593
594
|
process.stdin.setRawMode(false);
|
|
594
595
|
}
|
|
595
596
|
if (process.stdout.isTTY)
|
|
596
|
-
process.stdout.write(
|
|
597
|
+
process.stdout.write(extendedKeysOff() + '\x1B[?1004l' + RESTORE_TERMINAL_TITLE + '\x1B[?25h');
|
|
597
598
|
resetTerminalTheme();
|
|
598
599
|
});
|
|
599
600
|
process.on('SIGINT', () => {
|
|
@@ -605,11 +606,18 @@ process.on('SIGINT', () => {
|
|
|
605
606
|
process.stdin.setRawMode(false);
|
|
606
607
|
process.stdin.pause();
|
|
607
608
|
}
|
|
608
|
-
process.stdout.write(
|
|
609
|
+
process.stdout.write(extendedKeysOff() + '\x1B[?1004l' + RESTORE_TERMINAL_TITLE + '\x1B[?25h'); // restore cursor
|
|
609
610
|
resetTerminalTheme();
|
|
610
611
|
process.exit(0);
|
|
611
612
|
}
|
|
612
613
|
});
|
|
614
|
+
/**
|
|
615
|
+
* The new version has been started (it owns the terminal now) and this process ends when it ends.
|
|
616
|
+
* Nothing else may run here: a second prompt on the same terminal takes keys from the first.
|
|
617
|
+
*/
|
|
618
|
+
function waitForRestartedDevx() {
|
|
619
|
+
return new Promise(() => { });
|
|
620
|
+
}
|
|
613
621
|
function clearTerminalScreen() {
|
|
614
622
|
if (process.stdout.isTTY) {
|
|
615
623
|
// \x1b[2J: clear screen, \x1b[3J: clear scrollback buffer (crucial for Termux/xterm), \x1b[H: cursor to home
|
|
@@ -932,10 +940,16 @@ export async function main() {
|
|
|
932
940
|
MemoryManager.enabled = config.enableMemory !== false;
|
|
933
941
|
const sessionManager = new SessionManager(config.model, planMode);
|
|
934
942
|
drawLogo(config.banner || 'full');
|
|
935
|
-
await runStartupUpdateCheck(config, { save: saveConfig })
|
|
936
|
-
|
|
937
|
-
// After the servers are up: the prompt says how the agent reaches the tools it has to load.
|
|
943
|
+
if ((await runStartupUpdateCheck(config, { save: saveConfig })) === 'updated')
|
|
944
|
+
await waitForRestartedDevx();
|
|
938
945
|
history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
|
|
946
|
+
// The servers start while the prompt is already there (starting them takes seconds: `npx` and the
|
|
947
|
+
// network). What needs them waits for `mcpReady`: the first request, and /mcp. When they are up the
|
|
948
|
+
// prompt is written again, so that it says how the agent reaches the tools that wait to be loaded.
|
|
949
|
+
const mcpReady = MCPManager.getInstance()
|
|
950
|
+
.init(undefined, config.projectMcpTrusted === true)
|
|
951
|
+
.then(async () => { history.updateSystemPrompt(await buildSystemPrompt(planMode)); })
|
|
952
|
+
.catch(() => undefined);
|
|
939
953
|
// Ask a custom endpoint what the current model can do (cached for a day; never blocks the prompt).
|
|
940
954
|
void refreshEndpoint(config).catch(() => null);
|
|
941
955
|
const sessionCost = new CostLedger();
|
|
@@ -988,7 +1002,6 @@ export async function main() {
|
|
|
988
1002
|
// The name of the chat: in the title of the terminal window (`◈ Short title`) and in the list of sessions.
|
|
989
1003
|
let windowTitle = '';
|
|
990
1004
|
let titleAttempts = 0;
|
|
991
|
-
let terminalFocused = true; // updated by the terminal's focus reports while a task runs
|
|
992
1005
|
const setWindowTitle = (title) => {
|
|
993
1006
|
windowTitle = title;
|
|
994
1007
|
if (process.stdout.isTTY)
|
|
@@ -1276,7 +1289,8 @@ export async function main() {
|
|
|
1276
1289
|
continue;
|
|
1277
1290
|
}
|
|
1278
1291
|
if (cmd === '/update') {
|
|
1279
|
-
await runManualUpdate()
|
|
1292
|
+
if ((await runManualUpdate()) === 'updated')
|
|
1293
|
+
await waitForRestartedDevx();
|
|
1280
1294
|
continue;
|
|
1281
1295
|
}
|
|
1282
1296
|
if (cmd === '/help') {
|
|
@@ -1858,6 +1872,7 @@ export async function main() {
|
|
|
1858
1872
|
continue;
|
|
1859
1873
|
}
|
|
1860
1874
|
if (cmd === '/mcp') {
|
|
1875
|
+
await mcpReady;
|
|
1861
1876
|
const sub = (parts[1] || '').toLowerCase();
|
|
1862
1877
|
if (sub === 'reload' || sub === 'restart' || sub === 'r') {
|
|
1863
1878
|
const s = new UiSpinner();
|
|
@@ -2109,6 +2124,8 @@ export async function main() {
|
|
|
2109
2124
|
});
|
|
2110
2125
|
// The format of the gateway is learned once (cached); the first request waits for it.
|
|
2111
2126
|
await refreshFormats(config).catch(() => null);
|
|
2127
|
+
// The MCP servers may still be starting (see mcpReady): the request goes out with their tools.
|
|
2128
|
+
await mcpReady;
|
|
2112
2129
|
const provider = createProvider(config);
|
|
2113
2130
|
const tools = getToolSource(planMode);
|
|
2114
2131
|
const guard = new CLIConsoleGuard(config.autoApprove, [...(config.bashAllowlist || [])], {
|
|
@@ -2424,7 +2441,7 @@ export async function main() {
|
|
|
2424
2441
|
let text = (Buffer.isBuffer(data) ? data : Buffer.from(String(data))).toString('utf8');
|
|
2425
2442
|
// Focus reports: ESC [ I (got the focus) and ESC [ O (lost it). They are not keys.
|
|
2426
2443
|
if (text.includes('\x1b[I') || text.includes('\x1b[O')) {
|
|
2427
|
-
|
|
2444
|
+
setTerminalFocused(text.lastIndexOf('\x1b[I') > text.lastIndexOf('\x1b[O'));
|
|
2428
2445
|
text = text.replace(/\x1b\[[IO]/g, '');
|
|
2429
2446
|
if (!text)
|
|
2430
2447
|
return;
|
|
@@ -2468,6 +2485,7 @@ export async function main() {
|
|
|
2468
2485
|
process.stdout.on('resize', onResize);
|
|
2469
2486
|
uiSpinner?.mute(true);
|
|
2470
2487
|
process.stdout.write('\x1b[?1004h'); // the terminal tells when it loses or gets the focus
|
|
2488
|
+
setFocusTracking(true); // approval cards and questions notify when it is not in front
|
|
2471
2489
|
}
|
|
2472
2490
|
}
|
|
2473
2491
|
catch { }
|
|
@@ -2843,7 +2861,7 @@ export async function main() {
|
|
|
2843
2861
|
if (shouldNotify)
|
|
2844
2862
|
notifyDevice(heading, preview || `Task completed in ${secs}s!`);
|
|
2845
2863
|
}
|
|
2846
|
-
else if (!
|
|
2864
|
+
else if (!isTerminalFocused()) {
|
|
2847
2865
|
// The terminal is not in front: a notification with the start of the answer.
|
|
2848
2866
|
if (sendDesktopNotification(heading, preview || `Task completed in ${secs}s`) === 'none' && shouldNotify)
|
|
2849
2867
|
notifyDevice(heading, preview || `Task completed in ${secs}s!`);
|
|
@@ -2932,6 +2950,7 @@ export async function main() {
|
|
|
2932
2950
|
const unsent = composer.stop();
|
|
2933
2951
|
if (process.stdout.isTTY)
|
|
2934
2952
|
process.stdout.write('\x1b[?1004l');
|
|
2953
|
+
setFocusTracking(false);
|
|
2935
2954
|
releasePauseHook();
|
|
2936
2955
|
process.stdout.removeListener('resize', onResize);
|
|
2937
2956
|
if (!currentDraft && (unsent.draft || unsent.queued.length > 0)) {
|
package/dist/cli/keys.js
CHANGED
|
@@ -28,11 +28,50 @@ export function isNewlineKey(key) {
|
|
|
28
28
|
* Asks the terminal to tell Ctrl+Enter and Shift+Enter apart from Enter (a plain terminal sends
|
|
29
29
|
* the same byte for all of them). Two protocols are requested, a terminal answers the one it knows:
|
|
30
30
|
* the kitty keyboard protocol (kitty, foot, ghostty, wezterm, alacritty, ...) and xterm's
|
|
31
|
-
* modifyOtherKeys.
|
|
32
|
-
*
|
|
31
|
+
* modifyOtherKeys. While on, the keys arrive as `CSI … u`: run them through `normalizeKey`.
|
|
32
|
+
*
|
|
33
|
+
* Only sent to terminals that are known to understand them (`extendedKeysSupported`): a terminal
|
|
34
|
+
* that does not parses `CSI > 1 u` as "restore the cursor" and `CSI > 4 ; 1 m` as "underline, bold",
|
|
35
|
+
* which throws the prompt to the top of the screen and draws lines under every row (Termux, UserLAnd).
|
|
36
|
+
*/
|
|
37
|
+
const EXTENDED_KEYS_ON_SEQ = '\x1b[>1u\x1b[>4;1m';
|
|
38
|
+
const EXTENDED_KEYS_OFF_SEQ = '\x1b[<u\x1b[>4;0m';
|
|
39
|
+
const KEYBOARD_PROTOCOL_PROGRAMS = new Set(['ghostty', 'wezterm', 'iterm.app', 'kitty', 'foot', 'alacritty', 'rio', 'contour']);
|
|
40
|
+
/**
|
|
41
|
+
* Does this terminal understand the extended keyboard modes? Decided from the environment, and
|
|
42
|
+
* only for terminals known to: a wrong guess in the other direction costs Ctrl+Enter (Alt+Enter
|
|
43
|
+
* and a backslash before Enter still break the line), a wrong guess in this one wrecks the screen.
|
|
44
|
+
* `DEVX_EXTENDED_KEYS=1` forces it on, `DEVX_EXTENDED_KEYS=0` off.
|
|
33
45
|
*/
|
|
34
|
-
export
|
|
35
|
-
|
|
46
|
+
export function extendedKeysSupported(env = process.env, platform = process.platform) {
|
|
47
|
+
const forced = (env.DEVX_EXTENDED_KEYS ?? '').trim().toLowerCase();
|
|
48
|
+
if (['1', 'on', 'true', 'yes'].includes(forced))
|
|
49
|
+
return true;
|
|
50
|
+
if (['0', 'off', 'false', 'no'].includes(forced))
|
|
51
|
+
return false;
|
|
52
|
+
// Android: Termux, UserLAnd and other terminals built on the same emulator.
|
|
53
|
+
if (platform === 'android' || env.TERMUX_VERSION || env.PREFIX?.includes('com.termux') || env.ANDROID_ROOT || env.ANDROID_DATA)
|
|
54
|
+
return false;
|
|
55
|
+
// Multiplexers sit between devx and the real terminal: they decide for themselves.
|
|
56
|
+
if (env.TMUX || env.STY)
|
|
57
|
+
return false;
|
|
58
|
+
const term = (env.TERM ?? '').toLowerCase();
|
|
59
|
+
if (term.startsWith('screen') || term.startsWith('tmux') || term === 'linux' || term === 'dumb')
|
|
60
|
+
return false;
|
|
61
|
+
if (env.KITTY_WINDOW_ID || env.GHOSTTY_RESOURCES_DIR || env.WEZTERM_EXECUTABLE)
|
|
62
|
+
return true;
|
|
63
|
+
if (/kitty|ghostty|foot|alacritty|wezterm/.test(term))
|
|
64
|
+
return true;
|
|
65
|
+
return KEYBOARD_PROTOCOL_PROGRAMS.has((env.TERM_PROGRAM ?? '').toLowerCase());
|
|
66
|
+
}
|
|
67
|
+
/** What to write to turn the extended keyboard modes on (nothing on a terminal that does not know them). */
|
|
68
|
+
export function extendedKeysOn() {
|
|
69
|
+
return extendedKeysSupported() ? EXTENDED_KEYS_ON_SEQ : '';
|
|
70
|
+
}
|
|
71
|
+
/** What to write to turn them off again. */
|
|
72
|
+
export function extendedKeysOff() {
|
|
73
|
+
return extendedKeysSupported() ? EXTENDED_KEYS_OFF_SEQ : '';
|
|
74
|
+
}
|
|
36
75
|
/**
|
|
37
76
|
* Turns the extended encodings back into the keys the rest of the code knows:
|
|
38
77
|
* `CSI 99;5u` and `CSI 27;5;99~` become Ctrl+C (0x03), `CSI 27u` becomes Esc, and every way
|
package/dist/cli/prompt.js
CHANGED
|
@@ -8,7 +8,7 @@ import { filterCommandPalette, estimatePromptTokens, formatPromptTokenBadge } fr
|
|
|
8
8
|
import { PromptHistoryNavigator } from './history_search.js';
|
|
9
9
|
import { buildPromptFrame, moveCursorRow, palette, renderUserEcho, resolveMode } from './ui.js';
|
|
10
10
|
import { displayWidth, padEndWidth, rowsToTop, truncateWidth } from './width.js';
|
|
11
|
-
import {
|
|
11
|
+
import { extendedKeysOff, extendedKeysOn, isNewlineKey, normalizeKey } from './keys.js';
|
|
12
12
|
export const SLASH_COMMANDS = [
|
|
13
13
|
{ cmd: '/new', desc: 'Start a new clean chat session' },
|
|
14
14
|
{ cmd: '/resume', desc: 'Resume a previous chat session' },
|
|
@@ -99,7 +99,7 @@ export function askPrompt(opts = {}) {
|
|
|
99
99
|
process.stdin.setRawMode(true);
|
|
100
100
|
process.stdout.write('\x1b[?2004h'); // Enable bracketed paste mode
|
|
101
101
|
// Lets the terminal tell Ctrl+Enter and Shift+Enter (a new paragraph) from Enter (send).
|
|
102
|
-
process.stdout.write(
|
|
102
|
+
process.stdout.write(extendedKeysOn());
|
|
103
103
|
}
|
|
104
104
|
// ── framed prompt (clean layout) ──
|
|
105
105
|
// The frame is a block of rows; the cursor rests on the input row. Redrawing moves up
|
|
@@ -552,7 +552,7 @@ export function askPrompt(opts = {}) {
|
|
|
552
552
|
disposed = true;
|
|
553
553
|
unsubscribeOwner?.();
|
|
554
554
|
if (process.stdout.isTTY) {
|
|
555
|
-
process.stdout.write('\x1b[?2004l' +
|
|
555
|
+
process.stdout.write('\x1b[?2004l' + extendedKeysOff());
|
|
556
556
|
}
|
|
557
557
|
process.stdout.removeListener('resize', onResize);
|
|
558
558
|
process.stdin.removeListener('data', onData);
|
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 { extendedKeysOff, extendedKeysOn, 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';
|
|
@@ -209,7 +209,7 @@ export class TurnComposer {
|
|
|
209
209
|
this.shown = false;
|
|
210
210
|
this.original = this.out.write.bind(this.out);
|
|
211
211
|
this.out.write = ((chunk, encoding, cb) => this.through(chunk, encoding, cb));
|
|
212
|
-
this.write(
|
|
212
|
+
this.write(extendedKeysOn() + this.drawSeq());
|
|
213
213
|
// The working line animates: its time and counter are redrawn while the output is quiet.
|
|
214
214
|
if (this.status) {
|
|
215
215
|
this.timer = setInterval(() => this.tick(), 60);
|
|
@@ -225,7 +225,7 @@ export class TurnComposer {
|
|
|
225
225
|
this.timer = null;
|
|
226
226
|
}
|
|
227
227
|
if (this.active) {
|
|
228
|
-
this.write(this.eraseSeq() +
|
|
228
|
+
this.write(this.eraseSeq() + extendedKeysOff() + '\x1b[?25h');
|
|
229
229
|
if (this.original)
|
|
230
230
|
this.out.write = this.original;
|
|
231
231
|
this.original = null;
|
|
@@ -241,14 +241,14 @@ export class TurnComposer {
|
|
|
241
241
|
if (!this.active || this.suspended)
|
|
242
242
|
return;
|
|
243
243
|
// The card or question that takes over reads plain keys: the extended encodings go off for it.
|
|
244
|
-
this.write(this.eraseSeq() +
|
|
244
|
+
this.write(this.eraseSeq() + extendedKeysOff());
|
|
245
245
|
this.suspended = true;
|
|
246
246
|
}
|
|
247
247
|
resume() {
|
|
248
248
|
if (!this.active || !this.suspended)
|
|
249
249
|
return;
|
|
250
250
|
this.suspended = false;
|
|
251
|
-
this.write(
|
|
251
|
+
this.write(extendedKeysOn() + this.drawSeq());
|
|
252
252
|
}
|
|
253
253
|
/** Builds the frame again (the context percentage or the cost changed) and draws it. */
|
|
254
254
|
refresh() {
|
|
@@ -344,7 +344,9 @@ export class TurnComposer {
|
|
|
344
344
|
if (!this.shown)
|
|
345
345
|
return '';
|
|
346
346
|
this.shown = false;
|
|
347
|
-
|
|
347
|
+
// The erase starts from plain attributes: a style left on by the text above (an underline, a
|
|
348
|
+
// background) would be painted into every row it wipes (stripes across the screen).
|
|
349
|
+
return '\x1b7\x1b[0m\x1b[1B\r\x1b[J\x1b8';
|
|
348
350
|
}
|
|
349
351
|
/** Draws the frame under the cursor row and puts the cursor back where the text goes on. */
|
|
350
352
|
drawSeq() {
|
|
@@ -358,7 +360,7 @@ export class TurnComposer {
|
|
|
358
360
|
// ESC D moves down (scrolling at the bottom edge) and keeps the column; then back up.
|
|
359
361
|
let seq = '\x1b[?25l' + '\x1bD'.repeat(n) + `\x1b[${n}A` + '\x1b7';
|
|
360
362
|
for (const line of lines)
|
|
361
|
-
seq += `\x1b[1B\r\x1b[2K${line}\x1b[0m`;
|
|
363
|
+
seq += `\x1b[0m\x1b[1B\r\x1b[2K${line}\x1b[0m`;
|
|
362
364
|
return seq + '\x1b8';
|
|
363
365
|
}
|
|
364
366
|
}
|
|
@@ -4,7 +4,7 @@ import { runPicker } from './picker.js';
|
|
|
4
4
|
import { palette, renderNotice } from './ui.js';
|
|
5
5
|
import { displayWidth, truncateWidth } from './width.js';
|
|
6
6
|
import { getLanguage, t } from '../core/i18n.js';
|
|
7
|
-
import { UpdateError, buildUpdateSteps, checkForUpdates, detectInstallMethod, getCurrentVersion, restartDevx, runUpdateSteps, } from './updater.js';
|
|
7
|
+
import { UpdateError, buildUpdateSteps, checkForUpdates, checkForUpdatesRemembered, detectInstallMethod, readCachedCheck, getCurrentVersion, restartDevx, runUpdateSteps, } from './updater.js';
|
|
8
8
|
/**
|
|
9
9
|
* The update screens, in the style of the rest of devx:
|
|
10
10
|
*
|
|
@@ -344,21 +344,24 @@ export async function runStartupUpdateCheck(config, deps = {}) {
|
|
|
344
344
|
if (!out.isTTY)
|
|
345
345
|
return;
|
|
346
346
|
const current = await getCurrentVersion();
|
|
347
|
-
|
|
347
|
+
// A recent answer is used as it is: no network, no "checking" line, no wait before the prompt.
|
|
348
|
+
const cached = deps.check ? undefined : await readCachedCheck();
|
|
349
|
+
const result = cached ?? await withCheckingLine(out, current, () => (deps.check ?? checkForUpdatesRemembered)(6000));
|
|
348
350
|
if (result.updateAvailable) {
|
|
349
351
|
if (config.skippedUpdate === result.latestVersion)
|
|
350
352
|
return;
|
|
351
|
-
|
|
353
|
+
return presentUpdate(result, {
|
|
352
354
|
offerSkip: true,
|
|
353
355
|
save: async (version) => {
|
|
354
356
|
config.skippedUpdate = version;
|
|
355
357
|
await deps.save?.(config);
|
|
356
358
|
},
|
|
357
359
|
}, deps);
|
|
358
|
-
return;
|
|
359
360
|
}
|
|
360
361
|
if (result.error)
|
|
361
362
|
return; // offline at start-up is not worth a word
|
|
363
|
+
if (cached)
|
|
364
|
+
return; // up to date, as the last check said: nothing to show
|
|
362
365
|
// Up to date: a short line that goes away by itself.
|
|
363
366
|
out.write(` ${palette.ok('✓')} ${palette.muted(t('update.upToDate', { version: result.currentVersion }))}\n`);
|
|
364
367
|
await sleep(deps.tickMs !== undefined ? 0 : 450);
|
|
@@ -371,7 +374,7 @@ export async function runManualUpdate(deps = {}) {
|
|
|
371
374
|
const result = await withCheckingLine(out, current, () => (deps.check ?? checkForUpdates)(10000));
|
|
372
375
|
const cols = columnsOf(out);
|
|
373
376
|
if (result.updateAvailable) {
|
|
374
|
-
|
|
377
|
+
return presentUpdate(result, { offerSkip: false }, deps);
|
|
375
378
|
}
|
|
376
379
|
else if (result.error) {
|
|
377
380
|
out.write(renderNotice('warn', t('update.offline', { reason: result.error }), cols) + '\n');
|
package/dist/cli/updater.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
3
4
|
import fs from 'fs/promises';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
6
|
const GITHUB_REPO_URL = 'https://github.com/apvcode/Termux-Dev';
|
|
@@ -40,6 +41,12 @@ export async function checkForUpdates(timeoutMs = 10000) {
|
|
|
40
41
|
// mobile connection a stalled second request used to block devx at startup forever.
|
|
41
42
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
42
43
|
try {
|
|
44
|
+
// The release is asked at the same time as the package.json, not after it: the check takes as long
|
|
45
|
+
// as the slower of the two, not both. A failure of the release request is not an error of the check.
|
|
46
|
+
const releaseRequest = fetch(`${REMOTE_RELEASE_URL}?t=${Date.now()}`, {
|
|
47
|
+
signal,
|
|
48
|
+
headers: { 'User-Agent': 'devx-updater' }
|
|
49
|
+
}).catch(() => null);
|
|
43
50
|
// 1. Try fetching latest package.json with cache-busting timestamp
|
|
44
51
|
const cacheBusterUrl = `${REMOTE_PKG_URL}?t=${Date.now()}`;
|
|
45
52
|
const res = await fetch(cacheBusterUrl, {
|
|
@@ -59,11 +66,8 @@ export async function checkForUpdates(timeoutMs = 10000) {
|
|
|
59
66
|
}
|
|
60
67
|
// 2. Also check latest GitHub release tag
|
|
61
68
|
try {
|
|
62
|
-
const relRes = await
|
|
63
|
-
|
|
64
|
-
headers: { 'User-Agent': 'devx-updater' }
|
|
65
|
-
});
|
|
66
|
-
if (relRes.ok) {
|
|
69
|
+
const relRes = await releaseRequest;
|
|
70
|
+
if (relRes?.ok) {
|
|
67
71
|
const relData = await relRes.json();
|
|
68
72
|
const relTag = (relData.tag_name || relData.name || '').replace(/^v/, '').trim();
|
|
69
73
|
if (relTag && (!latestVersion || isNewerVersion(relTag, latestVersion))) {
|
|
@@ -99,6 +103,56 @@ export async function checkForUpdates(timeoutMs = 10000) {
|
|
|
99
103
|
};
|
|
100
104
|
}
|
|
101
105
|
}
|
|
106
|
+
// ───────────────────────── remembering the check ─────────────────────────
|
|
107
|
+
/** An answer is good for this long; after it devx asks again. */
|
|
108
|
+
const CHECK_FRESH_MS = 6 * 60 * 60 * 1000;
|
|
109
|
+
/** A failed check (offline, a stalled connection) is not repeated at every start: that would stall every start. */
|
|
110
|
+
const CHECK_FAILED_MS = 30 * 60 * 1000;
|
|
111
|
+
const checkFile = () => path.join(os.homedir(), '.devx', 'update-check.json');
|
|
112
|
+
/** The last check, when it is recent enough to be trusted at start-up; undefined when devx has to ask again. */
|
|
113
|
+
export async function readCachedCheck(now = Date.now()) {
|
|
114
|
+
try {
|
|
115
|
+
const saved = JSON.parse(await fs.readFile(checkFile(), 'utf8'));
|
|
116
|
+
const age = now - Number(saved.checkedAt);
|
|
117
|
+
// A clock that went backwards makes the age negative: such an answer is not trusted.
|
|
118
|
+
if (!Number.isFinite(age) || age < 0)
|
|
119
|
+
return undefined;
|
|
120
|
+
const currentVersion = await getCurrentVersion();
|
|
121
|
+
if (saved.error) {
|
|
122
|
+
return age < CHECK_FAILED_MS ? { updateAvailable: false, currentVersion, latestVersion: currentVersion, error: saved.error } : undefined;
|
|
123
|
+
}
|
|
124
|
+
if (age >= CHECK_FRESH_MS || !saved.latestVersion)
|
|
125
|
+
return undefined;
|
|
126
|
+
// Judged against the version that runs now: after an update the old answer no longer says "update".
|
|
127
|
+
return {
|
|
128
|
+
updateAvailable: isNewerVersion(saved.latestVersion, currentVersion),
|
|
129
|
+
currentVersion,
|
|
130
|
+
latestVersion: saved.latestVersion,
|
|
131
|
+
releaseNotes: saved.releaseNotes || undefined,
|
|
132
|
+
publishedAt: saved.publishedAt || undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Keeps the answer of a check for the next start-ups. Never throws: this is only a shortcut. */
|
|
140
|
+
export async function rememberCheck(result, now = Date.now()) {
|
|
141
|
+
try {
|
|
142
|
+
const saved = result.error
|
|
143
|
+
? { checkedAt: now, error: result.error }
|
|
144
|
+
: { checkedAt: now, latestVersion: result.latestVersion, releaseNotes: result.releaseNotes, publishedAt: result.publishedAt };
|
|
145
|
+
await fs.mkdir(path.dirname(checkFile()), { recursive: true, mode: 0o700 });
|
|
146
|
+
await fs.writeFile(checkFile(), JSON.stringify(saved));
|
|
147
|
+
}
|
|
148
|
+
catch { }
|
|
149
|
+
}
|
|
150
|
+
/** `checkForUpdates`, and the answer is kept for the next start-ups (the start-up check reads it, see readCachedCheck). */
|
|
151
|
+
export async function checkForUpdatesRemembered(timeoutMs = 10000) {
|
|
152
|
+
const result = await checkForUpdates(timeoutMs);
|
|
153
|
+
await rememberCheck(result);
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
102
156
|
/** How this devx was installed: from the npm package, or from a git clone (a developer's copy). */
|
|
103
157
|
export async function detectInstallMethod() {
|
|
104
158
|
const here = fileURLToPath(import.meta.url);
|
|
@@ -226,9 +280,26 @@ export async function runUpdateSteps(steps, events = {}, now = Date.now) {
|
|
|
226
280
|
}
|
|
227
281
|
return { ok: true };
|
|
228
282
|
}
|
|
229
|
-
/**
|
|
283
|
+
/**
|
|
284
|
+
* Starts devx again, with the same arguments, and ends this process when the new one ends.
|
|
285
|
+
*
|
|
286
|
+
* From here on this process only waits: the terminal belongs to the new one. It stops reading the
|
|
287
|
+
* keyboard (two programs reading one terminal take the keys from each other, and a typed line
|
|
288
|
+
* can end up in the shell) and no longer reacts to Ctrl+C itself (the new devx gets it).
|
|
289
|
+
*/
|
|
230
290
|
export function restartDevx() {
|
|
291
|
+
try {
|
|
292
|
+
process.stdin.pause();
|
|
293
|
+
}
|
|
294
|
+
catch { }
|
|
231
295
|
const child = spawn(process.argv[0], process.argv.slice(1), { stdio: 'inherit' });
|
|
296
|
+
process.removeAllListeners('SIGINT');
|
|
297
|
+
process.on('SIGINT', () => { });
|
|
298
|
+
process.removeAllListeners('SIGTERM');
|
|
299
|
+
process.on('SIGTERM', () => { try {
|
|
300
|
+
child.kill('SIGTERM');
|
|
301
|
+
}
|
|
302
|
+
catch { } });
|
|
232
303
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
233
304
|
child.on('error', () => process.exit(0));
|
|
234
305
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isTermux, notificationBody, notifyDevice, sendDesktopNotification } from './notify.js';
|
|
2
|
+
/**
|
|
3
|
+
* "devx is waiting for you": a notification when the agent needs an answer (an approval card, a
|
|
4
|
+
* question) and the person is not looking at the terminal. A finished task is announced elsewhere
|
|
5
|
+
* (the end of a turn); this is for the moments when the work is stopped until someone answers.
|
|
6
|
+
*/
|
|
7
|
+
let tracking = false;
|
|
8
|
+
let focused = true;
|
|
9
|
+
/**
|
|
10
|
+
* The terminal reports when it loses and gets the focus (while a task runs). Without those reports
|
|
11
|
+
* devx cannot tell whether anybody is looking, and a notification that pops up in front of someone who is
|
|
12
|
+
* typing in the terminal is worse than none, so nothing is sent then.
|
|
13
|
+
*/
|
|
14
|
+
export function setFocusTracking(on) {
|
|
15
|
+
tracking = on;
|
|
16
|
+
// A task starts because the person just pressed Enter here.
|
|
17
|
+
if (on)
|
|
18
|
+
focused = true;
|
|
19
|
+
}
|
|
20
|
+
export function setTerminalFocused(value) {
|
|
21
|
+
focused = value;
|
|
22
|
+
}
|
|
23
|
+
export function isTerminalFocused() {
|
|
24
|
+
return focused;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Tells the person that devx waits for them. Returns whether something was sent. Never throws.
|
|
28
|
+
* On Termux (Android) a notification always goes out: the person is most likely in another app, and
|
|
29
|
+
* the phone cannot tell devx. On a desktop it goes out only when the terminal is not in front.
|
|
30
|
+
*/
|
|
31
|
+
export function notifyAttention(title, body, deps = {}) {
|
|
32
|
+
try {
|
|
33
|
+
const text = notificationBody(body, 140) || title;
|
|
34
|
+
if (deps.termux ?? isTermux()) {
|
|
35
|
+
(deps.device ?? notifyDevice)(title, text);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (!tracking || focused)
|
|
39
|
+
return false;
|
|
40
|
+
const via = (deps.desktop ?? sendDesktopNotification)(title, text);
|
|
41
|
+
// No desktop notification service: the terminal bell at least marks the window as asking for attention.
|
|
42
|
+
if (via === 'none')
|
|
43
|
+
(deps.device ?? notifyDevice)(title, text);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** What an approval card asks, in a line for a notification: `bash: npm test`. */
|
|
51
|
+
export function approvalNotice(title, lines, danger) {
|
|
52
|
+
const first = (lines.find((l) => l.trim()) ?? '').trim();
|
|
53
|
+
return {
|
|
54
|
+
title: danger ? 'devx · dangerous action, approval needed' : 'devx · approval needed',
|
|
55
|
+
body: first ? `${title}: ${first}` : title,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** The question the agent asks, for a notification (and how many more follow). */
|
|
59
|
+
export function questionNotice(questions) {
|
|
60
|
+
const first = String(questions[0]?.question ?? '').trim();
|
|
61
|
+
const more = questions.length > 1 ? ` (+${questions.length - 1} more)` : '';
|
|
62
|
+
return { title: 'devx · question for you', body: `${first || 'The agent has a question'}${more}` };
|
|
63
|
+
}
|
package/dist/core/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const DEVX_VERSION = '1.9.
|
|
1
|
+
export const DEVX_VERSION = '1.9.8';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { approvalNotice, notifyAttention } from '../core/attention.js';
|
|
1
2
|
import pc from 'picocolors';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import os from 'os';
|
|
@@ -377,6 +378,9 @@ export class CLIConsoleGuard {
|
|
|
377
378
|
return false;
|
|
378
379
|
}
|
|
379
380
|
const request = buildPermissionRequest(toolName, args);
|
|
381
|
+
// The work stops here until somebody answers: if the terminal is not in front, a notification says so.
|
|
382
|
+
const notice = approvalNotice(request.title, request.lines, request.danger);
|
|
383
|
+
notifyAttention(notice.title, notice.body);
|
|
380
384
|
// Only requests that are safe to remember offer "Session" and "Always".
|
|
381
385
|
const remember = request.danger ? null : rememberKeyFor(toolName, args);
|
|
382
386
|
const offer = remember ? { session: true, always: true } : {};
|
package/dist/tools/questions.js
CHANGED
|
@@ -3,6 +3,7 @@ import { runPicker } from '../cli/picker.js';
|
|
|
3
3
|
import { palette } from '../cli/ui.js';
|
|
4
4
|
import { wrapWords } from '../cli/width.js';
|
|
5
5
|
import { t } from '../core/i18n.js';
|
|
6
|
+
import { notifyAttention, questionNotice } from '../core/attention.js';
|
|
6
7
|
export const askQuestionsTool = {
|
|
7
8
|
name: 'ask_questions',
|
|
8
9
|
definition: {
|
|
@@ -45,6 +46,9 @@ export const askQuestionsTool = {
|
|
|
45
46
|
if (!args.questions || !Array.isArray(args.questions) || args.questions.length === 0) {
|
|
46
47
|
return 'No questions provided.';
|
|
47
48
|
}
|
|
49
|
+
// The agent waits for the answer: if the terminal is not in front, a notification says so.
|
|
50
|
+
const notice = questionNotice(args.questions);
|
|
51
|
+
notifyAttention(notice.title, notice.body);
|
|
48
52
|
const results = [];
|
|
49
53
|
const total = args.questions.length;
|
|
50
54
|
const cols = process.stdout.columns || 80;
|
package/package.json
CHANGED