@milpa/comal 0.1.0 → 0.1.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 +30 -28
- package/backend/contracts/remote-commands-v1.json +125 -0
- package/bin/comal.mjs +16 -5
- package/docs/investigation.md +2 -2
- package/governance/chrome-task-v2.json +67 -0
- package/node_modules/@jitl/quickjs-ffi-types/LICENSE +21 -0
- package/node_modules/@jitl/quickjs-ffi-types/README.md +5 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.mts +549 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.ts +549 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-ffi-types/package.json +36 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/README.md +93 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.cjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.mts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.ts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/README.md +88 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.mjs +5430 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cjs +5485 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.cjs +5424 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.mjs +5541 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.mts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.ts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/README.md +89 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.mjs +26 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.cjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.mjs +30 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.mts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.ts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/README.md +82 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.mjs +22 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cjs +21 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.cjs +21 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.mjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.mts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.ts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/package.json +61 -0
- package/node_modules/quickjs-emscripten/LICENSE +21 -0
- package/node_modules/quickjs-emscripten/README.md +928 -0
- package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/dist/index.d.mts +57 -0
- package/node_modules/quickjs-emscripten/dist/index.d.ts +57 -0
- package/node_modules/quickjs-emscripten/dist/index.global.js +7309 -0
- package/node_modules/quickjs-emscripten/dist/index.js +2 -0
- package/node_modules/quickjs-emscripten/dist/index.js.map +1 -0
- package/node_modules/quickjs-emscripten/dist/index.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/index.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/dist/variants.d.mts +39 -0
- package/node_modules/quickjs-emscripten/dist/variants.d.ts +39 -0
- package/node_modules/quickjs-emscripten/dist/variants.js +2 -0
- package/node_modules/quickjs-emscripten/dist/variants.js.map +1 -0
- package/node_modules/quickjs-emscripten/dist/variants.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/variants.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/package.json +69 -0
- package/node_modules/quickjs-emscripten-core/LICENSE +21 -0
- package/node_modules/quickjs-emscripten-core/README.md +341 -0
- package/node_modules/quickjs-emscripten-core/README.template.md +70 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs +7 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/index.d.mts +2033 -0
- package/node_modules/quickjs-emscripten-core/dist/index.d.ts +2033 -0
- package/node_modules/quickjs-emscripten-core/dist/index.js +7 -0
- package/node_modules/quickjs-emscripten-core/dist/index.js.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/index.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/index.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/package.json +49 -0
- package/package.json +6 -2
- package/src/agent.mjs +17 -6
- package/src/artifact-tools.mjs +3 -2
- package/src/auto-guard.mjs +5 -1
- package/src/cli.mjs +267 -201
- package/src/cloud-client.mjs +58 -8
- package/src/code-run-worker.mjs +70 -0
- package/src/code-run.mjs +97 -0
- package/src/codex-provider.mjs +28 -28
- package/src/compaction.mjs +1 -1
- package/src/goals.mjs +2 -2
- package/src/house.mjs +24 -24
- package/src/i18n/cli-es.mjs +97 -0
- package/src/i18n/es.mjs +227 -0
- package/src/i18n/index.mjs +48 -0
- package/src/image-attachments.mjs +263 -0
- package/src/model-controls.mjs +4 -4
- package/src/onboarding.mjs +91 -0
- package/src/openai-provider.mjs +24 -24
- package/src/peer-tools.mjs +4 -3
- package/src/permissions.mjs +53 -17
- package/src/provider-auth.mjs +30 -30
- package/src/provider-profiles.mjs +27 -27
- package/src/provider.mjs +30 -30
- package/src/remote-commands.mjs +11 -0
- package/src/remote-control.mjs +180 -0
- package/src/sessions.mjs +3 -3
- package/src/subagents.mjs +7 -3
- package/src/tool-packs.mjs +5 -2
- package/src/tools.mjs +92 -34
- package/src/tui/brand.mjs +2 -1
- package/src/tui/clipboard.mjs +5 -4
- package/src/tui/composer.mjs +77 -11
- package/src/tui/controller.mjs +111 -29
- package/src/tui/discovery.mjs +105 -100
- package/src/tui/markdown.mjs +10 -9
- package/src/tui/message-queue.mjs +27 -9
- package/src/tui/screen.mjs +209 -118
- package/src/vision.mjs +31 -3
- package/src/work-assertions.mjs +7 -7
- package/src/work-plan.mjs +189 -21
- package/src/work-recovery-proof.mjs +13 -1
- package/src/workflows/chrome-task.mjs +24 -7
package/src/tui/composer.mjs
CHANGED
|
@@ -1,23 +1,88 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { t } from '../i18n/index.mjs';
|
|
2
|
+
import { clip, graphemes, safeText, width } from './text.mjs';
|
|
3
|
+
|
|
4
|
+
const normalize = text => safeText(String(text).replace(/\r\n?/g, '\n'), { preserveTabs: true });
|
|
5
|
+
const IMAGE_COUNT = 2;
|
|
6
|
+
const imageLabel = /^\[Image ([1-9]\d*)\]$/;
|
|
2
7
|
|
|
3
8
|
export class Composer {
|
|
4
|
-
constructor(text = '') { this.history = []; this.historyIndex = -1; this.scratch = ''; this.setText(text); }
|
|
9
|
+
constructor(text = '') { this.history = []; this.historyAttachments = []; this.historyIndex = -1; this.scratch = ''; this.nextImage = 1; this.setText(text); }
|
|
5
10
|
get text() { return this.buffer.join(''); }
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
// Positions are UTF-16 offsets in display text, not filesystem paths. Only
|
|
12
|
+
// explicit host metadata may restore an attachment; a typed label is text.
|
|
13
|
+
get attachments() {
|
|
14
|
+
const result = []; let start = 0;
|
|
15
|
+
for (const atom of this.buffer) {
|
|
16
|
+
const descriptor = this.attachmentMap.get(atom);
|
|
17
|
+
if (descriptor) result.push({ ...structuredClone(descriptor), start, end: start + atom.length });
|
|
18
|
+
start += atom.length;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
get attachmentDescriptors() { return this.attachments; }
|
|
23
|
+
snapshot() { return { text: this.text, cursor: this.cursor, attachments: this.attachments }; }
|
|
24
|
+
setText(text, cursor, attachments = []) {
|
|
25
|
+
const clean = normalize(text); this.attachmentMap = new Map();
|
|
26
|
+
const spans = [];
|
|
27
|
+
for (const candidate of attachments || []) {
|
|
28
|
+
if (!candidate || typeof candidate !== 'object' || !imageLabel.test(candidate.label || '') || spans.length >= IMAGE_COUNT || this.attachmentMap.has(candidate.label)) continue;
|
|
29
|
+
const start = Number.isInteger(candidate.start) ? candidate.start : clean.indexOf(candidate.label);
|
|
30
|
+
const end = start + candidate.label.length;
|
|
31
|
+
if (start < 0 || clean.slice(start, end) !== candidate.label || spans.some(span => start < span.end && end > span.start)) continue;
|
|
32
|
+
// Refuse offsets inside another grapheme, including joined emoji.
|
|
33
|
+
const boundaries = new Set([0]); let offset = 0;
|
|
34
|
+
for (const atom of graphemes(clean)) { offset += atom.length; boundaries.add(offset); }
|
|
35
|
+
if (!boundaries.has(start) || !boundaries.has(end)) continue;
|
|
36
|
+
const { start: _start, end: _end, ...descriptor } = structuredClone(candidate);
|
|
37
|
+
this.attachmentMap.set(candidate.label, descriptor); spans.push({ start, end, label: candidate.label });
|
|
38
|
+
this.nextImage = Math.max(this.nextImage, Number(imageLabel.exec(candidate.label)[1]) + 1);
|
|
39
|
+
}
|
|
40
|
+
spans.sort((a, b) => a.start - b.start); this.buffer = []; let offset = 0;
|
|
41
|
+
for (const span of spans) { this.buffer.push(...graphemes(clean.slice(offset, span.start)), span.label); offset = span.end; }
|
|
42
|
+
this.buffer.push(...graphemes(clean.slice(offset)));
|
|
8
43
|
this.cursor = Math.max(0, Math.min(cursor ?? this.buffer.length, this.buffer.length));
|
|
9
44
|
}
|
|
45
|
+
addAttachment(descriptor, range) {
|
|
46
|
+
if (!descriptor || typeof descriptor !== 'object') throw new Error(t("Invalid image attachment."));
|
|
47
|
+
if (this.attachments.length >= IMAGE_COUNT) throw new Error(t("Attach up to two images per message."));
|
|
48
|
+
const source = this.text;
|
|
49
|
+
const start = range?.start ?? this.buffer.slice(0, this.cursor).join('').length;
|
|
50
|
+
const end = range?.end ?? start;
|
|
51
|
+
let offset = 0; const boundaries = new Map([[0, 0]]);
|
|
52
|
+
for (let i = 0; i < this.buffer.length; i++) { offset += this.buffer[i].length; boundaries.set(offset, i + 1); }
|
|
53
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start > end || !boundaries.has(start) || !boundaries.has(end)) throw new Error(t("Invalid image attachment position."));
|
|
54
|
+
let label = descriptor.label;
|
|
55
|
+
if (!imageLabel.test(label || '') || source.includes(label)) {
|
|
56
|
+
do { label = `[Image ${this.nextImage++}]`; } while (source.includes(label));
|
|
57
|
+
} else this.nextImage = Math.max(this.nextImage, Number(imageLabel.exec(label)[1]) + 1);
|
|
58
|
+
const next = source.slice(0, start) + label + source.slice(end);
|
|
59
|
+
if (Buffer.byteLength(next) > 1024 * 1024) throw new Error(t("The composer supports up to 1 MiB."));
|
|
60
|
+
const first = boundaries.get(start), last = boundaries.get(end);
|
|
61
|
+
this.buffer.splice(first, last - first, label);
|
|
62
|
+
if (this.cursor >= last) this.cursor += 1 - (last - first);
|
|
63
|
+
else if (this.cursor > first) this.cursor = first + 1;
|
|
64
|
+
const { start: _start, end: _end, ...value } = structuredClone(descriptor);
|
|
65
|
+
this.attachmentMap.set(label, { ...value, label }); this.historyIndex = -1; this.pruneAttachments();
|
|
66
|
+
return this.attachments.find(attachment => attachment.label === label);
|
|
67
|
+
}
|
|
68
|
+
pruneAttachments() { for (const label of this.attachmentMap.keys()) if (!this.buffer.includes(label)) this.attachmentMap.delete(label); }
|
|
10
69
|
insert(text) {
|
|
11
70
|
const inserted = graphemes(safeText(String(text).replace(/\r\n?/g, '\n'), { preserveTabs: true }));
|
|
12
|
-
if (Buffer.byteLength(this.text + inserted.join('')) > 1024 * 1024) throw new Error(
|
|
71
|
+
if (Buffer.byteLength(this.text + inserted.join('')) > 1024 * 1024) throw new Error(t("The composer supports up to 1 MiB."));
|
|
13
72
|
this.buffer = this.buffer.slice(0, this.cursor).concat(inserted, this.buffer.slice(this.cursor)); this.cursor += inserted.length; this.historyIndex = -1;
|
|
14
73
|
}
|
|
15
74
|
clear() { this.setText(''); this.historyIndex = -1; }
|
|
16
|
-
remember(text
|
|
75
|
+
remember(text, attachments = text === this.text ? this.attachments : []) {
|
|
76
|
+
if (text.trim()) {
|
|
77
|
+
if (this.history.at(-1) !== text) { this.history.push(text); this.historyAttachments.push(structuredClone(attachments)); }
|
|
78
|
+
else this.historyAttachments[this.history.length - 1] = structuredClone(attachments);
|
|
79
|
+
}
|
|
80
|
+
this.history = this.history.slice(-100); this.historyAttachments = this.historyAttachments.slice(-100); this.historyIndex = -1;
|
|
81
|
+
}
|
|
17
82
|
boundary(direction) {
|
|
18
83
|
let cursor = this.cursor;
|
|
19
|
-
if (direction < 0) { while (cursor > 0 &&
|
|
20
|
-
else { while (cursor < this.buffer.length &&
|
|
84
|
+
if (direction < 0) { while (cursor > 0 && /^\s+$/.test(this.buffer[cursor - 1])) cursor--; while (cursor > 0 && !/^\s+$/.test(this.buffer[cursor - 1])) cursor--; }
|
|
85
|
+
else { while (cursor < this.buffer.length && !/^\s+$/.test(this.buffer[cursor])) cursor++; while (cursor < this.buffer.length && /^\s+$/.test(this.buffer[cursor])) cursor++; }
|
|
21
86
|
return cursor;
|
|
22
87
|
}
|
|
23
88
|
lineStart() { let c = this.cursor; while (c > 0 && this.buffer[c - 1] !== '\n') c--; return c; }
|
|
@@ -39,9 +104,9 @@ export class Composer {
|
|
|
39
104
|
}
|
|
40
105
|
recall(direction) {
|
|
41
106
|
if (!this.history.length) return;
|
|
42
|
-
if (this.historyIndex === -1) { if (direction > 0) return; this.scratch = this.text; this.historyIndex = this.history.length; }
|
|
107
|
+
if (this.historyIndex === -1) { if (direction > 0) return; this.scratch = this.text; this.scratchAttachments = this.attachments; this.historyIndex = this.history.length; }
|
|
43
108
|
this.historyIndex = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
|
|
44
|
-
this.setText(this.historyIndex === this.history.length ? this.scratch : this.history[this.historyIndex]);
|
|
109
|
+
this.setText(this.historyIndex === this.history.length ? this.scratch : this.history[this.historyIndex], undefined, this.historyIndex === this.history.length ? this.scratchAttachments : this.historyAttachments[this.historyIndex]);
|
|
45
110
|
}
|
|
46
111
|
key(text, key = {}, columns) {
|
|
47
112
|
const name = key.name;
|
|
@@ -58,11 +123,12 @@ export class Composer {
|
|
|
58
123
|
else if (name === 'down' || (key.ctrl && name === 'n')) { if (key.ctrl || !this.vertical(1, columns)) this.recall(1); }
|
|
59
124
|
else if ((name === 'return' && (key.meta || key.shift)) || name === 'linefeed' || (key.ctrl && name === 'j')) this.insert('\n');
|
|
60
125
|
else if (!key.ctrl && !key.meta && text && !text.startsWith('\x1b')) this.insert(text);
|
|
126
|
+
this.pruneAttachments();
|
|
61
127
|
}
|
|
62
128
|
layout(columns) {
|
|
63
129
|
columns = Math.max(2, columns); const lines = [''], positions = []; let row = 0, column = 0, cursorRow = 0, cursorColumn = 0;
|
|
64
130
|
for (let i = 0; i <= this.buffer.length; i++) {
|
|
65
|
-
const atom = this.buffer[i], visible = atom === '\t' ? ' ' : atom;
|
|
131
|
+
const atom = this.buffer[i], visible = this.attachmentMap.has(atom) ? clip(atom, columns, columns > 1 ? '…' : '') : atom === '\t' ? ' ' : atom;
|
|
66
132
|
const size = atom && atom !== '\n' ? width(visible) : 0;
|
|
67
133
|
if (atom && atom !== '\n' && column + size > columns) { lines.push(''); row++; column = 0; }
|
|
68
134
|
positions.push({ index: i, row, column });
|
package/src/tui/controller.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t } from '../i18n/index.mjs';
|
|
1
2
|
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -5,14 +6,18 @@ import { spawn } from 'node:child_process';
|
|
|
5
6
|
import { once } from 'node:events';
|
|
6
7
|
import { MessageQueue, QueueRunner } from './message-queue.mjs';
|
|
7
8
|
import { shellCommands } from '../auto-guard.mjs';
|
|
9
|
+
import { findImageCandidates } from '../image-attachments.mjs';
|
|
10
|
+
import { Composer } from './composer.mjs';
|
|
11
|
+
import { isRemoteCommand } from '../remote-commands.mjs';
|
|
8
12
|
|
|
9
|
-
const IMMEDIATE = new Set(['help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider', 'exit', 'quit']);
|
|
13
|
+
const IMMEDIATE = new Set(['cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider', 'exit', 'quit']);
|
|
10
14
|
|
|
11
15
|
export class InteractiveController {
|
|
12
16
|
constructor(ui, backend) { this.ui = ui; this.backend = backend; this.closed = false; this.switching = false; this.draftTimer = null; this.pendingDraft = null; }
|
|
13
17
|
async start(session, initialPrompt = '') {
|
|
14
18
|
this.attachSession(session);
|
|
15
|
-
this.ui.configure({ onSubmit: text => this.submit(text), onAction: (name, arg) => this.action(name, arg), onDraft: text => this.scheduleDraft(text), onDiscover: () => this.refreshDiscovery() });
|
|
19
|
+
this.ui.configure({ onSubmit: (text, attachments) => this.submit(text, attachments), onPrepareSubmit: snapshot => this.prepareSubmit(snapshot), onAction: (name, arg) => this.action(name, arg), onDraft: (text, attachments) => this.scheduleDraft(text, attachments), onDiscover: () => this.refreshDiscovery(), onCapabilitiesChanged: () => this.previewImages() });
|
|
20
|
+
this.previewImages();
|
|
16
21
|
if(this.backend.hasPeerMessages){this.peerTimer=setInterval(()=>this.kickPeers(),300);this.peerTimer.unref();}
|
|
17
22
|
if (initialPrompt.trim()) await this.submit(initialPrompt);
|
|
18
23
|
else if (session.goal?.status === 'active') await this.submit('/goal resume');
|
|
@@ -25,12 +30,13 @@ export class InteractiveController {
|
|
|
25
30
|
const id = session.id;
|
|
26
31
|
this.queue = new MessageQueue(session.queue, snapshot => this.backend.saveQueue(id, snapshot));
|
|
27
32
|
this.runner = new QueueRunner(this.queue, async item => {
|
|
33
|
+
if(item.remote){await this.backend.validateRemote(item);if(item.text.trimStart().startsWith('/')&&!isRemoteCommand(item.text))throw new Error('This command is only available in the local terminal.');}
|
|
28
34
|
this.ui.setBusy(true);
|
|
29
|
-
try { return await this.backend.execute(item.text, { userIntent: item.text, shouldYield: () => this.queue.items.length > 0 }); }
|
|
35
|
+
try { return await this.backend.execute(item.text, { userIntent: item.text, attachments: item.attachments || [], shouldYield: () => this.queue.items.length > 0 }); }
|
|
30
36
|
finally { this.ui.setBusy(false); }
|
|
31
37
|
}, { blocked: () => Boolean(this.peerProcessing || this.ui.selecting || this.modalOverlay() || this.ui.editingQueue || this.switching || this.closed), onChange: () => { this.syncQueue(); this.refreshDiscovery(); }, onError: error => this.ui.notice(error.message, true) });
|
|
32
38
|
this.syncQueue(); this.refreshDiscovery();
|
|
33
|
-
if (this.queue.items.length || this.queue.interrupted.length) this.ui.notice(
|
|
39
|
+
if (this.queue.items.length || this.queue.interrupted.length) this.ui.notice(t("The recovered queue is paused. Check F4 before resuming; interrupted messages were not resent."));
|
|
34
40
|
}
|
|
35
41
|
kickPeers(){
|
|
36
42
|
if(this.peerProcessing || this.ui.busy || this.runner.processing || this.queue.paused || this.queue.items.length || this.modalOverlay() || this.ui.selecting || this.ui.editingQueue || this.switching || this.closed)return;
|
|
@@ -42,16 +48,80 @@ export class InteractiveController {
|
|
|
42
48
|
})().catch(error=>this.ui.notice(error.message,true)).finally(()=>{this.peerProcessing=null;this.runner.kick();});
|
|
43
49
|
}
|
|
44
50
|
syncQueue() { this.ui.setQueue(this.queue.snapshot()); if (this.ui.overlay?.kind === 'queue') this.ui.overlay.index = Math.min(this.ui.overlay.index, Math.max(0, this.queue.items.length - 1)); }
|
|
45
|
-
scheduleDraft(text) {
|
|
46
|
-
clearTimeout(this.draftTimer); this.pendingDraft = { id: this.session.id, text };
|
|
51
|
+
scheduleDraft(text, attachments = this.ui.composer.attachments || []) {
|
|
52
|
+
clearTimeout(this.draftTimer); this.pendingDraft = { id: this.session.id, text, attachments };
|
|
47
53
|
this.draftTimer = setTimeout(() => { this.flushDraft().catch(e => this.ui.notice(e.message, true)); }, 500);
|
|
54
|
+
clearTimeout(this.imageTimer);
|
|
55
|
+
if (this.backend.stageAttachment) this.imageTimer = setTimeout(() => this.previewImages(), 80);
|
|
48
56
|
}
|
|
49
|
-
async flushDraft() { clearTimeout(this.draftTimer); const pending = this.pendingDraft; this.pendingDraft = null; if (pending) await this.backend.saveDraft(pending.id, pending.text); }
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
async flushDraft() { clearTimeout(this.draftTimer); const pending = this.pendingDraft; this.pendingDraft = null; if (pending) await this.backend.saveDraft(pending.id, pending.text, pending.attachments); }
|
|
58
|
+
imageStatus(status) { this.ui.setAttachmentStatus?.(status); }
|
|
59
|
+
async normalizeImages(snapshot) {
|
|
60
|
+
if (!this.backend.stageAttachment) return snapshot;
|
|
61
|
+
const id = this.session.id, signature = JSON.stringify([id, snapshot.text, snapshot.attachments]);
|
|
62
|
+
if (this.imagePending?.signature === signature) return this.imagePending.promise;
|
|
63
|
+
const promise = (async () => {
|
|
64
|
+
const draft = new Composer(); draft.setText(snapshot.text, snapshot.cursor, snapshot.attachments);
|
|
65
|
+
const candidates = /^\/[a-z][\w-]*(?:\s|$)/i.test(snapshot.text.trimStart()) ? [] : findImageCandidates(snapshot.text, { cwd: this.backend.metadata().cwd });
|
|
66
|
+
for (const candidate of candidates.reverse()) {
|
|
67
|
+
if (draft.attachments.length >= 2) throw new Error(t('Attach up to two images per message.'));
|
|
68
|
+
if (this.closed || this.session.id !== id) throw new Error(t('The session changed; the image was not attached.'));
|
|
69
|
+
const descriptor = await this.backend.stageAttachment(candidate, id);
|
|
70
|
+
draft.addAttachment(descriptor, candidate);
|
|
71
|
+
}
|
|
72
|
+
return draft.snapshot();
|
|
73
|
+
})();
|
|
74
|
+
const pending = { signature, promise }; this.imagePending = pending;
|
|
75
|
+
try { return await promise; } finally { if (this.imagePending === pending) this.imagePending = null; }
|
|
76
|
+
}
|
|
77
|
+
async visionStatus(snapshot) {
|
|
78
|
+
const generation = this.visionGeneration = (this.visionGeneration || 0) + 1;
|
|
79
|
+
if (!snapshot.attachments?.length) { this.imageStatus(null); return true; }
|
|
80
|
+
const id = this.session.id, model = this.backend.metadata().model;
|
|
81
|
+
this.imageStatus({ state: 'checking', message: t('Checking vision support…') });
|
|
82
|
+
let capability;
|
|
83
|
+
try { capability = await this.backend.attachmentCapability?.(); }
|
|
84
|
+
catch { capability = { supported: false, source: 'unknown' }; }
|
|
85
|
+
if (this.closed || this.session.id !== id || this.backend.metadata().model !== model || this.visionGeneration !== generation) return false;
|
|
86
|
+
const supported = capability?.supported === true;
|
|
87
|
+
const message = supported ? t('{count} image(s) attached · Backspace removes a label', { count: snapshot.attachments.length })
|
|
88
|
+
: t('This model cannot receive images or vision is unverified. Switch model or remove the image before sending.');
|
|
89
|
+
if (this.ui.composer.text === snapshot.text) this.imageStatus({ state: supported ? 'ready' : 'unsupported', message });
|
|
90
|
+
return supported;
|
|
91
|
+
}
|
|
92
|
+
async previewImages() {
|
|
93
|
+
if (this.closed || this.switching || !this.backend.stageAttachment) return;
|
|
94
|
+
const id = this.session.id, snapshot = this.ui.composer.snapshot(), stamp = JSON.stringify(snapshot);
|
|
95
|
+
try {
|
|
96
|
+
if (findImageCandidates(snapshot.text, { cwd: this.backend.metadata().cwd }).length) this.imageStatus({ state: 'checking', message: t('Attaching image…') });
|
|
97
|
+
const prepared = await this.normalizeImages(snapshot);
|
|
98
|
+
if (this.closed || this.session.id !== id || JSON.stringify(this.ui.composer.snapshot()) !== stamp) return;
|
|
99
|
+
if (prepared.text !== snapshot.text) {
|
|
100
|
+
this.ui.composer.setText(prepared.text, prepared.cursor, prepared.attachments);
|
|
101
|
+
this.scheduleDraft(prepared.text, prepared.attachments); this.ui.schedule?.();
|
|
102
|
+
}
|
|
103
|
+
await this.visionStatus(prepared);
|
|
104
|
+
} catch (error) { if (!this.closed && this.session.id === id && this.ui.composer.text === snapshot.text) this.imageStatus({ state: 'invalid', message: error.message }); }
|
|
105
|
+
}
|
|
106
|
+
async prepareSubmit(snapshot) {
|
|
107
|
+
const id = this.session.id, prepared = await this.normalizeImages(snapshot);
|
|
108
|
+
if (this.closed || this.switching || this.session.id !== id) throw new Error(t('The session changed; the message was not sent.'));
|
|
109
|
+
if (JSON.stringify(this.ui.composer.snapshot()) === JSON.stringify(snapshot) && prepared.text !== snapshot.text) {
|
|
110
|
+
this.ui.composer.setText(prepared.text, prepared.cursor, prepared.attachments); this.scheduleDraft(prepared.text, prepared.attachments); this.ui.schedule?.();
|
|
111
|
+
}
|
|
112
|
+
if (!await this.visionStatus(prepared)) throw new Error(t('Image not sent: the current model does not support verified vision.'));
|
|
113
|
+
if (prepared.attachments?.length && prepared.text.trimStart().startsWith('/')) throw new Error(t('Use a plain message with images; commands do not accept image attachments.'));
|
|
114
|
+
return prepared;
|
|
115
|
+
}
|
|
116
|
+
async submit(text, attachments) {
|
|
117
|
+
if (this.closed || this.switching) throw new Error(t("The session is changing; keep the message and resend it."));
|
|
118
|
+
if (attachments === undefined && this.backend.stageAttachment) {
|
|
119
|
+
const prepared = await this.prepareSubmit({ text, attachments: [], cursor: undefined }); text = prepared.text; attachments = prepared.attachments;
|
|
120
|
+
}
|
|
121
|
+
attachments ||= [];
|
|
52
122
|
if (this.ui.editingQueue) {
|
|
53
|
-
const editing = this.ui.editingQueue; await this.queue.update(editing.id, text); this.ui.editingQueue = null;
|
|
54
|
-
this.ui.setDraft(editing.draft); this.scheduleDraft(editing.draft); await this.queue.setPaused(editing.wasPaused); this.syncQueue(); this.runner.kick(); return;
|
|
123
|
+
const editing = this.ui.editingQueue; await this.queue.update(editing.id, text, attachments); this.ui.editingQueue = null;
|
|
124
|
+
this.ui.setDraft(editing.draft, editing.attachments); this.scheduleDraft(editing.draft, editing.attachments); await this.queue.setPaused(editing.wasPaused); this.syncQueue(); this.runner.kick(); return;
|
|
55
125
|
}
|
|
56
126
|
const trimmed = text.trim(), match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
|
|
57
127
|
const mcpMutation = match?.[1] === 'mcp' && match[2] && !/^(?:status|list|config)(?:\s|$)/.test(match[2]);
|
|
@@ -65,21 +135,30 @@ export class InteractiveController {
|
|
|
65
135
|
if (this.ui.busy && arg !== 'status') { await this.queue.setPaused(true); this.backend.interrupt(); await this.runner.processing; }
|
|
66
136
|
await this.backend.goalControl(arg); this.syncQueue(); return;
|
|
67
137
|
}
|
|
68
|
-
await this.queue.enqueue(text); this.syncQueue(); this.runner.kick();
|
|
138
|
+
await this.queue.enqueue(text, attachments); this.syncQueue(); this.runner.kick();
|
|
139
|
+
}
|
|
140
|
+
async requireIdle() { if (this.peerProcessing || this.ui.busy || this.queue.running || this.runner.processing) throw new Error(t("Cancel the turn with Esc or Ctrl+C before switching session or model.")); }
|
|
141
|
+
async admitRemote(input, remote) {
|
|
142
|
+
if(this.closed || this.switching)throw new Error('Local session unavailable.');
|
|
143
|
+
const accepted=await this.queue.enqueueRemote(input.text,remote);this.syncQueue();this.runner.kick();return accepted;
|
|
144
|
+
}
|
|
145
|
+
async stopRemote(turnId) {
|
|
146
|
+
if(this.closed || !this.backend.remoteTurnMatches(turnId))return false;
|
|
147
|
+
await this.queue.setPaused(true);if(!this.backend.remoteTurnMatches(turnId))return false;this.backend.interrupt();this.syncQueue();return true;
|
|
69
148
|
}
|
|
70
|
-
async requireIdle() { if (this.peerProcessing || this.ui.busy || this.queue.running || this.runner.processing) throw new Error('Cancela el turno con Esc o Ctrl+C antes de cambiar sesión o modelo.'); }
|
|
71
149
|
async command(command, arg) {
|
|
72
150
|
if (['exit', 'quit'].includes(command)) return this.exit();
|
|
73
151
|
if (command === 'copy' || command === 'select') {
|
|
74
|
-
if (arg.trim()) throw new Error(
|
|
152
|
+
if (arg.trim()) throw new Error(t("Use /{v0} without arguments.", {v0: command}));
|
|
75
153
|
if (command === 'copy') await this.ui.copyResponse(); else this.ui.setSelection();
|
|
76
154
|
return;
|
|
77
155
|
}
|
|
156
|
+
if (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()))await this.requireIdle();
|
|
78
157
|
if (command === 'help') { this.ui.showHelp(); return; }
|
|
79
158
|
if (command === 'sessions' || (command === 'resume' && !arg)) { this.ui.showSessions(await this.backend.listSessions()); return; }
|
|
80
159
|
if (command === 'resume' || command === 'clear') {
|
|
81
160
|
await this.requireIdle(); this.switching = true;
|
|
82
|
-
try { await this.flushDraft(); const next = await this.backend.activate(command === 'clear' ? null : arg); this.ui.overlay = null; this.ui.editingQueue = null; this.attachSession(next); }
|
|
161
|
+
try { await this.backend.detachRemote?.(); await this.flushDraft(); const next = await this.backend.activate(command === 'clear' ? null : arg); this.ui.overlay = null; this.ui.editingQueue = null; this.attachSession(next); }
|
|
83
162
|
finally { this.switching = false; }
|
|
84
163
|
return;
|
|
85
164
|
}
|
|
@@ -88,14 +167,15 @@ export class InteractiveController {
|
|
|
88
167
|
else if (arg === 'pause') await this.queue.setPaused(true);
|
|
89
168
|
else if (arg === 'resume') { await this.queue.setPaused(false); this.ui.closeOverlay(); this.runner.kick(); }
|
|
90
169
|
else if (arg === 'clear') await this.queue.clear();
|
|
91
|
-
else if (/^edit \d+$/.test(arg)) { const item = this.queue.items[Number(arg.slice(5)) - 1]; if (!item) throw new Error(
|
|
92
|
-
else throw new Error(
|
|
170
|
+
else if (/^edit \d+$/.test(arg)) { const item = this.queue.items[Number(arg.slice(5)) - 1]; if (!item) throw new Error(t("Invalid message number.")); await this.action('queueEdit', item.id); }
|
|
171
|
+
else throw new Error(t("Use /queue, /queue pause|resume|clear or /queue edit N."));
|
|
93
172
|
this.syncQueue(); return;
|
|
94
173
|
}
|
|
174
|
+
if(command==='work'&&(arg.trim()==='recover'||arg.trim().startsWith('review '))){await this.requireIdle();const output=await this.backend.control(command,arg);if(output)this.ui.notice(output);return;}
|
|
95
175
|
if (['board', 'tasks', 'work'].includes(command)) { this.ui.showBoard(JSON.parse(await this.backend.control('board', ''))); return; }
|
|
96
|
-
if (['model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
|
|
97
|
-
if (command === 'table') { if (!arg) throw new Error(
|
|
98
|
-
const changing=['model','effort','provider','login','logout'].includes(command) && (arg.trim() || ['login','logout'].includes(command));
|
|
176
|
+
if (['cloud', 'model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
|
|
177
|
+
if (command === 'table') { if (!arg) throw new Error(t("Use /table file.csv|tsv|json|md.")); const table = await this.backend.readTable(arg); this.ui.addTable(table.text, table.format, arg); return; }
|
|
178
|
+
const changing=(['cloud','model','effort','provider','login','logout'].includes(command) && (arg.trim() || ['login','logout'].includes(command))) || (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()));
|
|
99
179
|
if(changing)this.switching=true;
|
|
100
180
|
try {
|
|
101
181
|
const output=await this.backend.control(command,arg);if(output)this.ui.notice(output);
|
|
@@ -112,14 +192,14 @@ export class InteractiveController {
|
|
|
112
192
|
if (name === 'resume') return this.command('resume', arg);
|
|
113
193
|
if (name === 'queueKick') return this.runner.kick();
|
|
114
194
|
if (name === 'queueEdit') {
|
|
115
|
-
const item = this.queue.items.find(item => item.id === arg); if (!item) throw new Error(
|
|
195
|
+
const item = this.queue.items.find(item => item.id === arg); if (!item) throw new Error(t("That message is already running or has left the queue."));
|
|
116
196
|
const wasPaused = this.queue.paused; await this.queue.setPaused(true);
|
|
117
|
-
this.ui.editingQueue = { id: arg, draft: this.ui.composer.text, wasPaused };
|
|
118
|
-
this.ui.overlay = null; this.ui.setDraft(item.text); this.scheduleDraft(item.text); this.syncQueue(); return;
|
|
197
|
+
this.ui.editingQueue = { id: arg, draft: this.ui.composer.text, attachments: this.ui.composer.attachments, wasPaused };
|
|
198
|
+
this.ui.overlay = null; this.ui.setDraft(item.text, item.attachments); this.scheduleDraft(item.text, item.attachments); this.syncQueue(); return;
|
|
119
199
|
}
|
|
120
200
|
if (name === 'queueEditCancel') {
|
|
121
201
|
const editing = this.ui.editingQueue; if (!editing) return;
|
|
122
|
-
this.ui.editingQueue = null; this.ui.setDraft(editing.draft); this.scheduleDraft(editing.draft); await this.queue.setPaused(editing.wasPaused); this.syncQueue(); this.runner.kick(); return;
|
|
202
|
+
this.ui.editingQueue = null; this.ui.setDraft(editing.draft, editing.attachments); this.scheduleDraft(editing.draft, editing.attachments); await this.queue.setPaused(editing.wasPaused); this.syncQueue(); this.runner.kick(); return;
|
|
123
203
|
}
|
|
124
204
|
if (name === 'queueRemove') await this.queue.remove(arg);
|
|
125
205
|
if (name === 'queueMove') { await this.queue.move(arg.id, arg.delta); if (this.ui.overlay?.kind === 'queue') this.ui.overlay.index = Math.max(0, this.queue.items.findIndex(item => item.id === arg.id)); }
|
|
@@ -129,21 +209,23 @@ export class InteractiveController {
|
|
|
129
209
|
this.syncQueue(); this.runner.kick();
|
|
130
210
|
}
|
|
131
211
|
async externalEditor() {
|
|
212
|
+
const attachments = this.ui.composer.attachments || [];
|
|
132
213
|
const commands = shellCommands(process.env.VISUAL || process.env.EDITOR || 'vi');
|
|
133
|
-
if (!commands || commands.length !== 1 || !commands[0].length) throw new Error(
|
|
214
|
+
if (!commands || commands.length !== 1 || !commands[0].length) throw new Error(t("$VISUAL/$EDITOR must be an executable with arguments, without shell expansions."));
|
|
134
215
|
const dir = await mkdtemp(path.join(os.tmpdir(), 'comal-composer-')), file = path.join(dir, 'message.md');
|
|
135
216
|
await writeFile(file, this.ui.composer.text, { mode: 0o600 });
|
|
136
217
|
const wasPaused = this.queue.paused; await this.queue.setPaused(true); this.syncQueue(); this.ui.suspend();
|
|
137
218
|
try {
|
|
138
219
|
const [command, ...args] = commands[0], child = spawn(command, [...args, file], { stdio: 'inherit' });
|
|
139
220
|
const [code] = await once(child, 'close');
|
|
140
|
-
if (code !== 0) throw new Error(
|
|
141
|
-
const text = await readFile(file, 'utf8'); if (Buffer.byteLength(text) > 1024 * 1024) throw new Error(
|
|
142
|
-
|
|
221
|
+
if (code !== 0) throw new Error(t("The editor exited with code {v0}; the previous draft is kept.", {v0: code}));
|
|
222
|
+
const text = await readFile(file, 'utf8'); if (Buffer.byteLength(text) > 1024 * 1024) throw new Error(t("The editor produced more than 1 MiB; the previous draft is kept."));
|
|
223
|
+
const restored = attachments.map(({start,end,...attachment}) => attachment);
|
|
224
|
+
this.ui.setDraft(text, restored); this.scheduleDraft(text, this.ui.composer.attachments);
|
|
143
225
|
} finally { await rm(dir, { recursive: true, force: true }); this.ui.resume(); await this.queue.setPaused(wasPaused); this.syncQueue(); this.runner.kick(); }
|
|
144
226
|
}
|
|
145
227
|
async exit() {
|
|
146
|
-
if (this.closed) return; this.closed = true; clearInterval(this.peerTimer);this.runner.stopped = true; this.backend.interrupt();
|
|
228
|
+
if (this.closed) return; this.closed = true; clearInterval(this.peerTimer);clearTimeout(this.imageTimer);this.runner.stopped = true; this.backend.interrupt();
|
|
147
229
|
this.ui.permission?.finish(false); await this.runner.processing;await this.peerProcessing; await this.flushDraft(); await this.queue.save(); this.ui.close();
|
|
148
230
|
}
|
|
149
231
|
}
|