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 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.1</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.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>
@@ -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.1</text>
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
+ }
@@ -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
- for await (const event of agent.run(abortController.signal)) {
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
- async function saveConfig(config) {
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 persisted = persistedConfig(config);
89
- // The file holds API keys: owner-only permissions (0600).
90
- await writeSecureFile(CONFIG_PATH, JSON.stringify(persisted, null, 2));
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
- currentDraft = '';
1015
- if (inputStr === '__CANCEL__') {
1016
- const confirmExit = await askConfirm({ title: t('confirm.exitTitle'), question: t('confirm.exit'), record: false });
1017
- if (confirmExit) {
1018
- resetTerminalTheme();
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
- const { text: cleanAnswer, attachments, images, warnings: attachWarnings } = await resolveAtMentions(answer);
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
- transcript.push({ kind: 'user', text: answer });
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');
@@ -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) {