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 +32 -0
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/headless.js +4 -4
- package/dist/cli/index.js +32 -11
- package/dist/cli/telemetry.js +13 -0
- package/dist/cli/ui.js +4 -4
- package/dist/cli/update-screen.js +6 -2
- package/dist/cli/updater.js +59 -5
- package/dist/cli/width.js +102 -7
- package/dist/core/attention.js +63 -0
- package/dist/core/chat-search.js +172 -0
- package/dist/core/loop.js +20 -3
- package/dist/core/session.js +10 -0
- package/dist/core/types.js +1 -1
- package/dist/mcp/manager.js +199 -5
- package/dist/permissions/guard.js +4 -0
- package/dist/prompts/builder.js +5 -1
- package/dist/tools/chats.js +119 -0
- package/dist/tools/index.js +13 -2
- package/dist/tools/mcp-search.js +104 -0
- package/dist/tools/mcp.js +2 -2
- package/dist/tools/questions.js +4 -0
- package/package.json +1 -1
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.
|
|
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>
|
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.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>
|
package/dist/cli/headless.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 (!
|
|
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)) {
|
package/dist/cli/telemetry.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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);
|
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);
|
package/dist/cli/width.js
CHANGED
|
@@ -131,16 +131,48 @@ export function wrapWidth(text, width) {
|
|
|
131
131
|
}
|
|
132
132
|
return lines;
|
|
133
133
|
}
|
|
134
|
-
/**
|
|
135
|
-
export function
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
177
|
-
|
|
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
|
+
}
|