termux-dev 1.9.3 → 1.9.7

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,10 @@ 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.
72
+ - **Past chats (`search_chats`):**
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.
70
74
  - **18 Colour Themes on a Pure Black Background (`#0a0a0c`):**
71
75
  Deep OLED black for distraction-free CLI vibe-coding, with full palettes instead of a single accent: Cyan Cyber, Synthwave Purple, Matrix Hacker, Solar Amber, Ruby Crimson, Pure Monochrome, Nord Frost, Dracula, Tokyo Night, Gruvbox, Catppuccin Mocha, Solarized Dark, Rose Pine, Monokai, GitHub Dark, Deep Ocean, Sunset Glow and Forest Moss. See [Themes](#themes).
72
76
  - **Smart Session History (`/resume` & `/session`):**
@@ -90,6 +94,33 @@ The terminal interface is built around a framed prompt that carries the status i
90
94
  - **Node.js**: `v20.0.0` or higher
91
95
  - **npm**, **pnpm**, or **yarn**
92
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
+
93
124
  ### Fast Global Install (Recommended)
94
125
 
95
126
  ```bash
@@ -374,6 +405,7 @@ Colours are exact on terminals with truecolor support (`COLORTERM=truecolor`); o
374
405
  - **Undo for everything.** With git, `/undo` puts the whole folder back to how it was before the last turn, including what shell commands did (files in `.gitignore` are not touched).
375
406
  - **The approved plan** is saved in `.devx/plan.md` and given to the agent for 24 hours. **Project memory** longer than ~4000 characters is cut to the newest facts for the model.
376
407
  - **MCP.** `/mcp add <name> <command> [args]` or `/mcp add <name> <https-url>` (a remote server over HTTP; put a token in `headers` as `"Bearer ${TOKEN}"`), `/mcp remove <name>`, `/mcp import` (from Claude Code).
408
+ - **MCP tools on demand.** Every MCP tool schema is sent with every request, and a big server (Notion has ~80 tools) can cost 100k tokens before you type anything. So devx sends the tools of small servers at once and keeps the big ones back: the agent sees the list of servers in its `mcp_search` tool, finds and loads only the tools a task needs (`mcp_search` with keywords, tool names, or just a server name), and calls them from its next step. Loaded tools stay for the chat (a resumed chat gets back the ones it used). Which servers wait is decided by size (all tools sent together stay within ~4k tokens); force it per server with `"lazy": true` or `"lazy": false` in `mcp.json`.
377
409
 
378
410
  ## Security Model
379
411
 
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.3</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.7</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.3</text>
35
+ <text x="112.0" y="62.0" class="t" fill="#5f6b7a">v1.9.7</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>
@@ -5,7 +5,7 @@ import { Agent } from '../core/loop.js';
5
5
  import { buildSystemPrompt } from '../prompts/builder.js';
6
6
  import { createProvider } from '../providers/index.js';
7
7
  import { refreshFormats } from '../core/formats.js';
8
- import { getTools } from '../tools/index.js';
8
+ import { getToolSource } from '../tools/index.js';
9
9
  import { CLIConsoleGuard } from '../permissions/guard.js';
10
10
  import { globalSnapshotManager } from '../core/snapshot.js';
11
11
  import { UsageTracker } from '../core/usage.js';
@@ -18,8 +18,6 @@ export async function runHeadlessMode(userPrompt, config, options = {}) {
18
18
  const isQuiet = !!options.quiet;
19
19
  const isJson = !!options.json;
20
20
  let history = new History();
21
- const sysPrompt = await buildSystemPrompt(planMode);
22
- history.updateSystemPrompt(sysPrompt);
23
21
  history.addMessage({ role: 'user', content: userPrompt });
24
22
  let executedTools = [];
25
23
  let accumulatedText = '';
@@ -41,9 +39,11 @@ export async function runHeadlessMode(userPrompt, config, options = {}) {
41
39
  onAbort();
42
40
  try {
43
41
  await MCPManager.getInstance().init(undefined, config.projectMcpTrusted === true);
42
+ // Written once the servers are up: it tells how to reach the tools that wait to be loaded.
43
+ history.updateSystemPrompt(await buildSystemPrompt(planMode));
44
44
  await refreshFormats(config).catch(() => null);
45
45
  const provider = createProvider(config);
46
- const tools = getTools(planMode);
46
+ const tools = getToolSource(planMode);
47
47
  const guard = new CLIConsoleGuard(isYolo, [], { extra: isExtra });
48
48
  const agent = new Agent(config, provider, tools, history, guard);
49
49
  if (!isQuiet && !isJson) {
package/dist/cli/index.js CHANGED
@@ -11,12 +11,13 @@ import { History } from '../core/history.js';
11
11
  import { Agent } from '../core/loop.js';
12
12
  import { buildSystemPrompt } from '../prompts/builder.js';
13
13
  import { createProvider } from '../providers/index.js';
14
- import { getTools, lastPlanReady, resetPlanReady } from '../tools/index.js';
14
+ import { getTools, getToolSource, lastPlanReady, resetPlanReady } from '../tools/index.js';
15
15
  import { CLIConsoleGuard, newPermissionMemory } from '../permissions/guard.js';
16
16
  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';
@@ -930,12 +931,17 @@ export async function main() {
930
931
  enableDarkTheme(config.pureBlackTheme !== false);
931
932
  let history = new History();
932
933
  MemoryManager.enabled = config.enableMemory !== false;
933
- const sysPrompt = await buildSystemPrompt(planMode);
934
- history.addMessage({ role: 'system', content: sysPrompt });
935
934
  const sessionManager = new SessionManager(config.model, planMode);
936
935
  drawLogo(config.banner || 'full');
937
936
  await runStartupUpdateCheck(config, { save: saveConfig });
938
- await MCPManager.getInstance().init(undefined, config.projectMcpTrusted === true);
937
+ history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
938
+ // The servers start while the prompt is already there (starting them takes seconds: `npx` and the
939
+ // network). What needs them waits for `mcpReady`: the first request, and /mcp. When they are up the
940
+ // prompt is written again, so that it says how the agent reaches the tools that wait to be loaded.
941
+ const mcpReady = MCPManager.getInstance()
942
+ .init(undefined, config.projectMcpTrusted === true)
943
+ .then(async () => { history.updateSystemPrompt(await buildSystemPrompt(planMode)); })
944
+ .catch(() => undefined);
939
945
  // Ask a custom endpoint what the current model can do (cached for a day; never blocks the prompt).
940
946
  void refreshEndpoint(config).catch(() => null);
941
947
  const sessionCost = new CostLedger();
@@ -988,7 +994,6 @@ export async function main() {
988
994
  // The name of the chat: in the title of the terminal window (`◈ Short title`) and in the list of sessions.
989
995
  let windowTitle = '';
990
996
  let titleAttempts = 0;
991
- let terminalFocused = true; // updated by the terminal's focus reports while a task runs
992
997
  const setWindowTitle = (title) => {
993
998
  windowTitle = title;
994
999
  if (process.stdout.isTTY)
@@ -1018,6 +1023,7 @@ export async function main() {
1018
1023
  if (process.stdout.isTTY)
1019
1024
  process.stdout.write(RESTORE_TERMINAL_TITLE + SAVE_TERMINAL_TITLE);
1020
1025
  history = new History();
1026
+ MCPManager.getInstance().resetLoaded();
1021
1027
  history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
1022
1028
  sessionCost.reset();
1023
1029
  costLimitRaised = null;
@@ -1336,6 +1342,8 @@ export async function main() {
1336
1342
  for (const m of loaded.messages) {
1337
1343
  history.addMessage(m);
1338
1344
  }
1345
+ MCPManager.getInstance().resetLoaded();
1346
+ MCPManager.getInstance().restoreLoaded(loaded.messages);
1339
1347
  if (loaded.model) {
1340
1348
  config.model = loaded.model;
1341
1349
  config.maxContextTokens = resolveContextLimit({ ...config, maxContextTokens: 0 }).limit;
@@ -1855,6 +1863,7 @@ export async function main() {
1855
1863
  continue;
1856
1864
  }
1857
1865
  if (cmd === '/mcp') {
1866
+ await mcpReady;
1858
1867
  const sub = (parts[1] || '').toLowerCase();
1859
1868
  if (sub === 'reload' || sub === 'restart' || sub === 'r') {
1860
1869
  const s = new UiSpinner();
@@ -2106,8 +2115,10 @@ export async function main() {
2106
2115
  });
2107
2116
  // The format of the gateway is learned once (cached); the first request waits for it.
2108
2117
  await refreshFormats(config).catch(() => null);
2118
+ // The MCP servers may still be starting (see mcpReady): the request goes out with their tools.
2119
+ await mcpReady;
2109
2120
  const provider = createProvider(config);
2110
- const tools = getTools(planMode);
2121
+ const tools = getToolSource(planMode);
2111
2122
  const guard = new CLIConsoleGuard(config.autoApprove, [...(config.bashAllowlist || [])], {
2112
2123
  extra: !!config.autoApproveAll,
2113
2124
  memory: permissionMemory,
@@ -2135,6 +2146,10 @@ export async function main() {
2135
2146
  let spinnerActive = false;
2136
2147
  let thinkingActive = false;
2137
2148
  let thinkingStartTime = 0;
2149
+ // What one thought amounts to (estimated tokens, first and last piece), for the speed on its line.
2150
+ let thoughtTokens = 0;
2151
+ let thoughtFirstAt = 0;
2152
+ let thoughtLastAt = 0;
2138
2153
  // Thinking is collapsed by default: only a live line shows the newest words. ctrl+p shows it in full.
2139
2154
  let thinkingStreaming = false;
2140
2155
  let thoughtBuffer = '';
@@ -2247,9 +2262,7 @@ export async function main() {
2247
2262
  thoughtGutter.flush();
2248
2263
  }
2249
2264
  const elapsedSec = thinkingStartTime > 0 ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : '0.0';
2250
- const teleStats = config.ui?.liveTelemetry && telemetry.getTotalTokens() > 0
2251
- ? ` · ${LiveTelemetryTracker.formatTokenCount(telemetry.getTotalTokens())} tok · ${telemetry.getTokensPerSecond()} tok/s`
2252
- : '';
2265
+ const teleStats = LiveTelemetryTracker.thoughtStats(thoughtTokens, thoughtLastAt - thoughtFirstAt);
2253
2266
  if (cleanUi && !thinkingActive) {
2254
2267
  // Folded: one quiet line instead of the whole reasoning.
2255
2268
  if (spacerBeforeText)
@@ -2280,6 +2293,8 @@ export async function main() {
2280
2293
  thinkingActive = false;
2281
2294
  thinkingStreaming = false;
2282
2295
  thinkingStartTime = 0;
2296
+ thoughtTokens = 0;
2297
+ thoughtFirstAt = thoughtLastAt = 0;
2283
2298
  runPendingRedraw();
2284
2299
  };
2285
2300
  /** Prints the header of the reasoning block (once) and what has not been written of it yet. */
@@ -2417,7 +2432,7 @@ export async function main() {
2417
2432
  let text = (Buffer.isBuffer(data) ? data : Buffer.from(String(data))).toString('utf8');
2418
2433
  // Focus reports: ESC [ I (got the focus) and ESC [ O (lost it). They are not keys.
2419
2434
  if (text.includes('\x1b[I') || text.includes('\x1b[O')) {
2420
- terminalFocused = text.lastIndexOf('\x1b[I') > text.lastIndexOf('\x1b[O');
2435
+ setTerminalFocused(text.lastIndexOf('\x1b[I') > text.lastIndexOf('\x1b[O'));
2421
2436
  text = text.replace(/\x1b\[[IO]/g, '');
2422
2437
  if (!text)
2423
2438
  return;
@@ -2461,6 +2476,7 @@ export async function main() {
2461
2476
  process.stdout.on('resize', onResize);
2462
2477
  uiSpinner?.mute(true);
2463
2478
  process.stdout.write('\x1b[?1004h'); // the terminal tells when it loses or gets the focus
2479
+ setFocusTracking(true); // approval cards and questions notify when it is not in front
2464
2480
  }
2465
2481
  }
2466
2482
  catch { }
@@ -2497,12 +2513,16 @@ export async function main() {
2497
2513
  if (!thinkingStreaming) {
2498
2514
  thinkingStreaming = true;
2499
2515
  thinkingStartTime = Date.now();
2516
+ thoughtTokens = 0;
2517
+ thoughtFirstAt = thinkingStartTime;
2500
2518
  thoughtBuffer = '';
2501
2519
  thoughtPrinted = 0;
2502
2520
  liveShown = 0;
2503
2521
  liveAt = Date.now();
2504
2522
  }
2505
2523
  thoughtBuffer += event.delta;
2524
+ thoughtTokens += telemetry.estimateTokensFromText(event.delta);
2525
+ thoughtLastAt = Date.now();
2506
2526
  if (!cleanUi || thinkingExpanded)
2507
2527
  showThought();
2508
2528
  }
@@ -2832,7 +2852,7 @@ export async function main() {
2832
2852
  if (shouldNotify)
2833
2853
  notifyDevice(heading, preview || `Task completed in ${secs}s!`);
2834
2854
  }
2835
- else if (!terminalFocused) {
2855
+ else if (!isTerminalFocused()) {
2836
2856
  // The terminal is not in front: a notification with the start of the answer.
2837
2857
  if (sendDesktopNotification(heading, preview || `Task completed in ${secs}s`) === 'none' && shouldNotify)
2838
2858
  notifyDevice(heading, preview || `Task completed in ${secs}s!`);
@@ -2921,6 +2941,7 @@ export async function main() {
2921
2941
  const unsent = composer.stop();
2922
2942
  if (process.stdout.isTTY)
2923
2943
  process.stdout.write('\x1b[?1004l');
2944
+ setFocusTracking(false);
2924
2945
  releasePauseHook();
2925
2946
  process.stdout.removeListener('resize', onResize);
2926
2947
  if (!currentDraft && (unsent.draft || unsent.queued.length > 0)) {
@@ -75,6 +75,19 @@ export class LiveTelemetryTracker {
75
75
  return `${(n / 1_000).toFixed(1)}k`;
76
76
  return `${Math.round(n)}`;
77
77
  }
78
+ /**
79
+ * ` · 812 tok · 74 tok/s` for a finished thought: its own tokens and the speed it ran at, from the
80
+ * first to the last piece (not the whole wait). The speed is left out when the thought was so short
81
+ * that it would mean nothing (a few tokens in a fraction of a second).
82
+ */
83
+ static thoughtStats(tokens, spanMs) {
84
+ if (tokens <= 0)
85
+ return '';
86
+ const count = ` · ${LiveTelemetryTracker.formatTokenCount(tokens)} tok`;
87
+ if (tokens < 20 || spanMs < 1000)
88
+ return count;
89
+ return `${count} · ${Math.round(tokens / (spanMs / 1000))} tok/s`;
90
+ }
78
91
  formatLabel() {
79
92
  const secs = `${this.getElapsedSeconds()}s`;
80
93
  const tokens = LiveTelemetryTracker.formatTokenCount(this.totalTokens);
package/dist/cli/ui.js CHANGED
@@ -4,7 +4,7 @@ import path from 'path';
4
4
  import { getCurrentTheme } from './theme.js';
5
5
  import { t } from '../core/i18n.js';
6
6
  import { formatMoney } from '../core/cost.js';
7
- import { codePointWidth, displayWidth, padEndWidth, stripAnsi, truncateWidth, wrapText, wrapWidth, wrapWords, } from './width.js';
7
+ import { codePointWidth, displayWidth, padEndWidth, stripAnsi, truncateWidth, wrapKeepSpaces, wrapText, wrapWidth, wrapWords, } from './width.js';
8
8
  /**
9
9
  * devx visual language.
10
10
  *
@@ -519,7 +519,7 @@ function hintsRow(cols, mode) {
519
519
  export function renderUserEcho(text, cols) {
520
520
  const W = frameWidth(cols);
521
521
  const cw = W - 4;
522
- const rows = wrapWidth(text.trimEnd(), cw);
522
+ const rows = wrapKeepSpaces(text.trimEnd(), cw);
523
523
  const shaded = rows.map((line, i) => {
524
524
  const marker = i === 0 ? palette.accent('❯') : ' ';
525
525
  return palette.shade(' ' + marker + ' ' + padEndWidth(palette.text(line), cw) + ' ');
@@ -760,7 +760,7 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
760
760
  }
761
761
  if (expanded && t !== 'bash' && !(t === 'edit_file' || t === 'write_file')) {
762
762
  // For these the first line is part of the output, not a summary of it.
763
- const own = t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url' || t === 'background';
763
+ const own = t.startsWith('mcp__') || t === 'mcp_search' || t === 'search_chats' || t === 'web_search' || t === 'fetch_url' || t === 'background';
764
764
  const first = own ? [] : summarizeToolResult(name, result, cols, false);
765
765
  return [...first, ...fullRows(text, cols)];
766
766
  }
@@ -806,7 +806,7 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
806
806
  return rest > 0 ? [row, moreHint(rest)] : [row];
807
807
  }
808
808
  // Output of an MCP tool or a web fetch: the first line and how many more there are.
809
- if (t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url') {
809
+ if (t.startsWith('mcp__') || t === 'mcp_search' || t === 'search_chats' || t === 'web_search' || t === 'fetch_url') {
810
810
  const nonEmpty = outputRows(text).filter((l) => l.trim());
811
811
  if (nonEmpty.length === 0)
812
812
  return [renderResultLine('(no output)', 'muted', cols)];
@@ -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,7 +344,9 @@ 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;
@@ -359,6 +361,8 @@ export async function runStartupUpdateCheck(config, deps = {}) {
359
361
  }
360
362
  if (result.error)
361
363
  return; // offline at start-up is not worth a word
364
+ if (cached)
365
+ return; // up to date, as the last check said: nothing to show
362
366
  // Up to date: a short line that goes away by itself.
363
367
  out.write(` ${palette.ok('✓')} ${palette.muted(t('update.upToDate', { version: result.currentVersion }))}\n`);
364
368
  await sleep(deps.tickMs !== undefined ? 0 : 450);
@@ -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);
package/dist/cli/width.js CHANGED
@@ -131,16 +131,48 @@ export function wrapWidth(text, width) {
131
131
  }
132
132
  return lines;
133
133
  }
134
- /** Wraps text at word boundaries (a word longer than the line is cut). */
135
- export function wrapWords(text, width) {
134
+ /** Cuts a word into pieces of at most `width` cells, never splitting a character. */
135
+ export function splitWord(word, width) {
136
+ if (width <= 0)
137
+ return [word];
138
+ const pieces = [];
139
+ let current = '';
140
+ let cells = 0;
141
+ for (const ch of word) {
142
+ const w = codePointWidth(ch.codePointAt(0));
143
+ if (cells + w > width && current !== '') {
144
+ pieces.push(current);
145
+ current = '';
146
+ cells = 0;
147
+ }
148
+ current += ch;
149
+ cells += w;
150
+ }
151
+ if (current !== '')
152
+ pieces.push(current);
153
+ return pieces;
154
+ }
155
+ /**
156
+ * Words are put on lines one after another. A word longer than a whole line is dealt with by
157
+ * `cutLong`: false shortens it with an ellipsis, true cuts it into pieces (the text after it goes on
158
+ * right behind its last piece). A word that fits on a line is never cut.
159
+ */
160
+ function wrapWordList(text, width, cutLong) {
136
161
  const lines = [];
137
162
  let current = '';
138
163
  for (const word of text.split(/\s+/).filter(Boolean)) {
139
164
  if (displayWidth(word) > width) {
140
165
  if (current)
141
166
  lines.push(current);
142
- lines.push(truncateWidth(word, width));
143
- current = '';
167
+ if (cutLong) {
168
+ const pieces = splitWord(word, width);
169
+ lines.push(...pieces.slice(0, -1));
170
+ current = pieces[pieces.length - 1] ?? '';
171
+ }
172
+ else {
173
+ lines.push(truncateWidth(word, width));
174
+ current = '';
175
+ }
144
176
  }
145
177
  else if (current === '') {
146
178
  current = word;
@@ -157,6 +189,10 @@ export function wrapWords(text, width) {
157
189
  lines.push(current);
158
190
  return lines;
159
191
  }
192
+ /** Wraps text at word boundaries (a word longer than the line is cut). */
193
+ export function wrapWords(text, width) {
194
+ return wrapWordList(text, width, false);
195
+ }
160
196
  /** The last `maxCells` cells of a text (what a running line shows: the newest words). */
161
197
  export function tailWidth(text, maxCells) {
162
198
  const chars = Array.from(text);
@@ -171,10 +207,69 @@ export function tailWidth(text, maxCells) {
171
207
  }
172
208
  return chars.slice(start).join('');
173
209
  }
174
- /** Wraps at word boundaries; text without spaces (Chinese, Japanese) is wrapped anywhere. */
210
+ /**
211
+ * Wraps at word boundaries. Only a word that is longer than the line is cut (Chinese and Japanese
212
+ * have no spaces, so a whole sentence is such a word); the words around it stay whole.
213
+ */
175
214
  export function wrapText(text, width) {
176
- const unbreakable = text.split(/\s+/).some((word) => displayWidth(word) > width);
177
- return unbreakable ? wrapWidth(text, width) : wrapWords(text, width);
215
+ return wrapWordList(text, width, true);
216
+ }
217
+ /**
218
+ * Wraps text for showing it back (the message of the user): explicit newlines, blank lines, the
219
+ * indentation and the spaces inside a line stay as they were. A line is broken at a space, where the
220
+ * space is dropped; a word longer than a whole line goes on a row of its own and is cut by cells.
221
+ */
222
+ export function wrapKeepSpaces(text, width) {
223
+ const rows = [];
224
+ for (const raw of text.split(/\r\n|\r|\n/)) {
225
+ const line = raw.replace(/\s+$/, '');
226
+ if (line === '') {
227
+ rows.push('');
228
+ continue;
229
+ }
230
+ let current = '';
231
+ let cells = 0;
232
+ let gap = '';
233
+ for (const token of line.match(/\S+|\s+/g) ?? []) {
234
+ if (/^\s/.test(token)) {
235
+ // Indentation stays; the space between two words waits to see whether the next word fits.
236
+ if (current === '') {
237
+ current = token;
238
+ cells = displayWidth(token);
239
+ }
240
+ else
241
+ gap = token;
242
+ continue;
243
+ }
244
+ const w = displayWidth(token);
245
+ const gapCells = displayWidth(gap);
246
+ if (current.trim() !== '' && cells + gapCells + w <= width) {
247
+ current += gap + token;
248
+ cells += gapCells + w;
249
+ }
250
+ else if (current.trim() === '' && cells + w <= width) {
251
+ current += token;
252
+ cells += w;
253
+ }
254
+ else if (w <= width) {
255
+ if (current.trim() !== '')
256
+ rows.push(current);
257
+ current = token;
258
+ cells = w;
259
+ }
260
+ else {
261
+ if (current.trim() !== '')
262
+ rows.push(current);
263
+ const pieces = splitWord(token, width);
264
+ rows.push(...pieces.slice(0, -1));
265
+ current = pieces[pieces.length - 1];
266
+ cells = displayWidth(current);
267
+ }
268
+ gap = '';
269
+ }
270
+ rows.push(current);
271
+ }
272
+ return rows;
178
273
  }
179
274
  /**
180
275
  * How many rows up the top of a block is from the cursor, once the terminal is `cols` wide.
@@ -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
+ }