termux-dev 1.8.1 → 1.8.2
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 +3 -0
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +116 -0
- package/dist/cli/headless.js +3 -1
- package/dist/cli/index.js +135 -19
- package/dist/cli/prompt.js +39 -1
- package/dist/cli/ui.js +162 -25
- package/dist/cli/updater.js +10 -8
- package/dist/core/background.js +376 -0
- package/dist/core/config-merge.js +67 -0
- package/dist/core/loop.js +18 -1
- package/dist/core/types.js +1 -1
- package/dist/permissions/guard.js +3 -1
- package/dist/prompts/builder.js +2 -1
- package/dist/tools/background.js +118 -0
- package/dist/tools/bash.js +23 -1
- package/dist/tools/index.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,6 +57,8 @@ The terminal interface is built around a framed prompt that carries the status i
|
|
|
57
57
|
Paste screenshots straight from the clipboard with `Ctrl+V` or `Alt+V` (also `/image`, or a pasted file path). They appear as badges like `[Image #1 203kb]` and are sent to the model as real images, unless the model is known to read text only. Needs `wl-clipboard` on Wayland, `xclip` on X11, `pngpaste` on macOS.
|
|
58
58
|
- **Built-in Live Web Server (`/serve`):**
|
|
59
59
|
Instantly preview web apps and HTML5 games on port 3000. Seamlessly opens on Android via `termux-open-url`.
|
|
60
|
+
- **Background tasks (`/tasks`):**
|
|
61
|
+
The agent can start a dev server, a watcher or a long test run in the background and keep working. It can wait for a line (`ready|error`), read the output later, and is told when a task ends or prints something it asked about; if you are at the prompt then, it picks the news up by itself. A `◈ 2 bg` badge in the prompt frame shows what runs; `/tasks` lists tasks, `/tasks <id>` shows output, `/tasks stop <id|all>` ends them. Starting a task asks for approval like `bash`; everything is stopped when devx exits. Set `"backgroundAutoWake": false` to stop the agent from reacting by itself.
|
|
60
62
|
- **Instant Snapshot Rollback (`/undo`):**
|
|
61
63
|
Every edit made with the file tools (`write_file`, `edit_file`) is recorded and can be rolled back with a single command. Changes made by shell commands (`bash`) are **not** tracked, and snapshots live in memory for the current session only.
|
|
62
64
|
- **Self-Healing Diagnostics & Auto-Installer:**
|
|
@@ -170,6 +172,7 @@ Type `/` in the prompt to open the autocomplete command palette:
|
|
|
170
172
|
| **`/rename`** | Rename the chat: `/rename <name>`, or without a name the AI names it again (`/title` is the same) |
|
|
171
173
|
| **`/permissions`** | What is allowed without asking (`Session` / `Always` on an approval card); `/permissions clear` resets it |
|
|
172
174
|
| **`/rewind`** | Go back to one of **your messages**: the chat after it is removed (the model forgets it too), the message returns to the input line to be edited; the files can go back as well (with git) |
|
|
175
|
+
| **`/tasks`** | Background tasks: list, `/tasks <id>` for output, `/tasks stop <id\|all>`, `/tasks clear` |
|
|
173
176
|
| **`/undo`** | Revert all file changes from the last AI turn |
|
|
174
177
|
| **`/diff`** | View colored git diff of modified project files |
|
|
175
178
|
| **`/commit`** | AI-generated semantic commit message & git commit |
|
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.8.
|
|
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.8.2</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.8.
|
|
35
|
+
<text x="112.0" y="62.0" class="t" fill="#5f6b7a">v1.8.2</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>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { formatDuration } from '../core/background.js';
|
|
2
|
+
import { palette } from './ui.js';
|
|
3
|
+
import { displayWidth, padEndWidth, truncateWidth } from './width.js';
|
|
4
|
+
/** What the prompt frame shows: how many tasks run, how many ended badly and were not looked at yet. */
|
|
5
|
+
export function backgroundSummary(manager, seenFailures) {
|
|
6
|
+
const all = manager.list();
|
|
7
|
+
return {
|
|
8
|
+
running: all.filter((t) => t.status === 'running').length,
|
|
9
|
+
failed: all.filter((t) => t.status === 'failed' && !seenFailures.has(t.id)).length,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const STATE_LABEL = {
|
|
13
|
+
running: 'running',
|
|
14
|
+
exited: 'done',
|
|
15
|
+
failed: 'failed',
|
|
16
|
+
stopped: 'stopped',
|
|
17
|
+
};
|
|
18
|
+
function stateCell(info) {
|
|
19
|
+
const label = info.status === 'failed' && info.code !== null ? `failed ${info.code}` : STATE_LABEL[info.status];
|
|
20
|
+
const padded = label.padEnd(9);
|
|
21
|
+
if (info.status === 'running')
|
|
22
|
+
return palette.ok('● ' + padded);
|
|
23
|
+
if (info.status === 'failed')
|
|
24
|
+
return palette.err('✗ ' + padded);
|
|
25
|
+
return palette.faint('○ ' + padded);
|
|
26
|
+
}
|
|
27
|
+
/** The list of tasks for `/tasks`: one row each, the newest output line under a running one. */
|
|
28
|
+
export function renderTaskList(manager, cols, now = Date.now()) {
|
|
29
|
+
const tasks = manager.list();
|
|
30
|
+
if (tasks.length === 0) {
|
|
31
|
+
return [` ${palette.faint('·')} ${palette.muted('No background tasks. The agent starts them for servers, watchers and long runs.')}`];
|
|
32
|
+
}
|
|
33
|
+
const rows = [];
|
|
34
|
+
const running = tasks.filter((t) => t.status === 'running').length;
|
|
35
|
+
rows.push(` ${palette.bold('Background tasks')} ${palette.faint(`· ${running} running, ${tasks.length - running} ended`)}`);
|
|
36
|
+
for (const t of tasks) {
|
|
37
|
+
const age = formatDuration((t.endedAt ?? now) - t.startedAt);
|
|
38
|
+
const head = ` ${palette.accent(padEndWidth(t.id, 4))} ${stateCell(t)} ${palette.faint(padEndWidth(age, 7))} `;
|
|
39
|
+
rows.push(head + palette.text(truncateWidth(t.label, Math.max(8, cols - displayWidth(head) - 1))));
|
|
40
|
+
const last = lastLine(manager, t.id);
|
|
41
|
+
if (last)
|
|
42
|
+
rows.push(` ${' '.repeat(4)} ${palette.faint('╰')} ${palette.muted(truncateWidth(last, Math.max(8, cols - 14)))}`);
|
|
43
|
+
}
|
|
44
|
+
rows.push('');
|
|
45
|
+
rows.push(` ${palette.faint(truncateWidth('/tasks <id> output · /tasks stop <id|all> · /tasks clear', Math.max(8, cols - 4)))}`);
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
48
|
+
function lastLine(manager, id) {
|
|
49
|
+
try {
|
|
50
|
+
const { text } = manager.read(id, { mode: 'tail', lines: 3 });
|
|
51
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
52
|
+
return lines[lines.length - 1] ?? '';
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** The output of one task for `/tasks <id>`. */
|
|
59
|
+
export function renderTaskOutput(manager, id, cols, lines = 40) {
|
|
60
|
+
const info = manager.get(id);
|
|
61
|
+
if (!info)
|
|
62
|
+
return [` ${palette.err('✗')} ${palette.err(`No task "${id}". Type /tasks to see them.`)}`];
|
|
63
|
+
const { text, omitted } = manager.read(id, { mode: 'tail', lines });
|
|
64
|
+
const rows = [` ${palette.accent(info.id)} ${stateCell(info)} ${palette.text(truncateWidth(info.label, Math.max(8, cols - 22)))}`];
|
|
65
|
+
rows.push(` ${palette.faint(truncateWidth(info.command, Math.max(8, cols - 4)))}`);
|
|
66
|
+
if (omitted > 0)
|
|
67
|
+
rows.push(` ${palette.faint(`… ${omitted} earlier line${omitted === 1 ? '' : 's'}`)}`);
|
|
68
|
+
if (!text)
|
|
69
|
+
rows.push(` ${palette.faint('(no output yet)')}`);
|
|
70
|
+
for (const line of text.split('\n'))
|
|
71
|
+
if (text)
|
|
72
|
+
rows.push(' ' + palette.muted(truncateWidth(line, Math.max(8, cols - 4))));
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
/** The notes the model received, short: one row per note (the first line of each). */
|
|
76
|
+
export function noteRows(content) {
|
|
77
|
+
const body = content.replace(/^\[devx · background tasks\]\n?/, '');
|
|
78
|
+
return body
|
|
79
|
+
.split(/\n\n(?=bg\d+ )/)
|
|
80
|
+
.map((note) => note.split('\n')[0].trim())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
/** `/tasks ...`: returns what to print. Stopping is asynchronous, so this is too. */
|
|
84
|
+
export async function runTasksCommand(args, manager, cols, markSeen) {
|
|
85
|
+
const [first, second] = args;
|
|
86
|
+
const word = (first ?? '').toLowerCase();
|
|
87
|
+
if (!first || word === 'list' || word === 'ls') {
|
|
88
|
+
markSeen(manager.list().map((t) => t.id));
|
|
89
|
+
return renderTaskList(manager, cols);
|
|
90
|
+
}
|
|
91
|
+
if (word === 'stop' || word === 'kill') {
|
|
92
|
+
if (!second)
|
|
93
|
+
return [` ${palette.warn('!')} ${palette.warn('Usage: /tasks stop <id|all>')}`];
|
|
94
|
+
if (second === 'all') {
|
|
95
|
+
const n = await manager.stopAll();
|
|
96
|
+
return [` ${palette.ok('✓')} ${palette.text(n === 0 ? 'Nothing was running' : `Stopped ${n} task${n === 1 ? '' : 's'}`)}`];
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const info = await manager.stop(second);
|
|
100
|
+
return [` ${palette.ok('✓')} ${palette.text(`${info.id} ${STATE_LABEL[info.status]}`)}`];
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return [` ${palette.err('✗')} ${palette.err(err.message)}`];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (word === 'clear') {
|
|
107
|
+
const n = manager.clearFinished();
|
|
108
|
+
return [` ${palette.ok('✓')} ${palette.text(n === 0 ? 'No ended tasks to remove' : `Removed ${n} ended task${n === 1 ? '' : 's'}`)}`];
|
|
109
|
+
}
|
|
110
|
+
// `/tasks bg1`, `/tasks output bg1`, `/tasks logs bg1`
|
|
111
|
+
const id = ['output', 'logs', 'log', 'show'].includes(word) ? second : first;
|
|
112
|
+
if (!id)
|
|
113
|
+
return [` ${palette.warn('!')} ${palette.warn('Usage: /tasks output <id>')}`];
|
|
114
|
+
markSeen([id]);
|
|
115
|
+
return renderTaskOutput(manager, id, cols);
|
|
116
|
+
}
|
package/dist/cli/headless.js
CHANGED
|
@@ -10,6 +10,7 @@ import { CLIConsoleGuard } from '../permissions/guard.js';
|
|
|
10
10
|
import { globalSnapshotManager } from '../core/snapshot.js';
|
|
11
11
|
import { UsageTracker } from '../core/usage.js';
|
|
12
12
|
import { MCPManager } from '../mcp/manager.js';
|
|
13
|
+
import { getBackgroundManager } from '../core/background.js';
|
|
13
14
|
export async function runHeadlessMode(userPrompt, config, options = {}) {
|
|
14
15
|
const planMode = !!options.planMode;
|
|
15
16
|
const isYolo = !!options.yolo || !!config.autoApprove;
|
|
@@ -48,7 +49,8 @@ export async function runHeadlessMode(userPrompt, config, options = {}) {
|
|
|
48
49
|
console.log(pc.bold(pc.cyan(`devx v${DEVX_VERSION} (headless) | ${planMode ? 'PLAN' : 'AGENT'} | ${config.model}`)));
|
|
49
50
|
console.log(pc.dim(`Task: ${userPrompt}\n`));
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
const bgManager = getBackgroundManager();
|
|
53
|
+
for await (const event of agent.run(abortController.signal, { takeNotes: () => bgManager.takeNotes(), hasNotes: () => bgManager.hasNotes() })) {
|
|
52
54
|
if (event.type === 'reasoning_delta') {
|
|
53
55
|
accumulatedReasoning += event.delta;
|
|
54
56
|
if (!isQuiet && !isJson) {
|
package/dist/cli/index.js
CHANGED
|
@@ -42,6 +42,9 @@ import { SessionExporter } from './export.js';
|
|
|
42
42
|
import { MCPManager, discoverProjectMcpServers, hashProjectMcpServers, readClaudeServers, removeUserServer, saveUserServers } from '../mcp/manager.js';
|
|
43
43
|
import { writeSecureFile } from '../core/secure-file.js';
|
|
44
44
|
import { applyLocalConfig, persistedConfig, withoutSessionOnlySettings } from '../core/local-config.js';
|
|
45
|
+
import { cloneConfig, mergeConfigChanges } from '../core/config-merge.js';
|
|
46
|
+
import { getBackgroundManager } from '../core/background.js';
|
|
47
|
+
import { backgroundSummary, noteRows, runTasksCommand } from './background-ui.js';
|
|
45
48
|
import { LiveTelemetryTracker } from './telemetry.js';
|
|
46
49
|
import { renderPowerlineStatus, GitStatusCache } from './statusline.js';
|
|
47
50
|
import { formatDiffBox } from './diff.js';
|
|
@@ -75,7 +78,18 @@ function maskApiKey(key) {
|
|
|
75
78
|
return '****';
|
|
76
79
|
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
77
80
|
}
|
|
78
|
-
|
|
81
|
+
/**
|
|
82
|
+
* What this process last knew of the config file (its own settings after the start-up overrides,
|
|
83
|
+
* or what it wrote last). A save writes only the difference to it, see core/config-merge.ts.
|
|
84
|
+
*/
|
|
85
|
+
let configBaseline = null;
|
|
86
|
+
let saveChain = Promise.resolve();
|
|
87
|
+
/** Call once the start-up overrides (-m, project config) are applied: those are not saved by accident. */
|
|
88
|
+
function markConfigBaseline(config) {
|
|
89
|
+
syncKeyMaps(config);
|
|
90
|
+
configBaseline = cloneConfig(persistedConfig(config));
|
|
91
|
+
}
|
|
92
|
+
function syncKeyMaps(config) {
|
|
79
93
|
config.apiKeys = config.apiKeys || {};
|
|
80
94
|
config.baseUrls = config.baseUrls || {};
|
|
81
95
|
if (config.provider && config.apiKey) {
|
|
@@ -84,10 +98,38 @@ async function saveConfig(config) {
|
|
|
84
98
|
if (config.provider && config.baseUrl) {
|
|
85
99
|
config.baseUrls[config.provider] = config.baseUrl;
|
|
86
100
|
}
|
|
101
|
+
}
|
|
102
|
+
async function writeConfigFile(config) {
|
|
103
|
+
syncKeyMaps(config);
|
|
87
104
|
// Runtime-only flags and YOLO (one run only) must not end up in the user's config file.
|
|
88
|
-
const
|
|
89
|
-
//
|
|
90
|
-
|
|
105
|
+
const mine = cloneConfig(persistedConfig(config));
|
|
106
|
+
// Another devx may have saved since we looked: write only our own changes on top of the file.
|
|
107
|
+
let disk = null;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(await fs.readFile(CONFIG_PATH, 'utf8'));
|
|
110
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
111
|
+
disk = withoutSessionOnlySettings(parsed);
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
const merged = disk ? mergeConfigChanges(configBaseline ?? {}, mine, disk) : mine;
|
|
115
|
+
// The file holds API keys: owner-only permissions (0600). Written aside and renamed, so a second
|
|
116
|
+
// process never reads half a file.
|
|
117
|
+
const tmp = `${CONFIG_PATH}.${process.pid}.tmp`;
|
|
118
|
+
try {
|
|
119
|
+
await writeSecureFile(tmp, JSON.stringify(merged, null, 2));
|
|
120
|
+
await fs.rename(tmp, CONFIG_PATH);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
await fs.rm(tmp, { force: true }).catch(() => null);
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
configBaseline = mine;
|
|
127
|
+
}
|
|
128
|
+
async function saveConfig(config) {
|
|
129
|
+
// One save at a time inside this process; the read-merge-write of the next one sees the previous.
|
|
130
|
+
const run = saveChain.then(() => writeConfigFile(config));
|
|
131
|
+
saveChain = run.catch(() => undefined);
|
|
132
|
+
return run;
|
|
91
133
|
}
|
|
92
134
|
async function fetchModels(baseUrl, apiKey) {
|
|
93
135
|
try {
|
|
@@ -768,6 +810,8 @@ export async function main() {
|
|
|
768
810
|
config.model = options.model;
|
|
769
811
|
config.maxContextTokens = resolveContextLimit({ ...config, maxContextTokens: 0 }).limit;
|
|
770
812
|
}
|
|
813
|
+
// -m and the project config are for this run: only what changes from here on is saved.
|
|
814
|
+
markConfigBaseline(config);
|
|
771
815
|
if (options.yolo) {
|
|
772
816
|
config.autoApprove = true;
|
|
773
817
|
}
|
|
@@ -857,6 +901,10 @@ export async function main() {
|
|
|
857
901
|
const checkpoints = [];
|
|
858
902
|
// What the user allowed on the approval card for this session ("Session"); "Always" goes to the config.
|
|
859
903
|
const permissionMemory = newPermissionMemory();
|
|
904
|
+
// Commands the agent keeps running in the background (servers, watchers): shown in the prompt frame, listed by /tasks.
|
|
905
|
+
const bg = getBackgroundManager();
|
|
906
|
+
const seenBgFailures = new Set();
|
|
907
|
+
const bgSummary = () => backgroundSummary(bg, seenBgFailures);
|
|
860
908
|
// The most a session may cost (USD, 0 = no limit); "continue" at the limit raises it for this session only.
|
|
861
909
|
let costLimitRaised = null;
|
|
862
910
|
let costWarned = false;
|
|
@@ -885,6 +933,11 @@ export async function main() {
|
|
|
885
933
|
// and start from a clean slate: history, cost, usage counters, undo stack, screen.
|
|
886
934
|
const startFreshSession = async () => {
|
|
887
935
|
sessionManager.startNewSession(config.model, planMode);
|
|
936
|
+
// The tasks belonged to the conversation that is being left.
|
|
937
|
+
await bg.stopAll().catch(() => 0);
|
|
938
|
+
bg.clearFinished();
|
|
939
|
+
bg.takeNotes();
|
|
940
|
+
seenBgFailures.clear();
|
|
888
941
|
transcript.length = 0;
|
|
889
942
|
checkpoints.length = 0;
|
|
890
943
|
windowTitle = '';
|
|
@@ -965,6 +1018,8 @@ export async function main() {
|
|
|
965
1018
|
}
|
|
966
1019
|
}
|
|
967
1020
|
let answer = '';
|
|
1021
|
+
// Set when this turn starts by itself, from the news of a background task (not from the user).
|
|
1022
|
+
let wakeNotes = '';
|
|
968
1023
|
if (autoTriggerPrompt) {
|
|
969
1024
|
answer = autoTriggerPrompt;
|
|
970
1025
|
autoTriggerPrompt = '';
|
|
@@ -996,7 +1051,19 @@ export async function main() {
|
|
|
996
1051
|
dirty: GitStatusCache.getStatus()?.isDirty,
|
|
997
1052
|
contextPercent: usagePercent,
|
|
998
1053
|
cost: sessionCost.total,
|
|
999
|
-
costState: sessionCost.state()
|
|
1054
|
+
costState: sessionCost.state(),
|
|
1055
|
+
background: bgSummary,
|
|
1056
|
+
subscribe: (signal) => {
|
|
1057
|
+
const onChange = () => signal('redraw');
|
|
1058
|
+
// A task ended or printed what the agent asked to hear about: the agent takes it up by itself.
|
|
1059
|
+
const onNote = () => signal(config.backgroundAutoWake === false ? 'redraw' : 'wake');
|
|
1060
|
+
bg.on('change', onChange);
|
|
1061
|
+
bg.on('note', onNote);
|
|
1062
|
+
return () => {
|
|
1063
|
+
bg.off('change', onChange);
|
|
1064
|
+
bg.off('note', onNote);
|
|
1065
|
+
};
|
|
1066
|
+
},
|
|
1000
1067
|
});
|
|
1001
1068
|
if (inputStr.startsWith('__TOGGLE_MODE__:')) {
|
|
1002
1069
|
currentDraft = inputStr.slice('__TOGGLE_MODE__:'.length);
|
|
@@ -1011,19 +1078,31 @@ export async function main() {
|
|
|
1011
1078
|
}
|
|
1012
1079
|
continue;
|
|
1013
1080
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
printFarewell(t('confirm.goodbye'));
|
|
1020
|
-
break;
|
|
1021
|
-
}
|
|
1022
|
-
else {
|
|
1081
|
+
if (inputStr.startsWith('__WAKE__:')) {
|
|
1082
|
+
// The prompt was closed by news from a background task: the agent answers it. What was typed stays.
|
|
1083
|
+
currentDraft = inputStr.slice('__WAKE__:'.length);
|
|
1084
|
+
wakeNotes = bg.takeNotes();
|
|
1085
|
+
if (!wakeNotes)
|
|
1023
1086
|
continue;
|
|
1087
|
+
console.log(noteRows(wakeNotes).map((r) => renderNotice('info', r)).join('\n'));
|
|
1088
|
+
console.log();
|
|
1089
|
+
answer = wakeNotes;
|
|
1090
|
+
}
|
|
1091
|
+
else {
|
|
1092
|
+
currentDraft = '';
|
|
1093
|
+
if (inputStr === '__CANCEL__') {
|
|
1094
|
+
const confirmExit = await askConfirm({ title: t('confirm.exitTitle'), question: t('confirm.exit'), record: false });
|
|
1095
|
+
if (confirmExit) {
|
|
1096
|
+
resetTerminalTheme();
|
|
1097
|
+
printFarewell(t('confirm.goodbye'));
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1024
1103
|
}
|
|
1104
|
+
answer = inputStr.trim();
|
|
1025
1105
|
}
|
|
1026
|
-
answer = inputStr.trim();
|
|
1027
1106
|
}
|
|
1028
1107
|
if (!answer)
|
|
1029
1108
|
continue;
|
|
@@ -1045,7 +1124,7 @@ export async function main() {
|
|
|
1045
1124
|
'/settings', '/update', '/model', '/provider', '/providers',
|
|
1046
1125
|
'/plan', '/agent', '/image', '/serve', '/memory', '/permissions', '/copy', '/rename', '/title', '/rewind', '/undo',
|
|
1047
1126
|
'/diff', '/commit', '/status', '/compact', '/init', '/doctor',
|
|
1048
|
-
'/config', '/clear', '/exit', '/quit', '/help', '/effort'
|
|
1127
|
+
'/config', '/clear', '/exit', '/quit', '/help', '/effort', '/tasks', '/bg', '/jobs'
|
|
1049
1128
|
];
|
|
1050
1129
|
if (!VALID_COMMANDS.includes(cmd)) {
|
|
1051
1130
|
const customList = await CustomCommandManager.listCommands();
|
|
@@ -1070,6 +1149,7 @@ export async function main() {
|
|
|
1070
1149
|
{ name: '/plan - Switch to PLAN mode (architect)', value: '/plan' },
|
|
1071
1150
|
{ name: '/agent - Switch to AGENT mode (coder)', value: '/agent' },
|
|
1072
1151
|
{ name: '/serve - Start local web server for web preview', value: '/serve' },
|
|
1152
|
+
{ name: '/tasks - Background tasks (list, output, stop)', value: '/tasks' },
|
|
1073
1153
|
{ name: '/memory - View or edit project memory bank', value: '/memory' },
|
|
1074
1154
|
{ name: '/permissions - What is allowed without asking', value: '/permissions' },
|
|
1075
1155
|
{ name: '/copy - Copy the last answer to the clipboard', value: '/copy' },
|
|
@@ -1471,6 +1551,12 @@ export async function main() {
|
|
|
1471
1551
|
await runDoctor(config);
|
|
1472
1552
|
continue;
|
|
1473
1553
|
}
|
|
1554
|
+
if (cmd === '/tasks' || cmd === '/bg' || cmd === '/jobs') {
|
|
1555
|
+
const rows = await runTasksCommand(parts.slice(1).filter(Boolean), bg, process.stdout.columns || 80, (ids) => ids.forEach((id) => seenBgFailures.add(id)));
|
|
1556
|
+
console.log(rows.join('\n'));
|
|
1557
|
+
console.log();
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1474
1560
|
if (cmd === '/serve') {
|
|
1475
1561
|
const sub = parts[1]?.toLowerCase();
|
|
1476
1562
|
if (sub === 'stop') {
|
|
@@ -1897,7 +1983,10 @@ export async function main() {
|
|
|
1897
1983
|
}
|
|
1898
1984
|
}
|
|
1899
1985
|
// Resolve @file and @image mentions
|
|
1900
|
-
|
|
1986
|
+
// News from a background task is output of a program, not a message of the user: no @file in it is read.
|
|
1987
|
+
const { text: cleanAnswer, attachments, images, warnings: attachWarnings } = wakeNotes
|
|
1988
|
+
? { text: answer, attachments: [], images: [], warnings: [] }
|
|
1989
|
+
: await resolveAtMentions(answer);
|
|
1901
1990
|
for (const warning of attachWarnings)
|
|
1902
1991
|
log.warn(warning);
|
|
1903
1992
|
let finalContent = cleanAnswer;
|
|
@@ -1938,9 +2027,12 @@ export async function main() {
|
|
|
1938
2027
|
if (turnCheckpoint)
|
|
1939
2028
|
checkpoints.push(turnCheckpoint);
|
|
1940
2029
|
}
|
|
1941
|
-
|
|
2030
|
+
if (wakeNotes)
|
|
2031
|
+
transcript.push({ kind: 'raw', rows: noteRows(wakeNotes).map((r) => renderNotice('info', r)) });
|
|
2032
|
+
else
|
|
2033
|
+
transcript.push({ kind: 'user', text: answer });
|
|
1942
2034
|
// Until the model has named the chat, the start of the first message stands in for the name.
|
|
1943
|
-
if (!windowTitle && !sessionManager.hasGeneratedTitle)
|
|
2035
|
+
if (!wakeNotes && !windowTitle && !sessionManager.hasGeneratedTitle)
|
|
1944
2036
|
setWindowTitle(provisionalTitle(answer));
|
|
1945
2037
|
history.addMessage({
|
|
1946
2038
|
role: 'user',
|
|
@@ -2262,8 +2354,12 @@ export async function main() {
|
|
|
2262
2354
|
contextPercent: Math.min(100, Math.round((used / (config.maxContextTokens || getModelContextLimit(config.model))) * 100)),
|
|
2263
2355
|
cost: sessionCost.total,
|
|
2264
2356
|
costState: sessionCost.state(),
|
|
2357
|
+
background: bgSummary(),
|
|
2265
2358
|
};
|
|
2266
2359
|
}, undefined, statusLine, sendQueuedNow);
|
|
2360
|
+
// A task started or ended during the turn: the frame shows the new count.
|
|
2361
|
+
const onBgChange = () => composer.refresh();
|
|
2362
|
+
bg.on('change', onBgChange);
|
|
2267
2363
|
composer.live = liveThought;
|
|
2268
2364
|
composer.onToggle = toggleView;
|
|
2269
2365
|
const releasePauseHook = registerPauseHook(() => {
|
|
@@ -2320,6 +2416,8 @@ export async function main() {
|
|
|
2320
2416
|
for await (const event of agent.run(abortController.signal, {
|
|
2321
2417
|
takePending: async () => Promise.all(composer.takeQueued().map(queuedToMessage)),
|
|
2322
2418
|
hasPending: () => composer.hasQueued(),
|
|
2419
|
+
takeNotes: () => bg.takeNotes(),
|
|
2420
|
+
hasNotes: () => bg.hasNotes(),
|
|
2323
2421
|
})) {
|
|
2324
2422
|
if (aborted)
|
|
2325
2423
|
break;
|
|
@@ -2609,6 +2707,22 @@ export async function main() {
|
|
|
2609
2707
|
else
|
|
2610
2708
|
log.info(event.message);
|
|
2611
2709
|
}
|
|
2710
|
+
else if (event.type === 'background_note') {
|
|
2711
|
+
// A background task ended or printed what the agent asked to hear about.
|
|
2712
|
+
if (spinnerActive) {
|
|
2713
|
+
s.stop();
|
|
2714
|
+
spinnerActive = false;
|
|
2715
|
+
}
|
|
2716
|
+
finishThinking(true);
|
|
2717
|
+
streamer.finish();
|
|
2718
|
+
endText();
|
|
2719
|
+
const rows = noteRows(event.content);
|
|
2720
|
+
transcript.push({ kind: 'raw', rows: rows.map((r) => renderNotice('info', r)) });
|
|
2721
|
+
for (const row of rows)
|
|
2722
|
+
console.log(cleanUi ? renderNotice('info', row) : pc.dim(row));
|
|
2723
|
+
s.start(cleanUi ? t('status.waiting') : 'Connecting...');
|
|
2724
|
+
spinnerActive = true;
|
|
2725
|
+
}
|
|
2612
2726
|
}
|
|
2613
2727
|
finishThinking(true);
|
|
2614
2728
|
streamer.finish();
|
|
@@ -2619,6 +2733,7 @@ export async function main() {
|
|
|
2619
2733
|
endText();
|
|
2620
2734
|
// Hand the bottom of the screen back. Whatever was typed but not sent waits in the next prompt;
|
|
2621
2735
|
// a message that arrived too late for the agent starts the next turn by itself.
|
|
2736
|
+
bg.off('change', onBgChange);
|
|
2622
2737
|
const leftover = composer.stop();
|
|
2623
2738
|
if (interruptPrompt) {
|
|
2624
2739
|
autoTriggerPrompt = interruptPrompt;
|
|
@@ -2744,6 +2859,7 @@ export async function main() {
|
|
|
2744
2859
|
}
|
|
2745
2860
|
}
|
|
2746
2861
|
finally {
|
|
2862
|
+
bg.off('change', onBgChange);
|
|
2747
2863
|
const unsent = composer.stop();
|
|
2748
2864
|
if (process.stdout.isTTY)
|
|
2749
2865
|
process.stdout.write('\x1b[?1004l');
|
package/dist/cli/prompt.js
CHANGED
|
@@ -6,7 +6,7 @@ import { CustomCommandManager } from '../core/commands.js';
|
|
|
6
6
|
import { formatPromptHeader } from './modeborder.js';
|
|
7
7
|
import { filterCommandPalette, estimatePromptTokens, formatPromptTokenBadge } from './prompt_palette.js';
|
|
8
8
|
import { PromptHistoryNavigator } from './history_search.js';
|
|
9
|
-
import { buildPromptFrame, palette, renderUserEcho, resolveMode } from './ui.js';
|
|
9
|
+
import { buildPromptFrame, moveCursorRow, palette, renderUserEcho, resolveMode } from './ui.js';
|
|
10
10
|
import { padEndWidth, truncateWidth } from './width.js';
|
|
11
11
|
export const SLASH_COMMANDS = [
|
|
12
12
|
{ cmd: '/new', desc: 'Start a new clean chat session' },
|
|
@@ -27,6 +27,7 @@ export const SLASH_COMMANDS = [
|
|
|
27
27
|
{ cmd: '/agent', desc: 'Switch to AGENT mode (coder & executor)' },
|
|
28
28
|
{ cmd: '/image', desc: 'Paste the image from the clipboard (same as Ctrl+V or Alt+V)' },
|
|
29
29
|
{ cmd: '/serve', desc: 'Start local web server for web/game preview' },
|
|
30
|
+
{ cmd: '/tasks', desc: 'Background tasks: list, /tasks <id> output, /tasks stop <id|all>' },
|
|
30
31
|
{ cmd: '/copy', desc: 'Copy the last answer of the AI to the clipboard' },
|
|
31
32
|
{ cmd: '/rewind', desc: 'Go back to one of your messages: the chat after it is removed' },
|
|
32
33
|
{ cmd: '/rename', desc: 'Rename the chat (/rename <name>), or let the AI name it again' },
|
|
@@ -141,6 +142,7 @@ export function askPrompt(opts = {}) {
|
|
|
141
142
|
contextPercent: opts.contextPercent ?? 0,
|
|
142
143
|
cost: opts.cost ?? 0,
|
|
143
144
|
costState: opts.costState,
|
|
145
|
+
background: opts.background?.(),
|
|
144
146
|
input,
|
|
145
147
|
cursorPos,
|
|
146
148
|
placeholder,
|
|
@@ -180,6 +182,35 @@ export function askPrompt(opts = {}) {
|
|
|
180
182
|
render();
|
|
181
183
|
};
|
|
182
184
|
process.stdout.on('resize', onResize);
|
|
185
|
+
const unsubscribeOwner = opts.subscribe?.((kind) => {
|
|
186
|
+
if (disposed)
|
|
187
|
+
return;
|
|
188
|
+
if (kind === 'redraw') {
|
|
189
|
+
render();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// News for the model: close the prompt, the owner starts a turn. What was typed is kept.
|
|
193
|
+
if (cleanLayout)
|
|
194
|
+
eraseFrame();
|
|
195
|
+
else
|
|
196
|
+
process.stdout.write(`\x1b[0m\r\x1b[2K`);
|
|
197
|
+
cleanup();
|
|
198
|
+
resolve(`__WAKE__:${input}`);
|
|
199
|
+
});
|
|
200
|
+
/** Up / down inside a text that wraps onto several rows; false when there is no row to go to (history takes over). */
|
|
201
|
+
function moveInInput(dir) {
|
|
202
|
+
if (!cleanLayout)
|
|
203
|
+
return false;
|
|
204
|
+
let to = moveCursorRow(input, cursorPos, process.stdout.columns || 80, dir);
|
|
205
|
+
if (to === null)
|
|
206
|
+
return false;
|
|
207
|
+
// Never land inside an attachment tag: go to its start.
|
|
208
|
+
const inside = getTagSpans().find((sp) => to > sp.start && to < sp.end);
|
|
209
|
+
if (inside)
|
|
210
|
+
to = inside.start;
|
|
211
|
+
cursorPos = to;
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
183
214
|
function getTagSpans() {
|
|
184
215
|
const spans = [];
|
|
185
216
|
const regex = new RegExp(`\\[Pasted text #\\d+ \\+\\d+ lines\\]|${IMAGE_TAG_SOURCE}`, 'g');
|
|
@@ -505,6 +536,7 @@ export function askPrompt(opts = {}) {
|
|
|
505
536
|
render();
|
|
506
537
|
function cleanup() {
|
|
507
538
|
disposed = true;
|
|
539
|
+
unsubscribeOwner?.();
|
|
508
540
|
if (process.stdout.isTTY) {
|
|
509
541
|
process.stdout.write('\x1b[?2004l');
|
|
510
542
|
}
|
|
@@ -696,6 +728,9 @@ export function askPrompt(opts = {}) {
|
|
|
696
728
|
selectedIndex = Math.max(0, selectedIndex - 1); // stops at the first item
|
|
697
729
|
render();
|
|
698
730
|
}
|
|
731
|
+
else if (moveInInput(-1)) {
|
|
732
|
+
render();
|
|
733
|
+
}
|
|
699
734
|
else {
|
|
700
735
|
const prev = historyNav.navigateUp(input);
|
|
701
736
|
if (prev !== null) {
|
|
@@ -712,6 +747,9 @@ export function askPrompt(opts = {}) {
|
|
|
712
747
|
selectedIndex = Math.min(items.length - 1, selectedIndex + 1); // stops at the last item
|
|
713
748
|
render();
|
|
714
749
|
}
|
|
750
|
+
else if (moveInInput(1)) {
|
|
751
|
+
render();
|
|
752
|
+
}
|
|
715
753
|
else {
|
|
716
754
|
const next = historyNav.navigateDown();
|
|
717
755
|
if (next !== null) {
|