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 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.5</tspan></text>
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>
@@ -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.5</text>
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 { EXTENDED_KEYS_OFF, normalizeKey, splitKeys } from './keys.js';
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(EXTENDED_KEYS_OFF + '\x1B[?1004l' + RESTORE_TERMINAL_TITLE + '\x1B[?25h');
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(EXTENDED_KEYS_OFF + '\x1B[?1004l' + RESTORE_TERMINAL_TITLE + '\x1B[?25h'); // restore cursor
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
- await MCPManager.getInstance().init(undefined, config.projectMcpTrusted === true);
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
- terminalFocused = text.lastIndexOf('\x1b[I') > text.lastIndexOf('\x1b[O');
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 (!terminalFocused) {
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. Terminals that know neither ignore both, and Alt+Enter still works.
32
- * While on, the keys arrive as `CSI … u`: run them through `normalizeKey`.
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 const EXTENDED_KEYS_ON = '\x1b[>1u\x1b[>4;1m';
35
- export const EXTENDED_KEYS_OFF = '\x1b[<u\x1b[>4;0m';
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
@@ -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 { EXTENDED_KEYS_OFF, EXTENDED_KEYS_ON, isNewlineKey, normalizeKey } from './keys.js';
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(EXTENDED_KEYS_ON);
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' + EXTENDED_KEYS_OFF);
555
+ process.stdout.write('\x1b[?2004l' + extendedKeysOff());
556
556
  }
557
557
  process.stdout.removeListener('resize', onResize);
558
558
  process.stdin.removeListener('data', onData);
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import { EXTENDED_KEYS_OFF, EXTENDED_KEYS_ON, isNewlineKey, normalizeKey, splitKeys } from './keys.js';
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(EXTENDED_KEYS_ON + this.drawSeq());
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() + EXTENDED_KEYS_OFF + '\x1b[?25h');
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() + EXTENDED_KEYS_OFF);
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(EXTENDED_KEYS_ON + this.drawSeq());
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
- return '\x1b7\x1b[1B\r\x1b[J\x1b8';
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
- const result = await withCheckingLine(out, current, () => (deps.check ?? checkForUpdates)(6000));
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
- await presentUpdate(result, {
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
- await presentUpdate(result, { offerSkip: false }, deps);
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');
@@ -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 fetch(`${REMOTE_RELEASE_URL}?t=${Date.now()}`, {
63
- signal,
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
- /** Starts devx again, with the same arguments, and ends this process when the new one ends. */
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
+ }
@@ -1 +1 @@
1
- export const DEVX_VERSION = '1.9.5';
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 } : {};
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termux-dev",
3
- "version": "1.9.5",
3
+ "version": "1.9.8",
4
4
  "description": "Ultra-fast, terminal-native AI coding assistant and agent built for Android Termux, Windows, macOS, and Linux.",
5
5
  "main": "dist/cli/index.js",
6
6
  "type": "module",