@xmoon76/dsh-pi-tui 0.3.1 → 0.3.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/dist/builtins.mjs +1 -1
- package/dist/extension/advanced.d.mts +1 -1
- package/dist/extension/unstable.d.mts +1 -1
- package/dist/extensions.d.mts +1 -1
- package/dist/extensions.mjs +1 -1
- package/dist/index.d.mts +114 -12
- package/dist/index.mjs +2 -2
- package/dist/{public-types-2omeXG8d.d.mts → public-types-CZz5_arj.d.mts} +3 -0
- package/dist/{src-ClAKiW0z.mjs → src-oZjot5jQ.mjs} +2925 -191
- package/package.json +1 -1
- package/scripts/bench.mts +6 -0
- package/scripts/preview-prompt.mts +62 -0
- package/scripts/preview-role-styles.mts +97 -0
- package/scripts/tarball-smoke.mjs +6 -2
|
@@ -2,13 +2,13 @@ import { TUI_STARTUP_SERVICE } from "./startup.mjs";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
|
-
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
5
|
+
import { execFile, execFileSync, execSync, spawn } from "node:child_process";
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import { accessSync, appendFileSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
|
-
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path";
|
|
12
12
|
import z from "@deepseek-ai/schemastery";
|
|
13
13
|
import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
14
14
|
import { ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
@@ -27,10 +27,11 @@ import { homedir as homedir$1 } from "os";
|
|
|
27
27
|
import { basename as basename$1, dirname as dirname$1, join as join$1 } from "path";
|
|
28
28
|
import { eastAsianWidth } from "get-east-asian-width";
|
|
29
29
|
import { performance } from "node:perf_hooks";
|
|
30
|
-
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
31
31
|
import { EventEmitter } from "events";
|
|
32
32
|
import { Service } from "@deepseek-ai/cordis";
|
|
33
33
|
import { isUserInvocable, renderSkillContent } from "@deepseek-ai/dsh-skill";
|
|
34
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
34
35
|
import { AuthorizationDeclinedError } from "@deepseek-ai/dsh-authorization";
|
|
35
36
|
import { credentialKeyId, credentialKeyScope } from "@deepseek-ai/dsh-credentials";
|
|
36
37
|
//#region src/present.ts
|
|
@@ -427,6 +428,177 @@ function parseReadEnvelopes(result) {
|
|
|
427
428
|
return out;
|
|
428
429
|
}
|
|
429
430
|
/**
|
|
431
|
+
* Parse the write tool's XML confirmation envelope (`<path>…</path> <type>
|
|
432
|
+
* …</type> <content>Created file</content>` — no file content is echoed).
|
|
433
|
+
* The verb + path are the only material the envelope carries. A malformed
|
|
434
|
+
* envelope — including a blank `<path>` — yields undefined, never a
|
|
435
|
+
* partial parse (a whitespace path would surface as an invalid card row).
|
|
436
|
+
* @param result - the tool result text.
|
|
437
|
+
* @returns the envelope's verb and path, or undefined when the result is
|
|
438
|
+
* not a well-formed write envelope.
|
|
439
|
+
*/
|
|
440
|
+
function parseWriteEnvelope(result) {
|
|
441
|
+
const match = /<path>([\s\S]*?)<\/path>\s*<type>[^<]*<\/type>\s*<content>([\s\S]*?)<\/content>/.exec(result);
|
|
442
|
+
if (match === null) return void 0;
|
|
443
|
+
const verb = /^\s*(Created|Updated)\s+file\s*$/.exec(match[2] ?? "")?.[1];
|
|
444
|
+
if (verb !== "Created" && verb !== "Updated") return void 0;
|
|
445
|
+
const path = (match[1] ?? "").trim();
|
|
446
|
+
if (path === "") return void 0;
|
|
447
|
+
return {
|
|
448
|
+
verb,
|
|
449
|
+
path
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The folded preview suffix for a write card: ` — Created` / ` — Updated`
|
|
454
|
+
* when the result is the tool's XML confirmation envelope, empty otherwise.
|
|
455
|
+
* The folded row must NEVER dump the raw envelope (the read-card no-XML
|
|
456
|
+
* rule; a malformed envelope yields no preview at all, not the raw text).
|
|
457
|
+
* @param result - the tool result text.
|
|
458
|
+
* @returns the verb suffix, or '' when the result is not a write envelope.
|
|
459
|
+
*/
|
|
460
|
+
function writeFoldedPreview(result) {
|
|
461
|
+
const envelope = parseWriteEnvelope(result);
|
|
462
|
+
return envelope === void 0 ? "" : ` — ${envelope.verb}`;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Parse the skill tool's XML instruction envelope (`<skill_content
|
|
466
|
+
* name="…"> <skill_resources>…</skill_resources> <skill_instructions>…
|
|
467
|
+
* </skill_instructions> </skill_content>`). The name attribute is XML-
|
|
468
|
+
* escaped by the producer (escapeAttr: `&`/`"`/`<`) and is
|
|
469
|
+
* decoded here; the instructions body is embedded VERBATIM by the producer
|
|
470
|
+
* (skills are trusted local content), so it is returned unmodified.
|
|
471
|
+
*
|
|
472
|
+
* The boundaries are OUTER-envelope-aware: the opener is the first
|
|
473
|
+
* `<skill_content name="…">`, the instructions opener the first
|
|
474
|
+
* `<skill_instructions>` after it, and the closers the last
|
|
475
|
+
* `</skill_instructions>` / `</skill_content>` — so a body that itself
|
|
476
|
+
* documents envelope-shaped XML (a complete nested block, or an unclosed
|
|
477
|
+
* tag) never truncates the outer body. A MALFORMED first envelope never
|
|
478
|
+
* borrows a later envelope's boundaries: a second `<skill_content name="…">`
|
|
479
|
+
* opener between the first opener and its instructions opener means the
|
|
480
|
+
* first envelope is incomplete, and the selected instructions body must
|
|
481
|
+
* close every `<skill_content name="…">` opener it contains (unmatched
|
|
482
|
+
* closing tags are tolerated) — without those guards, a first envelope
|
|
483
|
+
* whose own boundaries are unclosed would cross-pair with a later
|
|
484
|
+
* envelope's. An unclosed `<skill_content name="…">` example inside a body
|
|
485
|
+
* is indistinguishable from such a malformed concatenation and degrades to
|
|
486
|
+
* a header-only card, never raw tags.
|
|
487
|
+
* @param result - the tool result text.
|
|
488
|
+
* @returns the envelope's name and instruction body, or undefined when the
|
|
489
|
+
* result is not a well-formed skill envelope (a blank name is malformed).
|
|
490
|
+
*/
|
|
491
|
+
function parseSkillEnvelope(result) {
|
|
492
|
+
const open = result.indexOf("<skill_content name=\"");
|
|
493
|
+
if (open < 0) return void 0;
|
|
494
|
+
const nameStart = open + 21;
|
|
495
|
+
const nameEnd = result.indexOf("\"", nameStart);
|
|
496
|
+
if (nameEnd < 0) return void 0;
|
|
497
|
+
const instrOpen = result.indexOf("<skill_instructions>", nameEnd);
|
|
498
|
+
if (instrOpen < 0) return void 0;
|
|
499
|
+
const nestedOpen = result.indexOf("<skill_content name=\"", nameEnd);
|
|
500
|
+
if (nestedOpen >= 0 && nestedOpen < instrOpen) return void 0;
|
|
501
|
+
const outerClose = result.lastIndexOf("</skill_content>");
|
|
502
|
+
if (outerClose < instrOpen) return void 0;
|
|
503
|
+
const instrClose = result.lastIndexOf("</skill_instructions>", outerClose);
|
|
504
|
+
if (instrClose <= instrOpen) return void 0;
|
|
505
|
+
const bodyRegion = result.slice(instrOpen, instrClose);
|
|
506
|
+
let contentDepth = 0;
|
|
507
|
+
for (const token of bodyRegion.matchAll(/<skill_content name="|<\/skill_content>/g)) if (token[0] === "<skill_content name=\"") contentDepth += 1;
|
|
508
|
+
else contentDepth = Math.max(0, contentDepth - 1);
|
|
509
|
+
if (contentDepth > 0) return void 0;
|
|
510
|
+
const rawName = result.slice(nameStart, nameEnd).trim();
|
|
511
|
+
if (rawName === "") return void 0;
|
|
512
|
+
const name = rawName.replaceAll(""", "\"").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
513
|
+
let body = result.slice(instrOpen + 20, instrClose);
|
|
514
|
+
if (body.startsWith("\n")) body = body.slice(1);
|
|
515
|
+
if (body.endsWith("\n")) body = body.slice(0, -1);
|
|
516
|
+
return {
|
|
517
|
+
name,
|
|
518
|
+
instructions: body
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* The folded preview suffix for a skill card: ` — N lines` (the instruction
|
|
523
|
+
* body's line count — the header already carries `skill · <name>`), empty
|
|
524
|
+
* otherwise. The folded row never dumps the raw `<skill_content>` block.
|
|
525
|
+
* @param result - the tool result text.
|
|
526
|
+
* @returns the line-count suffix, or '' when the result is not a skill
|
|
527
|
+
* envelope.
|
|
528
|
+
*/
|
|
529
|
+
function skillFoldedPreview(result) {
|
|
530
|
+
const envelope = parseSkillEnvelope(result);
|
|
531
|
+
if (envelope === void 0) return "";
|
|
532
|
+
const lines = envelope.instructions.split("\n").filter((line) => line !== "").length;
|
|
533
|
+
return lines === 0 ? "" : ` — ${lines} lines of instructions`;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* The human-facing body lines of one injected context row, derived from the
|
|
537
|
+
* model-facing text so the expanded row never leaks the envelope's raw XML
|
|
538
|
+
* (the same no-XML rule as the read/write/skill tool cards):
|
|
539
|
+
*
|
|
540
|
+
* - a well-formed skill envelope (`<skill_content …>` — the skill loader
|
|
541
|
+
* tool result and the user-explicit invocation injection share this
|
|
542
|
+
* shape) renders its instructions body;
|
|
543
|
+
* - a `<system-reminder>`-wrapped producer (the skill catalog, workspace
|
|
544
|
+
* instructions — both bake their complete frame into the content,
|
|
545
|
+
* harness caller-owned framing) renders its content with the wrapper tag
|
|
546
|
+
* lines stripped; `<available_skills>` markers are stripped only when the
|
|
547
|
+
* pair is present, so a lone marker inside real content survives;
|
|
548
|
+
* - any OTHER text returns undefined and the caller keeps its raw-body
|
|
549
|
+
* behavior (plain context rows are unchanged).
|
|
550
|
+
*
|
|
551
|
+
* A malformed skill envelope yields an EMPTY body, never the raw tags: the
|
|
552
|
+
* header still names the producer, and the model-facing bytes are untouched
|
|
553
|
+
* (presentation only).
|
|
554
|
+
* @param text - the injected context message text, exactly as logged.
|
|
555
|
+
* @returns the body lines to render, or undefined when no envelope shape
|
|
556
|
+
* applies.
|
|
557
|
+
*/
|
|
558
|
+
function systemContextBody(text) {
|
|
559
|
+
const skill = parseSkillEnvelope(text);
|
|
560
|
+
if (skill !== void 0) return skill.instructions.split("\n");
|
|
561
|
+
if (text.trimStart().startsWith("<skill_content")) return [];
|
|
562
|
+
const lines = text.split("\n");
|
|
563
|
+
const first = lines[0]?.trim();
|
|
564
|
+
const last = lines[lines.length - 1]?.trim();
|
|
565
|
+
if (first !== "<system-reminder>" || last !== "</system-reminder>") return void 0;
|
|
566
|
+
const body = lines.slice(1, -1);
|
|
567
|
+
if (!(body.some((line) => line.trim() === "<available_skills>") && body.some((line) => line.trim() === "</available_skills>"))) return body;
|
|
568
|
+
return body.filter((line) => line.trim() !== "<available_skills>" && line.trim() !== "</available_skills>");
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Parse the read_image tool's XML confirmation envelope (`<path>…</path>
|
|
572
|
+
* <type>image</type> <content>PNG image, 800x600 px, … bytes</content>`).
|
|
573
|
+
* @param result - the tool result text.
|
|
574
|
+
* @returns the envelope's path and the content summary line, or undefined
|
|
575
|
+
* when the result is not a well-formed image envelope (blank path or blank
|
|
576
|
+
* summary are malformed).
|
|
577
|
+
*/
|
|
578
|
+
function parseImageEnvelope(result) {
|
|
579
|
+
const match = /<path>([\s\S]*?)<\/path>\s*<type>image<\/type>\s*<content>\s*\n?\s*([\s\S]*?)\s*\n?\s*<\/content>/.exec(result);
|
|
580
|
+
if (match === null) return void 0;
|
|
581
|
+
const path = (match[1] ?? "").trim();
|
|
582
|
+
const summary = (match[2] ?? "").trim();
|
|
583
|
+
if (path === "" || summary === "") return void 0;
|
|
584
|
+
return {
|
|
585
|
+
path,
|
|
586
|
+
summary
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* The folded preview suffix for a read_image card: ` — <summary>` (e.g.
|
|
591
|
+
* `PNG image · 800x600 px`), empty otherwise. The folded row never dumps
|
|
592
|
+
* the raw envelope (the read-card no-XML rule).
|
|
593
|
+
* @param result - the tool result text.
|
|
594
|
+
* @returns the summary suffix, or '' when the result is not an image
|
|
595
|
+
* envelope.
|
|
596
|
+
*/
|
|
597
|
+
function imageFoldedPreview(result) {
|
|
598
|
+
const envelope = parseImageEnvelope(result);
|
|
599
|
+
return envelope === void 0 ? "" : ` — ${firstLine(envelope.summary)}`;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
430
602
|
* The folded preview suffix for a read card: `— {N} lines` when the envelope
|
|
431
603
|
* reports a total (or a line count), empty otherwise. A merged group card
|
|
432
604
|
* (multiple envelopes) yields nothing — its head already carries "N files".
|
|
@@ -540,6 +712,7 @@ function genericRawInputLines(name, rawInput) {
|
|
|
540
712
|
function resultTextLines(blocks, error) {
|
|
541
713
|
const lines = [];
|
|
542
714
|
for (const block of blocks) if (block.type === "text") lines.push(...block.text.split("\n"));
|
|
715
|
+
else if (block.type === "image") lines.push("[image]");
|
|
543
716
|
else lines.push(JSON.stringify(block, null, 2));
|
|
544
717
|
if (lines.length === 0 && error !== void 0) lines.push(`${error.name}: ${error.code}`);
|
|
545
718
|
return lines;
|
|
@@ -910,6 +1083,32 @@ function contextSummary(source) {
|
|
|
910
1083
|
function textOf(blocks) {
|
|
911
1084
|
return blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
912
1085
|
}
|
|
1086
|
+
/** Flat text with image positions preserved: text blocks verbatim, image
|
|
1087
|
+
* blocks as an inline `🖼️ name` marker AT their position (the queue-preview
|
|
1088
|
+
* format; U+FE0F keeps the marker 2 cells wide in emoji fonts). A marker
|
|
1089
|
+
* boundary always carries a single separating space — the /image insertion
|
|
1090
|
+
* leaves NO space before the placeholder, so `这张图是啥[image…]` must not
|
|
1091
|
+
* read as `这张图是啥🖼️ shot.png` — while a space the user already typed is
|
|
1092
|
+
* never doubled. The structured `content` blocks stay the canonical form
|
|
1093
|
+
* for thumbnail rendering; this projection feeds the flat-text consumers
|
|
1094
|
+
* (transcript search, loader-less fallback rendering, the user bubble's
|
|
1095
|
+
* inline marker) so a mixed message never reads as if the image was not
|
|
1096
|
+
* there, and an image-only message is not empty. Identical to
|
|
1097
|
+
* {@link textOf} for text-only content. */
|
|
1098
|
+
function textWithImageMarkers(blocks) {
|
|
1099
|
+
let text = "";
|
|
1100
|
+
let boundary = false;
|
|
1101
|
+
for (const block of blocks) if (block.type === "text") {
|
|
1102
|
+
if (boundary && text !== "" && !/\s$/.test(text) && !/^\s/.test(block.text)) text += " ";
|
|
1103
|
+
boundary = false;
|
|
1104
|
+
text += block.text;
|
|
1105
|
+
} else if (block.type === "image") {
|
|
1106
|
+
if (text !== "" && !/\s$/.test(text)) text += " ";
|
|
1107
|
+
text += `🖼️ ${block.attachment.name ?? "image"}`;
|
|
1108
|
+
boundary = true;
|
|
1109
|
+
}
|
|
1110
|
+
return text;
|
|
1111
|
+
}
|
|
913
1112
|
/** Key identifying one step's model output (turn + step). */
|
|
914
1113
|
function stepKey$1(turn, step) {
|
|
915
1114
|
return `${turn}/${step}`;
|
|
@@ -1310,12 +1509,14 @@ var TranscriptFolder = class TranscriptFolder {
|
|
|
1310
1509
|
this.currentTurn = event.data.turn;
|
|
1311
1510
|
break;
|
|
1312
1511
|
case "user/message": {
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1512
|
+
const blocks = event.data.content;
|
|
1513
|
+
const text = textWithImageMarkers(blocks);
|
|
1514
|
+
if (text === "" && !blocks.some((block) => block.type === "image")) break;
|
|
1315
1515
|
if (event.data.source.kind === "user") this.appendItem({
|
|
1316
1516
|
kind: "user",
|
|
1317
1517
|
turn: this.currentTurn,
|
|
1318
|
-
text
|
|
1518
|
+
text,
|
|
1519
|
+
content: blocks
|
|
1319
1520
|
});
|
|
1320
1521
|
else {
|
|
1321
1522
|
const provenance = contextProvenance(event.data.source);
|
|
@@ -1341,14 +1542,18 @@ var TranscriptFolder = class TranscriptFolder {
|
|
|
1341
1542
|
}
|
|
1342
1543
|
case "assistant/message": {
|
|
1343
1544
|
const key = stepKey$1(event.data.turn, event.data.step);
|
|
1344
|
-
const
|
|
1545
|
+
const messageBlocks = event.data.message.content;
|
|
1546
|
+
const text = textOf(messageBlocks);
|
|
1345
1547
|
const entry = this.assistantEntries.get(key);
|
|
1346
|
-
if (entry !== void 0)
|
|
1347
|
-
|
|
1548
|
+
if (entry !== void 0) {
|
|
1549
|
+
entry.text = text;
|
|
1550
|
+
if (messageBlocks.some((block) => block.type !== "text")) entry.content = messageBlocks;
|
|
1551
|
+
} else if (text !== "" || messageBlocks.some((block) => block.type !== "text")) {
|
|
1348
1552
|
const created = {
|
|
1349
1553
|
kind: "assistant",
|
|
1350
1554
|
turn: event.data.turn,
|
|
1351
|
-
text
|
|
1555
|
+
text,
|
|
1556
|
+
...messageBlocks.some((block) => block.type !== "text") ? { content: messageBlocks } : {}
|
|
1352
1557
|
};
|
|
1353
1558
|
this.assistantEntries.set(key, created);
|
|
1354
1559
|
this.appendItem(created);
|
|
@@ -1563,6 +1768,29 @@ function childOwnEvents(events) {
|
|
|
1563
1768
|
return cut === 0 ? events : events.slice(cut);
|
|
1564
1769
|
}
|
|
1565
1770
|
/** Render one session's log as a readable markdown transcript for `/export md`. */
|
|
1771
|
+
/** The markdown projection of content blocks (review finding 4): text
|
|
1772
|
+
* blocks verbatim, image blocks as a compact `🖼️` line (U+FE0F marker,
|
|
1773
|
+
* same convention as the transcript and queue summaries) with the durable
|
|
1774
|
+
* attachment id — the binary is NEVER embedded, and an image-only message
|
|
1775
|
+
* still renders a User/Assistant section. */
|
|
1776
|
+
function markdownContent(blocks) {
|
|
1777
|
+
const parts = [];
|
|
1778
|
+
let buffer = "";
|
|
1779
|
+
const flush = () => {
|
|
1780
|
+
if (buffer !== "") {
|
|
1781
|
+
parts.push(buffer);
|
|
1782
|
+
buffer = "";
|
|
1783
|
+
}
|
|
1784
|
+
};
|
|
1785
|
+
for (const block of blocks) if (block.type === "text") buffer += block.text;
|
|
1786
|
+
else if (block.type === "image") {
|
|
1787
|
+
flush();
|
|
1788
|
+
const attachment = block.attachment;
|
|
1789
|
+
parts.push(`> 🖼️ ${attachment.name ?? "image"} · ${attachment.width}×${attachment.height} · attachment \`${attachment.attachmentId}\``);
|
|
1790
|
+
}
|
|
1791
|
+
flush();
|
|
1792
|
+
return parts.join("\n\n");
|
|
1793
|
+
}
|
|
1566
1794
|
function renderTranscriptMarkdown(session) {
|
|
1567
1795
|
const lines = [
|
|
1568
1796
|
`# Session ${session.header.id}`,
|
|
@@ -1572,12 +1800,12 @@ function renderTranscriptMarkdown(session) {
|
|
|
1572
1800
|
];
|
|
1573
1801
|
for (const event of session.events) switch (event.type) {
|
|
1574
1802
|
case "user/message": {
|
|
1575
|
-
const text =
|
|
1803
|
+
const text = markdownContent(event.data.content);
|
|
1576
1804
|
if (text !== "") lines.push(`## User\n\n${text}\n`);
|
|
1577
1805
|
break;
|
|
1578
1806
|
}
|
|
1579
1807
|
case "assistant/message": {
|
|
1580
|
-
const text =
|
|
1808
|
+
const text = markdownContent(event.data.message.content);
|
|
1581
1809
|
if (text !== "") lines.push(`## Assistant\n\n${text}\n`);
|
|
1582
1810
|
break;
|
|
1583
1811
|
}
|
|
@@ -1588,7 +1816,7 @@ function renderTranscriptMarkdown(session) {
|
|
|
1588
1816
|
}
|
|
1589
1817
|
case "tool/result": {
|
|
1590
1818
|
const block = event.data.message.content[0];
|
|
1591
|
-
const text =
|
|
1819
|
+
const text = markdownContent(block?.content ?? []);
|
|
1592
1820
|
if (text !== "") lines.push(`<details><summary>result</summary>\n\n${text}\n\n</details>\n`);
|
|
1593
1821
|
break;
|
|
1594
1822
|
}
|
|
@@ -1869,8 +2097,10 @@ const darkColors = {
|
|
|
1869
2097
|
diffRemovedStrong: "#F08585",
|
|
1870
2098
|
diffGutter: "#6B6B6B",
|
|
1871
2099
|
diffMeta: "#888888",
|
|
1872
|
-
roleUser: "#
|
|
1873
|
-
shellMode: "#BD93F9"
|
|
2100
|
+
roleUser: "#679EFE",
|
|
2101
|
+
shellMode: "#BD93F9",
|
|
2102
|
+
/** dsh-web `--dsw-specific-bubble` (dark): neutral bluish-850. */
|
|
2103
|
+
roleUserBg: "#2C2C2F"
|
|
1874
2104
|
};
|
|
1875
2105
|
/** Light palette, tuned for ≥ 4.5:1 contrast on white (pi's values). */
|
|
1876
2106
|
const lightColors = {
|
|
@@ -1891,8 +2121,10 @@ const lightColors = {
|
|
|
1891
2121
|
diffRemovedStrong: "#B91C1C",
|
|
1892
2122
|
diffGutter: "#737373",
|
|
1893
2123
|
diffMeta: "#5F5F5F",
|
|
1894
|
-
roleUser: "#
|
|
1895
|
-
shellMode: "#7C3AED"
|
|
2124
|
+
roleUser: "#4177E6",
|
|
2125
|
+
shellMode: "#7C3AED",
|
|
2126
|
+
/** dsh-web `--dsw-specific-bubble` (light): deepseek-100. */
|
|
2127
|
+
roleUserBg: "#E4EDFD"
|
|
1896
2128
|
};
|
|
1897
2129
|
/** The active palette; style helpers read it on every call, so swapping is live. */
|
|
1898
2130
|
let currentPalette = darkColors;
|
|
@@ -1947,6 +2179,7 @@ const PALETTE_KEYS = [
|
|
|
1947
2179
|
"diffGutter",
|
|
1948
2180
|
"diffMeta",
|
|
1949
2181
|
"roleUser",
|
|
2182
|
+
"roleUserBg",
|
|
1950
2183
|
"shellMode"
|
|
1951
2184
|
];
|
|
1952
2185
|
/**
|
|
@@ -2054,6 +2287,10 @@ const color = {
|
|
|
2054
2287
|
diffGutter: (text) => hex("diffGutter")(text),
|
|
2055
2288
|
diffMeta: (text) => hex("diffMeta")(text),
|
|
2056
2289
|
roleUser: (text) => hex("roleUser")(text),
|
|
2290
|
+
/** User-bubble background paint (dsh-web `--dsw-specific-bubble`
|
|
2291
|
+
* parity): the whole user row reads as a floating block. The token is
|
|
2292
|
+
* optional — an absent `roleUserBg` makes this an identity function. */
|
|
2293
|
+
roleUserBg: (text) => currentPalette.roleUserBg === void 0 ? text : chalk.bgHex(currentPalette.roleUserBg)(text),
|
|
2057
2294
|
shellMode: (text) => hex("shellMode")(text),
|
|
2058
2295
|
/** Plain italics (kimi thinking parity). */
|
|
2059
2296
|
italic: (text) => chalk.italic(text),
|
|
@@ -5031,6 +5268,16 @@ function parseTerminalColorSchemeReport(data) {
|
|
|
5031
5268
|
return match[1] === "2" ? "light" : "dark";
|
|
5032
5269
|
}
|
|
5033
5270
|
let cachedCapabilities = null;
|
|
5271
|
+
let cellDimensions = {
|
|
5272
|
+
widthPx: 9,
|
|
5273
|
+
heightPx: 18
|
|
5274
|
+
};
|
|
5275
|
+
function getCellDimensions() {
|
|
5276
|
+
return cellDimensions;
|
|
5277
|
+
}
|
|
5278
|
+
function setCellDimensions(dims) {
|
|
5279
|
+
cellDimensions = dims;
|
|
5280
|
+
}
|
|
5034
5281
|
/**
|
|
5035
5282
|
* Checks whether the attached tmux client forwards OSC 8 hyperlinks to the
|
|
5036
5283
|
* outer terminal. tmux only re-emits them when its `client_termfeatures` lists
|
|
@@ -5139,6 +5386,41 @@ function isImageLine(line) {
|
|
|
5139
5386
|
return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX);
|
|
5140
5387
|
}
|
|
5141
5388
|
/**
|
|
5389
|
+
* Generate a random image ID for Kitty graphics protocol.
|
|
5390
|
+
* Uses random IDs to avoid collisions between different module instances
|
|
5391
|
+
* (e.g., main app vs extensions).
|
|
5392
|
+
*/
|
|
5393
|
+
function allocateImageId() {
|
|
5394
|
+
return Math.floor(Math.random() * 4294967294) + 1;
|
|
5395
|
+
}
|
|
5396
|
+
function encodeKitty(base64Data, options = {}) {
|
|
5397
|
+
const CHUNK_SIZE = 4096;
|
|
5398
|
+
const params = [
|
|
5399
|
+
"a=T",
|
|
5400
|
+
"f=100",
|
|
5401
|
+
"q=2"
|
|
5402
|
+
];
|
|
5403
|
+
if (options.moveCursor === false) params.push("C=1");
|
|
5404
|
+
if (options.columns) params.push(`c=${options.columns}`);
|
|
5405
|
+
if (options.rows) params.push(`r=${options.rows}`);
|
|
5406
|
+
if (options.imageId) params.push(`i=${options.imageId}`);
|
|
5407
|
+
if (base64Data.length <= CHUNK_SIZE) return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
|
|
5408
|
+
const chunks = [];
|
|
5409
|
+
let offset = 0;
|
|
5410
|
+
let isFirst = true;
|
|
5411
|
+
while (offset < base64Data.length) {
|
|
5412
|
+
const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
|
|
5413
|
+
const isLast = offset + CHUNK_SIZE >= base64Data.length;
|
|
5414
|
+
if (isFirst) {
|
|
5415
|
+
chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
|
|
5416
|
+
isFirst = false;
|
|
5417
|
+
} else if (isLast) chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
|
|
5418
|
+
else chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
|
|
5419
|
+
offset += CHUNK_SIZE;
|
|
5420
|
+
}
|
|
5421
|
+
return chunks.join("");
|
|
5422
|
+
}
|
|
5423
|
+
/**
|
|
5142
5424
|
* Delete a Kitty graphics image by ID.
|
|
5143
5425
|
* Uses uppercase 'I' to also free the image data.
|
|
5144
5426
|
*/
|
|
@@ -5156,7 +5438,31 @@ function deleteAllKittyImages() {
|
|
|
5156
5438
|
function deleteAllKittyPlacements() {
|
|
5157
5439
|
return "\x1B_Ga=d,d=a,q=2\x1B\\";
|
|
5158
5440
|
}
|
|
5441
|
+
function encodeITerm2(base64Data, options = {}) {
|
|
5442
|
+
const params = [`inline=${options.inline !== false ? 1 : 0}`, `size=${Buffer.byteLength(base64Data, "base64")}`];
|
|
5443
|
+
if (options.width !== void 0) params.push(`width=${options.width}`);
|
|
5444
|
+
if (options.height !== void 0) params.push(`height=${options.height}`);
|
|
5445
|
+
if (options.name) {
|
|
5446
|
+
const nameBase64 = Buffer.from(options.name).toString("base64");
|
|
5447
|
+
params.push(`name=${nameBase64}`);
|
|
5448
|
+
}
|
|
5449
|
+
if (options.preserveAspectRatio === false) params.push("preserveAspectRatio=0");
|
|
5450
|
+
return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
|
|
5451
|
+
}
|
|
5159
5452
|
const kittyImageMetadata = /* @__PURE__ */ new Map();
|
|
5453
|
+
let kittyTransmissionGeneration = 0;
|
|
5454
|
+
function registerKittyImageMetadata(metadata) {
|
|
5455
|
+
kittyTransmissionGeneration += 1;
|
|
5456
|
+
kittyImageMetadata.delete(metadata.imageId);
|
|
5457
|
+
kittyImageMetadata.set(metadata.imageId, {
|
|
5458
|
+
...metadata,
|
|
5459
|
+
transmissionGeneration: kittyTransmissionGeneration
|
|
5460
|
+
});
|
|
5461
|
+
if (kittyImageMetadata.size > 1e3) {
|
|
5462
|
+
const oldestImageId = kittyImageMetadata.keys().next().value;
|
|
5463
|
+
if (oldestImageId !== void 0) kittyImageMetadata.delete(oldestImageId);
|
|
5464
|
+
}
|
|
5465
|
+
}
|
|
5160
5466
|
function getRegisteredKittyImageMetadata(line) {
|
|
5161
5467
|
const controls = /\x1b_G([^;]*);/.exec(line)?.[1];
|
|
5162
5468
|
if (!controls) return void 0;
|
|
@@ -5235,6 +5541,157 @@ function cropKittyImageLine(line, hiddenRows, visibleRows) {
|
|
|
5235
5541
|
controls.push(`y=${sourceY}`, `h=${sourceHeight}`, `r=${croppedRows}`);
|
|
5236
5542
|
return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`;
|
|
5237
5543
|
}
|
|
5544
|
+
function calculateImageCellSize(imageDimensions, maxWidthCells, maxHeightCells, cellDimensions = {
|
|
5545
|
+
widthPx: 9,
|
|
5546
|
+
heightPx: 18
|
|
5547
|
+
}) {
|
|
5548
|
+
const maxWidth = Math.max(1, Math.floor(maxWidthCells));
|
|
5549
|
+
const maxHeight = maxHeightCells === void 0 ? void 0 : Math.max(1, Math.floor(maxHeightCells));
|
|
5550
|
+
const imageWidth = Math.max(1, imageDimensions.widthPx);
|
|
5551
|
+
const imageHeight = Math.max(1, imageDimensions.heightPx);
|
|
5552
|
+
const widthScale = maxWidth * cellDimensions.widthPx / imageWidth;
|
|
5553
|
+
const heightScale = maxHeight === void 0 ? widthScale : maxHeight * cellDimensions.heightPx / imageHeight;
|
|
5554
|
+
const scale = Math.min(widthScale, heightScale);
|
|
5555
|
+
const scaledWidthPx = imageWidth * scale;
|
|
5556
|
+
const scaledHeightPx = imageHeight * scale;
|
|
5557
|
+
const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx);
|
|
5558
|
+
const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
|
|
5559
|
+
return {
|
|
5560
|
+
columns: Math.max(1, Math.min(maxWidth, columns)),
|
|
5561
|
+
rows: Math.max(1, maxHeight === void 0 ? rows : Math.min(maxHeight, rows))
|
|
5562
|
+
};
|
|
5563
|
+
}
|
|
5564
|
+
function getPngDimensions(base64Data) {
|
|
5565
|
+
try {
|
|
5566
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
5567
|
+
if (buffer.length < 24) return null;
|
|
5568
|
+
if (buffer[0] !== 137 || buffer[1] !== 80 || buffer[2] !== 78 || buffer[3] !== 71) return null;
|
|
5569
|
+
return {
|
|
5570
|
+
widthPx: buffer.readUInt32BE(16),
|
|
5571
|
+
heightPx: buffer.readUInt32BE(20)
|
|
5572
|
+
};
|
|
5573
|
+
} catch {
|
|
5574
|
+
return null;
|
|
5575
|
+
}
|
|
5576
|
+
}
|
|
5577
|
+
function getJpegDimensions(base64Data) {
|
|
5578
|
+
try {
|
|
5579
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
5580
|
+
if (buffer.length < 2) return null;
|
|
5581
|
+
if (buffer[0] !== 255 || buffer[1] !== 216) return null;
|
|
5582
|
+
let offset = 2;
|
|
5583
|
+
while (offset < buffer.length - 9) {
|
|
5584
|
+
if (buffer[offset] !== 255) {
|
|
5585
|
+
offset++;
|
|
5586
|
+
continue;
|
|
5587
|
+
}
|
|
5588
|
+
const marker = buffer[offset + 1];
|
|
5589
|
+
if (marker >= 192 && marker <= 194) {
|
|
5590
|
+
const height = buffer.readUInt16BE(offset + 5);
|
|
5591
|
+
return {
|
|
5592
|
+
widthPx: buffer.readUInt16BE(offset + 7),
|
|
5593
|
+
heightPx: height
|
|
5594
|
+
};
|
|
5595
|
+
}
|
|
5596
|
+
if (offset + 3 >= buffer.length) return null;
|
|
5597
|
+
const length = buffer.readUInt16BE(offset + 2);
|
|
5598
|
+
if (length < 2) return null;
|
|
5599
|
+
offset += 2 + length;
|
|
5600
|
+
}
|
|
5601
|
+
return null;
|
|
5602
|
+
} catch {
|
|
5603
|
+
return null;
|
|
5604
|
+
}
|
|
5605
|
+
}
|
|
5606
|
+
function getGifDimensions(base64Data) {
|
|
5607
|
+
try {
|
|
5608
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
5609
|
+
if (buffer.length < 10) return null;
|
|
5610
|
+
const sig = buffer.slice(0, 6).toString("ascii");
|
|
5611
|
+
if (sig !== "GIF87a" && sig !== "GIF89a") return null;
|
|
5612
|
+
return {
|
|
5613
|
+
widthPx: buffer.readUInt16LE(6),
|
|
5614
|
+
heightPx: buffer.readUInt16LE(8)
|
|
5615
|
+
};
|
|
5616
|
+
} catch {
|
|
5617
|
+
return null;
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
function getWebpDimensions(base64Data) {
|
|
5621
|
+
try {
|
|
5622
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
5623
|
+
if (buffer.length < 30) return null;
|
|
5624
|
+
const riff = buffer.slice(0, 4).toString("ascii");
|
|
5625
|
+
const webp = buffer.slice(8, 12).toString("ascii");
|
|
5626
|
+
if (riff !== "RIFF" || webp !== "WEBP") return null;
|
|
5627
|
+
const chunk = buffer.slice(12, 16).toString("ascii");
|
|
5628
|
+
if (chunk === "VP8 ") {
|
|
5629
|
+
if (buffer.length < 30) return null;
|
|
5630
|
+
return {
|
|
5631
|
+
widthPx: buffer.readUInt16LE(26) & 16383,
|
|
5632
|
+
heightPx: buffer.readUInt16LE(28) & 16383
|
|
5633
|
+
};
|
|
5634
|
+
} else if (chunk === "VP8L") {
|
|
5635
|
+
if (buffer.length < 25) return null;
|
|
5636
|
+
const bits = buffer.readUInt32LE(21);
|
|
5637
|
+
return {
|
|
5638
|
+
widthPx: (bits & 16383) + 1,
|
|
5639
|
+
heightPx: (bits >> 14 & 16383) + 1
|
|
5640
|
+
};
|
|
5641
|
+
} else if (chunk === "VP8X") {
|
|
5642
|
+
if (buffer.length < 30) return null;
|
|
5643
|
+
return {
|
|
5644
|
+
widthPx: (buffer[24] | buffer[25] << 8 | buffer[26] << 16) + 1,
|
|
5645
|
+
heightPx: (buffer[27] | buffer[28] << 8 | buffer[29] << 16) + 1
|
|
5646
|
+
};
|
|
5647
|
+
}
|
|
5648
|
+
return null;
|
|
5649
|
+
} catch {
|
|
5650
|
+
return null;
|
|
5651
|
+
}
|
|
5652
|
+
}
|
|
5653
|
+
function getImageDimensions(base64Data, mimeType) {
|
|
5654
|
+
if (mimeType === "image/png") return getPngDimensions(base64Data);
|
|
5655
|
+
if (mimeType === "image/jpeg") return getJpegDimensions(base64Data);
|
|
5656
|
+
if (mimeType === "image/gif") return getGifDimensions(base64Data);
|
|
5657
|
+
if (mimeType === "image/webp") return getWebpDimensions(base64Data);
|
|
5658
|
+
return null;
|
|
5659
|
+
}
|
|
5660
|
+
function renderImage(base64Data, imageDimensions, options = {}) {
|
|
5661
|
+
const caps = getCapabilities();
|
|
5662
|
+
if (!caps.images) return null;
|
|
5663
|
+
const size = calculateImageCellSize(imageDimensions, options.maxWidthCells ?? 80, options.maxHeightCells, getCellDimensions());
|
|
5664
|
+
if (caps.images === "kitty") {
|
|
5665
|
+
if (options.imageId !== void 0) registerKittyImageMetadata({
|
|
5666
|
+
imageId: options.imageId,
|
|
5667
|
+
columns: size.columns,
|
|
5668
|
+
rows: size.rows,
|
|
5669
|
+
widthPx: imageDimensions.widthPx,
|
|
5670
|
+
heightPx: imageDimensions.heightPx
|
|
5671
|
+
});
|
|
5672
|
+
return {
|
|
5673
|
+
sequence: encodeKitty(base64Data, {
|
|
5674
|
+
columns: size.columns,
|
|
5675
|
+
rows: size.rows,
|
|
5676
|
+
imageId: options.imageId,
|
|
5677
|
+
moveCursor: options.moveCursor
|
|
5678
|
+
}),
|
|
5679
|
+
columns: size.columns,
|
|
5680
|
+
rows: size.rows,
|
|
5681
|
+
imageId: options.imageId
|
|
5682
|
+
};
|
|
5683
|
+
}
|
|
5684
|
+
if (caps.images === "iterm2") return {
|
|
5685
|
+
sequence: encodeITerm2(base64Data, {
|
|
5686
|
+
width: size.columns,
|
|
5687
|
+
height: "auto",
|
|
5688
|
+
preserveAspectRatio: options.preserveAspectRatio ?? true
|
|
5689
|
+
}),
|
|
5690
|
+
columns: size.columns,
|
|
5691
|
+
rows: size.rows
|
|
5692
|
+
};
|
|
5693
|
+
return null;
|
|
5694
|
+
}
|
|
5238
5695
|
/**
|
|
5239
5696
|
* Wrap text in an OSC 8 hyperlink sequence.
|
|
5240
5697
|
* The text is rendered as a clickable hyperlink in terminals that support OSC 8
|
|
@@ -5248,6 +5705,28 @@ function cropKittyImageLine(line, hiddenRows, visibleRows) {
|
|
|
5248
5705
|
function hyperlink(text, url) {
|
|
5249
5706
|
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
5250
5707
|
}
|
|
5708
|
+
/** Shorten home-prefixed absolute paths to ~/... for compact display. */
|
|
5709
|
+
function shortenImagePath(filename) {
|
|
5710
|
+
const home = homedir();
|
|
5711
|
+
if (home && (filename === home || filename.startsWith(`${home}/`) || filename.startsWith(`${home}\\`))) return `~${filename.slice(home.length)}`;
|
|
5712
|
+
return filename;
|
|
5713
|
+
}
|
|
5714
|
+
/**
|
|
5715
|
+
* Text fallback when the terminal cannot render inline images.
|
|
5716
|
+
* Absolute paths are shown shortened (~/...) and, when OSC 8 hyperlinks are
|
|
5717
|
+
* available, linked to file:// so the full path remains openable.
|
|
5718
|
+
*/
|
|
5719
|
+
function imageFallback(mimeType, dimensions, filename) {
|
|
5720
|
+
const parts = [];
|
|
5721
|
+
if (filename) {
|
|
5722
|
+
const display = shortenImagePath(filename);
|
|
5723
|
+
if (getCapabilities().hyperlinks && isAbsolute(filename)) parts.push(hyperlink(display, pathToFileURL(filename).href));
|
|
5724
|
+
else parts.push(display);
|
|
5725
|
+
}
|
|
5726
|
+
parts.push(`[${mimeType}]`);
|
|
5727
|
+
if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
|
|
5728
|
+
return `[Image: ${parts.join(" ")}]`;
|
|
5729
|
+
}
|
|
5251
5730
|
/**
|
|
5252
5731
|
* Minimal TUI implementation with differential rendering
|
|
5253
5732
|
*/
|
|
@@ -5753,6 +6232,10 @@ var TuiBase = class TuiBase extends Container {
|
|
|
5753
6232
|
const heightPx = parseInt(match[1], 10);
|
|
5754
6233
|
const widthPx = parseInt(match[2], 10);
|
|
5755
6234
|
if (heightPx <= 0 || widthPx <= 0) return true;
|
|
6235
|
+
setCellDimensions({
|
|
6236
|
+
widthPx,
|
|
6237
|
+
heightPx
|
|
6238
|
+
});
|
|
5756
6239
|
this.invalidate();
|
|
5757
6240
|
this.requestRender();
|
|
5758
6241
|
return true;
|
|
@@ -8424,6 +8907,75 @@ var HStack = class extends Stack {
|
|
|
8424
8907
|
return result;
|
|
8425
8908
|
}
|
|
8426
8909
|
};
|
|
8910
|
+
var Image = class {
|
|
8911
|
+
base64Data;
|
|
8912
|
+
mimeType;
|
|
8913
|
+
dimensions;
|
|
8914
|
+
theme;
|
|
8915
|
+
options;
|
|
8916
|
+
imageId;
|
|
8917
|
+
cachedLines;
|
|
8918
|
+
cachedWidth;
|
|
8919
|
+
constructor(base64Data, mimeType, theme, options = {}, dimensions) {
|
|
8920
|
+
this.base64Data = base64Data;
|
|
8921
|
+
this.mimeType = mimeType;
|
|
8922
|
+
this.theme = theme;
|
|
8923
|
+
this.options = options;
|
|
8924
|
+
this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || {
|
|
8925
|
+
widthPx: 800,
|
|
8926
|
+
heightPx: 600
|
|
8927
|
+
};
|
|
8928
|
+
this.imageId = options.imageId;
|
|
8929
|
+
}
|
|
8930
|
+
/** Get the Kitty image ID used by this image (if any). */
|
|
8931
|
+
getImageId() {
|
|
8932
|
+
return this.imageId;
|
|
8933
|
+
}
|
|
8934
|
+
invalidate() {
|
|
8935
|
+
this.cachedLines = void 0;
|
|
8936
|
+
this.cachedWidth = void 0;
|
|
8937
|
+
}
|
|
8938
|
+
render(width) {
|
|
8939
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
8940
|
+
const maxWidth = Math.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60));
|
|
8941
|
+
const cellDimensions = getCellDimensions();
|
|
8942
|
+
const defaultMaxHeight = Math.max(1, Math.ceil(maxWidth * cellDimensions.widthPx / cellDimensions.heightPx));
|
|
8943
|
+
const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight;
|
|
8944
|
+
const caps = getCapabilities();
|
|
8945
|
+
let lines;
|
|
8946
|
+
if (caps.images) {
|
|
8947
|
+
if (caps.images === "kitty" && this.imageId === void 0) this.imageId = allocateImageId();
|
|
8948
|
+
const result = renderImage(this.base64Data, this.dimensions, {
|
|
8949
|
+
maxWidthCells: maxWidth,
|
|
8950
|
+
maxHeightCells: maxHeight,
|
|
8951
|
+
imageId: this.imageId,
|
|
8952
|
+
moveCursor: false
|
|
8953
|
+
});
|
|
8954
|
+
if (result) {
|
|
8955
|
+
if (result.imageId) this.imageId = result.imageId;
|
|
8956
|
+
if (caps.images === "kitty") {
|
|
8957
|
+
lines = [result.sequence];
|
|
8958
|
+
for (let i = 0; i < result.rows - 1; i++) lines.push("");
|
|
8959
|
+
} else {
|
|
8960
|
+
lines = [];
|
|
8961
|
+
for (let i = 0; i < result.rows - 1; i++) lines.push("");
|
|
8962
|
+
const rowOffset = result.rows - 1;
|
|
8963
|
+
const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : "";
|
|
8964
|
+
lines.push(moveUp + result.sequence);
|
|
8965
|
+
}
|
|
8966
|
+
} else {
|
|
8967
|
+
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
|
|
8968
|
+
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
|
|
8969
|
+
}
|
|
8970
|
+
} else {
|
|
8971
|
+
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
|
|
8972
|
+
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
|
|
8973
|
+
}
|
|
8974
|
+
this.cachedLines = lines;
|
|
8975
|
+
this.cachedWidth = width;
|
|
8976
|
+
return lines;
|
|
8977
|
+
}
|
|
8978
|
+
};
|
|
8427
8979
|
const SYMBOLS = {
|
|
8428
8980
|
alpha: "α",
|
|
8429
8981
|
beta: "β",
|
|
@@ -13159,6 +13711,164 @@ var TuiMainScreen = class extends TuiBase {
|
|
|
13159
13711
|
}
|
|
13160
13712
|
};
|
|
13161
13713
|
//#endregion
|
|
13714
|
+
//#region src/image/errors.ts
|
|
13715
|
+
/**
|
|
13716
|
+
* Image-pipeline error vocabulary (plan §20). Every error carries an
|
|
13717
|
+
* ACTIONABLE message; callers surface them to the user verbatim (or as
|
|
13718
|
+
* notices), never as bare stack traces.
|
|
13719
|
+
* @module @xmoon76/dsh-pi-tui/image/errors
|
|
13720
|
+
*/
|
|
13721
|
+
/** Base class for every TUI image-pipeline failure. */
|
|
13722
|
+
var ImageInputError = class extends Error {
|
|
13723
|
+
constructor(message, options) {
|
|
13724
|
+
super(message, options);
|
|
13725
|
+
this.name = "ImageInputError";
|
|
13726
|
+
}
|
|
13727
|
+
};
|
|
13728
|
+
/** The bytes do not decode to a supported raster format. */
|
|
13729
|
+
var UnsupportedImageTypeError = class extends ImageInputError {
|
|
13730
|
+
constructor(detail = "") {
|
|
13731
|
+
super(`Unsupported image format. Supported: PNG, JPEG, WebP, GIF.${detail === "" ? "" : ` ${detail}`}`);
|
|
13732
|
+
this.name = "UnsupportedImageTypeError";
|
|
13733
|
+
}
|
|
13734
|
+
};
|
|
13735
|
+
/** The source bytes exceed the deployment's per-image attachment limit. */
|
|
13736
|
+
var ImageTooLargeError = class extends ImageInputError {
|
|
13737
|
+
constructor(bytes, limit) {
|
|
13738
|
+
super(`Image is ${formatBytes$1(bytes)}; current attachment limit is ${formatBytes$1(limit)}.`);
|
|
13739
|
+
this.name = "ImageTooLargeError";
|
|
13740
|
+
}
|
|
13741
|
+
};
|
|
13742
|
+
/** The image violates the deployment's dimension/pixel limits. */
|
|
13743
|
+
var ImageDimensionError = class extends ImageInputError {
|
|
13744
|
+
constructor(message) {
|
|
13745
|
+
super(message);
|
|
13746
|
+
this.name = "ImageDimensionError";
|
|
13747
|
+
}
|
|
13748
|
+
};
|
|
13749
|
+
/** The clipboard lookup failed (distinct from "clipboard has no image"). */
|
|
13750
|
+
var ClipboardImageError = class extends ImageInputError {
|
|
13751
|
+
constructor(message = "Failed to read image from clipboard.") {
|
|
13752
|
+
super(message);
|
|
13753
|
+
this.name = "ClipboardImageError";
|
|
13754
|
+
}
|
|
13755
|
+
};
|
|
13756
|
+
/** The harness rejected the staged images at admission time. */
|
|
13757
|
+
var ImageAdmissionError = class extends ImageInputError {
|
|
13758
|
+
constructor(message) {
|
|
13759
|
+
super(message);
|
|
13760
|
+
this.name = "ImageAdmissionError";
|
|
13761
|
+
}
|
|
13762
|
+
};
|
|
13763
|
+
/** A durable attachment could not be restored from session storage. */
|
|
13764
|
+
var ImageLoadError = class extends ImageInputError {
|
|
13765
|
+
constructor(message = "Image attachment could not be restored from session storage.") {
|
|
13766
|
+
super(message);
|
|
13767
|
+
this.name = "ImageLoadError";
|
|
13768
|
+
}
|
|
13769
|
+
};
|
|
13770
|
+
/** The current provider/model cannot accept image input. */
|
|
13771
|
+
var ModelImageUnsupportedError = class extends ImageInputError {
|
|
13772
|
+
constructor(provider, model) {
|
|
13773
|
+
super(`Current model \`${provider}/${model}\` does not support image input.`);
|
|
13774
|
+
this.name = "ModelImageUnsupportedError";
|
|
13775
|
+
}
|
|
13776
|
+
};
|
|
13777
|
+
/** Human byte-size formatting for limit messages (1024-based). */
|
|
13778
|
+
function formatBytes$1(bytes) {
|
|
13779
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
13780
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
13781
|
+
return `${(bytes / 1048576).toFixed(1)} MiB`;
|
|
13782
|
+
}
|
|
13783
|
+
/** A component rendering one durable attachment ref as an inline image. */
|
|
13784
|
+
var ImageThumbnail = class {
|
|
13785
|
+
loader;
|
|
13786
|
+
theme;
|
|
13787
|
+
ref;
|
|
13788
|
+
collapsedRef;
|
|
13789
|
+
unsubscribe;
|
|
13790
|
+
instance;
|
|
13791
|
+
cachedLines;
|
|
13792
|
+
cachedKey;
|
|
13793
|
+
constructor(ref, loader, theme, collapsedRef) {
|
|
13794
|
+
this.ref = ref;
|
|
13795
|
+
this.loader = loader;
|
|
13796
|
+
this.theme = theme;
|
|
13797
|
+
this.collapsedRef = collapsedRef;
|
|
13798
|
+
this.unsubscribe = loader.subscribe(ref.attachmentId, () => this.invalidate());
|
|
13799
|
+
}
|
|
13800
|
+
/** Whether this thumbnail participates in the fullscreen collapse
|
|
13801
|
+
* toggle: only instances the host wired a `collapsedRef` for (user and
|
|
13802
|
+
* assistant message attachments). Tool-card images stay inside their
|
|
13803
|
+
* card's own click surface — their rows must never swallow a click that
|
|
13804
|
+
* would otherwise fold/unfold the card. */
|
|
13805
|
+
get collapsible() {
|
|
13806
|
+
return this.collapsedRef !== void 0;
|
|
13807
|
+
}
|
|
13808
|
+
/** Whether the image rows are collapsed (the info line stays constant). */
|
|
13809
|
+
collapsed() {
|
|
13810
|
+
return this.collapsedRef?.() === true;
|
|
13811
|
+
}
|
|
13812
|
+
/** Fallback display name (never a path). */
|
|
13813
|
+
label() {
|
|
13814
|
+
return this.ref.name ?? "image";
|
|
13815
|
+
}
|
|
13816
|
+
/** Release the loader subscription (transcript trim / cache eviction).
|
|
13817
|
+
* Idempotent; a disposed thumbnail stops repainting with loader settles
|
|
13818
|
+
* (round-1 finding 7). */
|
|
13819
|
+
dispose() {
|
|
13820
|
+
if (this.unsubscribe !== void 0) {
|
|
13821
|
+
this.unsubscribe();
|
|
13822
|
+
this.unsubscribe = void 0;
|
|
13823
|
+
}
|
|
13824
|
+
this.invalidate();
|
|
13825
|
+
}
|
|
13826
|
+
/** The constant info line (§17.1): the attachment's identity — name +
|
|
13827
|
+
* dimensions + size — visible in EVERY state. The marker is `🖼️` WITH
|
|
13828
|
+
* the U+FE0F variation selector: U+1F5BC alone has no default emoji
|
|
13829
|
+
* presentation, so the width math measures it as ONE cell while fonts
|
|
13830
|
+
* with an emoji face render it TWO cells wide — the glyph overhang then
|
|
13831
|
+
* eats the space and overlaps the name (font-dependent). VS16 forces the
|
|
13832
|
+
* wide 2-cell rendering the measurement expects. */
|
|
13833
|
+
infoLine() {
|
|
13834
|
+
return `🖼️ ${this.label()} · ${this.ref.width}×${this.ref.height} · ${formatBytes$1(this.ref.bytes)}`;
|
|
13835
|
+
}
|
|
13836
|
+
invalidate() {
|
|
13837
|
+
this.instance = void 0;
|
|
13838
|
+
this.cachedLines = void 0;
|
|
13839
|
+
this.cachedKey = void 0;
|
|
13840
|
+
}
|
|
13841
|
+
render(width) {
|
|
13842
|
+
const key = `${width}:${getCapabilities().images ?? "none"}:${this.collapsed() ? "collapsed" : "expanded"}`;
|
|
13843
|
+
if (this.cachedLines !== void 0 && this.cachedKey === key) return this.cachedLines;
|
|
13844
|
+
const lines = this.renderLines(width);
|
|
13845
|
+
this.cachedLines = lines;
|
|
13846
|
+
this.cachedKey = key;
|
|
13847
|
+
return lines;
|
|
13848
|
+
}
|
|
13849
|
+
renderLines(width) {
|
|
13850
|
+
if (width < 42) return [this.theme.fallbackColor(this.infoLine())];
|
|
13851
|
+
if (getCapabilities().images === null) return [this.theme.fallbackColor(this.infoLine())];
|
|
13852
|
+
const state = this.loader.get(this.ref);
|
|
13853
|
+
if (state.state === "idle") {
|
|
13854
|
+
this.loader.load(this.ref);
|
|
13855
|
+
return [this.theme.fallbackColor(this.infoLine())];
|
|
13856
|
+
}
|
|
13857
|
+
if (state.state === "loading") return [this.theme.fallbackColor(`${this.infoLine()} …`)];
|
|
13858
|
+
if (state.state === "error") return [this.theme.fallbackColor(`${this.infoLine()} — ${state.error.message}`)];
|
|
13859
|
+
if (this.collapsed()) return [this.theme.fallbackColor(this.infoLine())];
|
|
13860
|
+
if (this.instance === void 0) this.instance = new Image(state.base64, this.ref.mediaType, { fallbackColor: this.theme.fallbackColor }, {
|
|
13861
|
+
maxHeightCells: 12,
|
|
13862
|
+
maxWidthCells: Math.min(40, Math.max(1, width - 2)),
|
|
13863
|
+
filename: this.label()
|
|
13864
|
+
}, {
|
|
13865
|
+
widthPx: this.ref.width,
|
|
13866
|
+
heightPx: this.ref.height
|
|
13867
|
+
});
|
|
13868
|
+
return [this.theme.fallbackColor(this.infoLine()), ...this.instance.render(width)];
|
|
13869
|
+
}
|
|
13870
|
+
};
|
|
13871
|
+
//#endregion
|
|
13162
13872
|
//#region src/diff.ts
|
|
13163
13873
|
/**
|
|
13164
13874
|
* Whether a tool result should render as a diff: edit-class tools always,
|
|
@@ -14836,6 +15546,118 @@ function toMentionItem(candidate) {
|
|
|
14836
15546
|
description: candidate.absolutePath
|
|
14837
15547
|
};
|
|
14838
15548
|
}
|
|
15549
|
+
/** Expand a leading `~` in one argument token (other tokens unchanged). */
|
|
15550
|
+
function expandHomeToken(token) {
|
|
15551
|
+
if (token === "~") return homedir();
|
|
15552
|
+
if (token.startsWith("~/")) return join(homedir(), token.slice(2));
|
|
15553
|
+
return token;
|
|
15554
|
+
}
|
|
15555
|
+
/**
|
|
15556
|
+
* Pure token → search-dir/prefix resolution (no filesystem access): which
|
|
15557
|
+
* directory a completion token reads and what basename prefix it filters.
|
|
15558
|
+
* Exported so the POSIX and Windows ROOT semantics are pinned without
|
|
15559
|
+
* touching the fs — `dirname` leaves a trailing separator ONLY on roots,
|
|
15560
|
+
* and a root must stay a root (`/` stripped would read `''`; a drive root
|
|
15561
|
+
* keeps its trailing separator while the drive-relative form drops it; a
|
|
15562
|
+
* UNC share keeps its legal trailing form).
|
|
15563
|
+
* @param token - the parsed argument token (no leading separator
|
|
15564
|
+
* whitespace).
|
|
15565
|
+
* @param cwd - the session workspace (relative forms resolve against it).
|
|
15566
|
+
* @param expanded - `expandHomeToken(token)` (the caller already computed
|
|
15567
|
+
* it; kept as a param so the fs-free contract stays pure).
|
|
15568
|
+
* @returns the readdir target, the basename prefix, and whether the token
|
|
15569
|
+
* is genuinely Windows-dialect (the caller's display math needs it).
|
|
15570
|
+
*/
|
|
15571
|
+
function resolvePathSearch(token, cwd, expanded) {
|
|
15572
|
+
const posixAbsolute = isAbsolute(expanded);
|
|
15573
|
+
const winAbsolute = !posixAbsolute && win32.isAbsolute(expanded);
|
|
15574
|
+
const absolute = token.startsWith("~") || posixAbsolute || winAbsolute;
|
|
15575
|
+
const pathDirname = winAbsolute ? win32.dirname : dirname;
|
|
15576
|
+
const pathBasename = winAbsolute ? win32.basename : basename;
|
|
15577
|
+
if (token === "./" || token === "../" || token === "~" || token === "~/" || token === "/" || token === "") return {
|
|
15578
|
+
searchDir: absolute ? expanded : join(cwd, expanded),
|
|
15579
|
+
searchPrefix: "",
|
|
15580
|
+
winAbsolute
|
|
15581
|
+
};
|
|
15582
|
+
if (token.endsWith("/") || token.endsWith("\\")) return {
|
|
15583
|
+
searchDir: absolute ? expanded : join(cwd, expanded),
|
|
15584
|
+
searchPrefix: "",
|
|
15585
|
+
winAbsolute
|
|
15586
|
+
};
|
|
15587
|
+
return {
|
|
15588
|
+
searchDir: absolute ? pathDirname(expanded) : join(cwd, dirname(expanded)),
|
|
15589
|
+
searchPrefix: pathBasename(expanded),
|
|
15590
|
+
winAbsolute
|
|
15591
|
+
};
|
|
15592
|
+
}
|
|
15593
|
+
/**
|
|
15594
|
+
* Slash-command PATH-argument completion (`/image <path>`): complete the
|
|
15595
|
+
* single argument token against the session cwd — shell-style and
|
|
15596
|
+
* directory-local (the fd whole-tree fuzzy search stays `@`'s job). Handles
|
|
15597
|
+
* `~`, absolute and relative forms and mirrors the fork's getFileSuggestions
|
|
15598
|
+
* display rules so a completed value always reads like what the user typed
|
|
15599
|
+
* (`./`, `~/` and `dir/` forms preserved); directories keep their trailing
|
|
15600
|
+
* `/` so Tab-accepting one continues completion. Returns null when the
|
|
15601
|
+
* argument is not a single completable token (embedded spaces) or the
|
|
15602
|
+
* directory cannot be read — the editor then shows no suggestions.
|
|
15603
|
+
* @param argumentText - the text after the command name (the fork passes
|
|
15604
|
+
* everything up to the cursor; its argument apply replaces that whole
|
|
15605
|
+
* range, so only single-token arguments can complete).
|
|
15606
|
+
* @param cwd - the session workspace (resolve relative forms against it).
|
|
15607
|
+
*/
|
|
15608
|
+
function suggestPathArgument(argumentText, cwd) {
|
|
15609
|
+
const token = argumentText;
|
|
15610
|
+
const leading = token.match(/^[ \t]+/)?.[0] ?? "";
|
|
15611
|
+
const parsed = token.slice(leading.length);
|
|
15612
|
+
if (parsed === "" || parsed.includes(" ") || parsed.includes(" ")) return null;
|
|
15613
|
+
const { searchDir, searchPrefix, winAbsolute } = resolvePathSearch(parsed, cwd, expandHomeToken(parsed));
|
|
15614
|
+
let entries;
|
|
15615
|
+
try {
|
|
15616
|
+
entries = readdirSync(searchDir, { withFileTypes: true });
|
|
15617
|
+
} catch {
|
|
15618
|
+
return null;
|
|
15619
|
+
}
|
|
15620
|
+
const items = [];
|
|
15621
|
+
for (const entry of entries) {
|
|
15622
|
+
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue;
|
|
15623
|
+
let isDirectory = entry.isDirectory();
|
|
15624
|
+
if (!isDirectory && entry.isSymbolicLink()) try {
|
|
15625
|
+
isDirectory = statSync(winAbsolute ? win32.join(searchDir, entry.name) : join(searchDir, entry.name)).isDirectory();
|
|
15626
|
+
} catch {}
|
|
15627
|
+
const name = entry.name;
|
|
15628
|
+
let relativePath;
|
|
15629
|
+
if (parsed.endsWith("/") || parsed.endsWith("\\")) relativePath = parsed + name;
|
|
15630
|
+
else if (winAbsolute) relativePath = win32.join(win32.dirname(parsed), name);
|
|
15631
|
+
else if (parsed.includes("/") || parsed.includes("\\")) {
|
|
15632
|
+
if (parsed.startsWith("~/")) {
|
|
15633
|
+
const dir = dirname(parsed.slice(2));
|
|
15634
|
+
relativePath = `~/${dir === "." ? name : join(dir, name)}`;
|
|
15635
|
+
} else if (parsed.startsWith("/")) {
|
|
15636
|
+
const dir = dirname(parsed);
|
|
15637
|
+
relativePath = dir === "/" ? `/${name}` : `${dir}/${name}`;
|
|
15638
|
+
} else {
|
|
15639
|
+
relativePath = join(dirname(parsed), name);
|
|
15640
|
+
if (parsed.startsWith("./") && !relativePath.startsWith("./")) relativePath = `./${relativePath}`;
|
|
15641
|
+
}
|
|
15642
|
+
} else relativePath = parsed.startsWith("~") ? `~/${name}` : name;
|
|
15643
|
+
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
|
|
15644
|
+
const value = leading + (pathValue.includes(" ") ? `"${pathValue}"` : pathValue);
|
|
15645
|
+
items.push({
|
|
15646
|
+
value,
|
|
15647
|
+
label: `${name}${isDirectory ? "/" : ""}`,
|
|
15648
|
+
description: winAbsolute ? win32.join(searchDir, entry.name) : join(searchDir, entry.name)
|
|
15649
|
+
});
|
|
15650
|
+
}
|
|
15651
|
+
if (items.length === 0) return null;
|
|
15652
|
+
items.sort((left, right) => {
|
|
15653
|
+
const leftIsDir = left.label.endsWith("/");
|
|
15654
|
+
const rightIsDir = right.label.endsWith("/");
|
|
15655
|
+
if (leftIsDir && !rightIsDir) return -1;
|
|
15656
|
+
if (!leftIsDir && rightIsDir) return 1;
|
|
15657
|
+
return left.label.localeCompare(right.label);
|
|
15658
|
+
});
|
|
15659
|
+
return items;
|
|
15660
|
+
}
|
|
14839
15661
|
/** The recursive fallback suggestion set for one `@` prefix. */
|
|
14840
15662
|
function fsMentionSuggestions(workDir, atPrefix, signal) {
|
|
14841
15663
|
if (signal.aborted) return null;
|
|
@@ -14867,10 +15689,16 @@ var MentionProvider = class {
|
|
|
14867
15689
|
inner;
|
|
14868
15690
|
workDir;
|
|
14869
15691
|
fdPath;
|
|
15692
|
+
/** The slash commands whose argument completion is a PATH (the host
|
|
15693
|
+
* attaches `getArgumentCompletions`, e.g. `/image`): ONLY these tolerate
|
|
15694
|
+
* a trailing-space argument as a Tab file-completion site — every other
|
|
15695
|
+
* command keeps the fork's judgment. */
|
|
15696
|
+
pathArgumentCommands;
|
|
14870
15697
|
constructor(slashCommands, workDir, fdPath) {
|
|
14871
15698
|
this.workDir = workDir;
|
|
14872
15699
|
this.fdPath = fdPath;
|
|
14873
15700
|
this.inner = new CombinedAutocompleteProvider([...slashCommands], workDir, fdPath);
|
|
15701
|
+
this.pathArgumentCommands = new Set(slashCommands.filter((command) => command.getArgumentCompletions !== void 0).map((command) => command.name));
|
|
14874
15702
|
}
|
|
14875
15703
|
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
14876
15704
|
const currentLine = lines[cursorLine] ?? "";
|
|
@@ -14909,7 +15737,15 @@ var MentionProvider = class {
|
|
|
14909
15737
|
return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
14910
15738
|
}
|
|
14911
15739
|
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
14912
|
-
|
|
15740
|
+
const trimmedStart = (lines[cursorLine] ?? "").slice(0, cursorCol).trimStart();
|
|
15741
|
+
if (trimmedStart.startsWith("/")) {
|
|
15742
|
+
const separatorIndex = trimmedStart.search(/[ \t]/);
|
|
15743
|
+
if (separatorIndex > 0) {
|
|
15744
|
+
const commandName = trimmedStart.slice(1, separatorIndex);
|
|
15745
|
+
if (this.pathArgumentCommands.has(commandName)) return true;
|
|
15746
|
+
}
|
|
15747
|
+
}
|
|
15748
|
+
return this.inner.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
14913
15749
|
}
|
|
14914
15750
|
};
|
|
14915
15751
|
//#endregion
|
|
@@ -16723,6 +17559,7 @@ var EditorSeatHolder = class {
|
|
|
16723
17559
|
isShowingAutocomplete: () => editor.isShowingAutocomplete?.() ?? false,
|
|
16724
17560
|
getCursor: () => editor.getCursor?.() ?? 0,
|
|
16725
17561
|
setCursor: (offset) => editor.setCursor?.(offset),
|
|
17562
|
+
insertTextAtCursor: (text) => editor.insertTextAtCursor?.(text),
|
|
16726
17563
|
get focused() {
|
|
16727
17564
|
return editor.focused;
|
|
16728
17565
|
},
|
|
@@ -17078,9 +17915,64 @@ var EditorSeatHolder = class {
|
|
|
17078
17915
|
* Tab-accepting a directory immediately shows its children (kimi's
|
|
17079
17916
|
* reopenAutocompleteAfterInput). Esc while autocomplete is active closes
|
|
17080
17917
|
* it WITHOUT re-triggering (kimi parity).
|
|
17918
|
+
*
|
|
17919
|
+
* The editor also carries the terminal-prompt `❯ ` prefix: the fork's
|
|
17920
|
+
* Editor is constructed with `paddingX: 2` (kimi's CustomEditor reserves
|
|
17921
|
+
* padding for its `>` prompt the same way), and render() paints the
|
|
17922
|
+
* prompt over the first content row's leading padding — kimi's
|
|
17923
|
+
* injectPromptSymbol pattern, consumer-side only. The prompt reads like
|
|
17924
|
+
* the transcript's user messages (`❯ text`), so what the user is about
|
|
17925
|
+
* to send matches what their sent input looks like.
|
|
17081
17926
|
* @module @xmoon76/dsh-pi-tui/tui-editor
|
|
17082
17927
|
*/
|
|
17928
|
+
/** Visible width of the `❯ ` prompt — must equal the editor's paddingX,
|
|
17929
|
+
* so content and wrapped continuations start right after the prompt. */
|
|
17930
|
+
const PROMPT_WIDTH = 2;
|
|
17931
|
+
/** Whether the text before the cursor is a slash-command line WITH an
|
|
17932
|
+
* argument position (`/cmd <arg>`): the command name is the first
|
|
17933
|
+
* whitespace-delimited (space OR tab — the fork's path delimiters) token
|
|
17934
|
+
* after the leading `/`; anything after it is the argument the completion
|
|
17935
|
+
* re-trigger targets. */
|
|
17936
|
+
function isSlashCommandArgument(textBeforeCursor) {
|
|
17937
|
+
const trimmed = textBeforeCursor.trimStart();
|
|
17938
|
+
if (!trimmed.startsWith("/")) return false;
|
|
17939
|
+
const separatorIndex = trimmed.search(/[ \t]/);
|
|
17940
|
+
if (separatorIndex <= 0) return false;
|
|
17941
|
+
const commandName = trimmed.slice(1, separatorIndex);
|
|
17942
|
+
return commandName !== "" && !commandName.includes("/");
|
|
17943
|
+
}
|
|
17944
|
+
/**
|
|
17945
|
+
* Paint the terminal-prompt `❯ ` over the first content row of the fork's
|
|
17946
|
+
* editor render. The fork renders every content row with `paddingX` spaces
|
|
17947
|
+
* on each side; the first visible content row's leading padding is replaced
|
|
17948
|
+
* by the prompt (kimi's injectPromptSymbol, which requires the same leading
|
|
17949
|
+
* padding). Wrapped continuation rows keep their padding, so they indent
|
|
17950
|
+
* under the prompt exactly like the transcript's user-message bullet
|
|
17951
|
+
* (BulletedComponent). No SGR is emitted for the trailing space, so the
|
|
17952
|
+
* text after the prompt keeps its own colouring.
|
|
17953
|
+
*
|
|
17954
|
+
* When the draft is scrolled (the fork paints `↑ N more` as the top row),
|
|
17955
|
+
* the first visible row is NOT the first line of the draft, so no prompt is
|
|
17956
|
+
* painted — a floating `❯` on a continuation line would lie about where the
|
|
17957
|
+
* input starts (kimi paints unconditionally; the scroll indicator makes the
|
|
17958
|
+
* skip unambiguous here).
|
|
17959
|
+
*/
|
|
17960
|
+
function injectEditorPrompt(lines) {
|
|
17961
|
+
if (lines.length < 3) return lines;
|
|
17962
|
+
if (lines[0].includes("↑")) return lines;
|
|
17963
|
+
const first = lines[1];
|
|
17964
|
+
if (first.length < PROMPT_WIDTH) return lines;
|
|
17965
|
+
for (let i = 0; i < PROMPT_WIDTH; i++) if (first[i] !== " ") return lines;
|
|
17966
|
+
lines[1] = `${color.roleUser("❯")} ${first.slice(PROMPT_WIDTH)}`;
|
|
17967
|
+
return lines;
|
|
17968
|
+
}
|
|
17083
17969
|
var TuiEditor = class extends Editor {
|
|
17970
|
+
constructor(tui, theme) {
|
|
17971
|
+
super(tui, theme, { paddingX: PROMPT_WIDTH });
|
|
17972
|
+
}
|
|
17973
|
+
render(width) {
|
|
17974
|
+
return injectEditorPrompt(super.render(width));
|
|
17975
|
+
}
|
|
17084
17976
|
handleInput(data) {
|
|
17085
17977
|
if (matchesKey(data, "escape") && this.isShowingAutocomplete()) {
|
|
17086
17978
|
this.cancelAutocomplete();
|
|
@@ -17090,13 +17982,17 @@ var TuiEditor = class extends Editor {
|
|
|
17090
17982
|
this.reopenAutocompleteAfterInput();
|
|
17091
17983
|
}
|
|
17092
17984
|
/** Reopen `@dir/` mention completion right after a key closed it (e.g.
|
|
17093
|
-
* Tab accepted a directory)
|
|
17094
|
-
*
|
|
17985
|
+
* Tab accepted a directory), and the same for a slash-command path
|
|
17986
|
+
* argument (`/image subdir/`): the editor's natural trigger only fires on
|
|
17987
|
+
* letters, so a freshly accepted trailing `/` would otherwise leave the
|
|
17988
|
+
* children hidden until the next keystroke. Mirrors kimi's
|
|
17989
|
+
* reopenAutocompleteAfterInput for the @-mention case, extended to
|
|
17990
|
+
* path-argument commands. */
|
|
17095
17991
|
reopenAutocompleteAfterInput() {
|
|
17096
17992
|
if (this.isShowingAutocomplete()) return;
|
|
17097
17993
|
const { line, col } = this.getCursor();
|
|
17098
17994
|
const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? "";
|
|
17099
|
-
if (textBeforeCursor.endsWith("/") && extractAtPrefix(textBeforeCursor) !== null) this.requestAutocomplete({
|
|
17995
|
+
if (textBeforeCursor.endsWith("/") && (extractAtPrefix(textBeforeCursor) !== null || isSlashCommandArgument(textBeforeCursor))) this.requestAutocomplete({
|
|
17100
17996
|
force: false,
|
|
17101
17997
|
explicitTab: false
|
|
17102
17998
|
});
|
|
@@ -17343,6 +18239,19 @@ var UnstableMountedComponentAdapter = class {
|
|
|
17343
18239
|
}
|
|
17344
18240
|
}
|
|
17345
18241
|
};
|
|
18242
|
+
/**
|
|
18243
|
+
* Tools whose result is a structured XML envelope (read/write/read_image/
|
|
18244
|
+
* skill). Each has its own parser branch in the folded-card logic; this
|
|
18245
|
+
* set is the DEFENSIVE backstop: if a future envelope tool is added
|
|
18246
|
+
* without its parser, its raw `<…>` result never leaks into the folded
|
|
18247
|
+
* preview. Register new envelope tools here AND add their parser branch.
|
|
18248
|
+
*/
|
|
18249
|
+
const XML_ENVELOPE_RESULT_TOOLS = /* @__PURE__ */ new Set([
|
|
18250
|
+
"read",
|
|
18251
|
+
"write",
|
|
18252
|
+
"read_image",
|
|
18253
|
+
"skill"
|
|
18254
|
+
]);
|
|
17346
18255
|
/** First lines of a multi-line text, joined for folded previews. */
|
|
17347
18256
|
function preview(text, lines) {
|
|
17348
18257
|
const parts = text.split("\n");
|
|
@@ -17557,6 +18466,54 @@ var BulletedComponent = class {
|
|
|
17557
18466
|
}
|
|
17558
18467
|
};
|
|
17559
18468
|
/**
|
|
18469
|
+
* User-message bubble: the whole row is painted with the role background
|
|
18470
|
+
* (dsh-web `--dsw-specific-bubble` parity — user input is a floating
|
|
18471
|
+
* block, NOT a text colour, so it never collides with the assistant's
|
|
18472
|
+
* brand-blue whale or kimi's amber), the ❯ marker leads the FIRST line in
|
|
18473
|
+
* the role colour, and wrapped continuation lines indent under it with the
|
|
18474
|
+
* background kept across the row.
|
|
18475
|
+
*
|
|
18476
|
+
* The child stays LIVE (a resize re-wraps at the new width — the 5a76526
|
|
18477
|
+
* rule) and the prefixed output is REFERENCE-STABLE like BulletedComponent:
|
|
18478
|
+
* same child array + same width → same prefixed array, so the fork's
|
|
18479
|
+
* per-frame processed-line reuse keeps hitting on steady frames.
|
|
18480
|
+
*/
|
|
18481
|
+
var UserBubbleComponent = class {
|
|
18482
|
+
child;
|
|
18483
|
+
marker;
|
|
18484
|
+
markerWidth;
|
|
18485
|
+
bg;
|
|
18486
|
+
lastChild;
|
|
18487
|
+
lastWidth = -1;
|
|
18488
|
+
cached;
|
|
18489
|
+
constructor(child, marker, bg) {
|
|
18490
|
+
this.child = child;
|
|
18491
|
+
this.marker = marker;
|
|
18492
|
+
this.markerWidth = visibleWidth(marker);
|
|
18493
|
+
this.bg = bg;
|
|
18494
|
+
}
|
|
18495
|
+
invalidate() {
|
|
18496
|
+
this.child.invalidate?.();
|
|
18497
|
+
}
|
|
18498
|
+
dispose() {
|
|
18499
|
+
this.child.dispose?.();
|
|
18500
|
+
}
|
|
18501
|
+
render(width) {
|
|
18502
|
+
const inner = Math.max(1, width - this.markerWidth);
|
|
18503
|
+
const child = this.child.render(inner);
|
|
18504
|
+
if (child === this.lastChild && width === this.lastWidth && this.cached !== void 0) return this.cached;
|
|
18505
|
+
this.lastChild = child;
|
|
18506
|
+
this.lastWidth = width;
|
|
18507
|
+
const indent = " ".repeat(this.markerWidth);
|
|
18508
|
+
this.cached = child.map((line, index) => {
|
|
18509
|
+
const prefix = index === 0 ? this.marker : indent;
|
|
18510
|
+
const pad = " ".repeat(Math.max(0, inner - visibleWidth(line)));
|
|
18511
|
+
return this.bg(prefix + line + pad);
|
|
18512
|
+
});
|
|
18513
|
+
return this.cached;
|
|
18514
|
+
}
|
|
18515
|
+
};
|
|
18516
|
+
/**
|
|
17560
18517
|
* Longest prefix of `text` whose WRAPPED height fits `budget` rows at
|
|
17561
18518
|
* `width`, with an ellipsis marking a cut — the approval dialog's height
|
|
17562
18519
|
* budget must count wrapped rows, not raw lines, because a single long
|
|
@@ -17959,6 +18916,10 @@ var TuiApp = class TuiApp {
|
|
|
17959
18916
|
lastRendererRevision = -1;
|
|
17960
18917
|
/** M9: the editor seat holder (the atomic handoff + current occupant). */
|
|
17961
18918
|
editorSeatHolder;
|
|
18919
|
+
/** The durable-image loader (plan M8): optional, wired by the runner. */
|
|
18920
|
+
imageLoader;
|
|
18921
|
+
/** The thumbnail fallback theme (plan M9): optional, wired by the runner. */
|
|
18922
|
+
imageTheme;
|
|
17962
18923
|
/** The busy indicator row directly above the editor border; idle renders nothing. */
|
|
17963
18924
|
working;
|
|
17964
18925
|
/** The fullscreen transcript ScrollView, for click hit-testing offsets. */
|
|
@@ -17970,6 +18931,19 @@ var TuiApp = class TuiApp {
|
|
|
17970
18931
|
* keyboard behavior is unaffected by mouse toggles.
|
|
17971
18932
|
*/
|
|
17972
18933
|
expandedOverride = /* @__PURE__ */ new Map();
|
|
18934
|
+
/**
|
|
18935
|
+
* Per-OCCURRENCE image-display collapse overrides from fullscreen clicks:
|
|
18936
|
+
* a collapsed attachment occurrence renders its constant info bar
|
|
18937
|
+
* (`🖼️ name · W×H · bytes`) only — the image rows collapse, the identity
|
|
18938
|
+
* never does. Keyed by message object + the image block's index within
|
|
18939
|
+
* `message.content` (the folder's entry objects are stable within one
|
|
18940
|
+
* folder lifetime — the same identity the per-message render cache and
|
|
18941
|
+
* `expandedOverride` use), so the SAME durable attachment displayed twice
|
|
18942
|
+
* collapses independently: clicking "this position's picture" never
|
|
18943
|
+
* touches the other occurrence. Absent = expanded. Cleared on session
|
|
18944
|
+
* switch with the other click overrides.
|
|
18945
|
+
*/
|
|
18946
|
+
collapsedOccurrences = /* @__PURE__ */ new Map();
|
|
17973
18947
|
/** Rendered row heights per transcript message, for mouse hit-testing. */
|
|
17974
18948
|
messageRows = [];
|
|
17975
18949
|
/** ONE external-editor ownership at a time: set synchronously at launch,
|
|
@@ -18029,6 +19003,8 @@ var TuiApp = class TuiApp {
|
|
|
18029
19003
|
if (this.tasksActive) this.renderFooter();
|
|
18030
19004
|
this.editorSeatHolder.notifyChanged();
|
|
18031
19005
|
};
|
|
19006
|
+
this.imageLoader = options.imageLoader;
|
|
19007
|
+
this.imageTheme = options.imageTheme;
|
|
18032
19008
|
this.editorSeatHolder = new EditorSeatHolder({
|
|
18033
19009
|
hostAdapter: () => this.hostEditorAdapter(),
|
|
18034
19010
|
surfaceId: `tui-${Date.now().toString(36)}`,
|
|
@@ -18171,7 +19147,7 @@ var TuiApp = class TuiApp {
|
|
|
18171
19147
|
}
|
|
18172
19148
|
this.terminalSchemeListeners.clear();
|
|
18173
19149
|
this.expandedOverride.clear();
|
|
18174
|
-
this.
|
|
19150
|
+
this.disposeMessageComponents();
|
|
18175
19151
|
this.localMessages.length = 0;
|
|
18176
19152
|
for (const lease of this.extensionOverlayLeases) lease.close();
|
|
18177
19153
|
this.extensionOverlayLeases.clear();
|
|
@@ -18426,6 +19402,11 @@ var TuiApp = class TuiApp {
|
|
|
18426
19402
|
this.rebuildMessages();
|
|
18427
19403
|
return { consume: true };
|
|
18428
19404
|
}
|
|
19405
|
+
if (matchesKey(data, "ctrl+v")) {
|
|
19406
|
+
if (this.events.onClipboardPaste === void 0) return { consume: false };
|
|
19407
|
+
this.events.onClipboardPaste();
|
|
19408
|
+
return { consume: true };
|
|
19409
|
+
}
|
|
18429
19410
|
if (matchesKey(data, "ctrl+t")) {
|
|
18430
19411
|
this.toggleTodoPanel();
|
|
18431
19412
|
return { consume: true };
|
|
@@ -18644,10 +19625,14 @@ var TuiApp = class TuiApp {
|
|
|
18644
19625
|
this.externalEditorInFlight = false;
|
|
18645
19626
|
}
|
|
18646
19627
|
}
|
|
18647
|
-
/** Record a submitted line into the editor history and the persistence
|
|
19628
|
+
/** Record a submitted line into the editor history and the persistence
|
|
19629
|
+
* mirror. The host may REFUSE via `shouldRememberInput` (e.g. a
|
|
19630
|
+
* multimodal draft whose placeholders die with their drafts — recalling
|
|
19631
|
+
* it would re-send dead placeholders as plain text; review finding). */
|
|
18648
19632
|
rememberInput(text) {
|
|
18649
19633
|
const trimmed = text.trim();
|
|
18650
19634
|
if (trimmed === "") return;
|
|
19635
|
+
if (this.events.shouldRememberInput?.(text) === false) return;
|
|
18651
19636
|
this.editor.addToHistory(trimmed);
|
|
18652
19637
|
if (this.inputHistory[0] === trimmed) return;
|
|
18653
19638
|
this.inputHistory.unshift(trimmed);
|
|
@@ -18928,7 +19913,8 @@ var TuiApp = class TuiApp {
|
|
|
18928
19913
|
const height = component.render(width).length + (index < blocks.length - 1 ? 1 : 0);
|
|
18929
19914
|
rows.push({
|
|
18930
19915
|
message,
|
|
18931
|
-
height
|
|
19916
|
+
height,
|
|
19917
|
+
attachments: this.attachmentRangesOf(component, width)
|
|
18932
19918
|
});
|
|
18933
19919
|
if (index < blocks.length - 1) this.messagesView.addChild(new Spacer());
|
|
18934
19920
|
});
|
|
@@ -18962,13 +19948,86 @@ var TuiApp = class TuiApp {
|
|
|
18962
19948
|
this.reconcileWorkingRow();
|
|
18963
19949
|
this.requestRender();
|
|
18964
19950
|
}
|
|
18965
|
-
/**
|
|
18966
|
-
*
|
|
18967
|
-
*
|
|
18968
|
-
*
|
|
18969
|
-
*
|
|
18970
|
-
|
|
18971
|
-
|
|
19951
|
+
/** The per-attachment click regions inside one message component: the row
|
|
19952
|
+
* span (message-relative) of every COLLAPSIBLE `ImageThumbnail` child —
|
|
19953
|
+
* the host wires a `collapsedRef` only for user/assistant message
|
|
19954
|
+
* attachments. Tool-card images (no collapsedRef) stay inside their
|
|
19955
|
+
* card's own click surface: their rows advance the counter but never
|
|
19956
|
+
* enter a collapse range, so a click there still folds/unfolds the card
|
|
19957
|
+
* instead of being swallowed by an inert attachment toggle. Every other
|
|
19958
|
+
* child's height still advances the row counter, so the spans line up
|
|
19959
|
+
* with the rendered layout.
|
|
19960
|
+
*
|
|
19961
|
+
* The ordinal among COLLAPSIBLE thumbnails IS the occurrence's image
|
|
19962
|
+
* index: renderUserBlocks / renderBlockSequence create a collapsible
|
|
19963
|
+
* thumbnail for EVERY image block of the message in content order, so
|
|
19964
|
+
* the nth thumbnail ↔ the nth image block — the same index the host's
|
|
19965
|
+
* collapsedRef getter reads. */
|
|
19966
|
+
attachmentRangesOf(component, width) {
|
|
19967
|
+
if (!(component instanceof Container)) return [];
|
|
19968
|
+
const ranges = [];
|
|
19969
|
+
let row = 0;
|
|
19970
|
+
let imageIndex = 0;
|
|
19971
|
+
for (const child of component.children) {
|
|
19972
|
+
const height = child.render(width).length;
|
|
19973
|
+
if (child instanceof ImageThumbnail && child.collapsible) {
|
|
19974
|
+
ranges.push({
|
|
19975
|
+
imageIndex,
|
|
19976
|
+
start: row,
|
|
19977
|
+
end: row + height
|
|
19978
|
+
});
|
|
19979
|
+
imageIndex += 1;
|
|
19980
|
+
}
|
|
19981
|
+
row += height;
|
|
19982
|
+
}
|
|
19983
|
+
return ranges;
|
|
19984
|
+
}
|
|
19985
|
+
/** Re-measure the message row map (heights + attachment spans) from the
|
|
19986
|
+
* cached components — called before a fullscreen click hit-test so a
|
|
19987
|
+
* thumbnail that just finished loading (1 row → image rows) never shifts
|
|
19988
|
+
* the hit map. Cached renders make this cheap (reference-stable lines). */
|
|
19989
|
+
refreshMessageRows() {
|
|
19990
|
+
const width = this.terminal.columns;
|
|
19991
|
+
const boundary = this.expandBoundary();
|
|
19992
|
+
const blocks = [...this.messages.filter((message) => !(message.kind === "thinking" && this.hideThinking)), ...this.localMessages];
|
|
19993
|
+
const rows = [];
|
|
19994
|
+
blocks.forEach((message, index) => {
|
|
19995
|
+
const component = this.componentForMessage(message, boundary);
|
|
19996
|
+
const height = component.render(width).length + (index < blocks.length - 1 ? 1 : 0);
|
|
19997
|
+
rows.push({
|
|
19998
|
+
message,
|
|
19999
|
+
height,
|
|
20000
|
+
attachments: this.attachmentRangesOf(component, width)
|
|
20001
|
+
});
|
|
20002
|
+
});
|
|
20003
|
+
this.messageRows = rows;
|
|
20004
|
+
}
|
|
20005
|
+
/** The live collapse flag for ONE image-block occurrence (message object
|
|
20006
|
+
* + image index within its content). Read at render time — a fullscreen
|
|
20007
|
+
* click only repaints. */
|
|
20008
|
+
occurrenceCollapsedRef(message, imageIndex) {
|
|
20009
|
+
return () => this.collapsedOccurrences.get(message)?.has(imageIndex) ?? false;
|
|
20010
|
+
}
|
|
20011
|
+
/** Toggle ONE image occurrence's display (fullscreen click). The info
|
|
20012
|
+
* bar stays constant — only that image's rows collapse/expand; a
|
|
20013
|
+
* repeated attachment elsewhere in the transcript is untouched. */
|
|
20014
|
+
toggleAttachmentCollapsed(message, imageIndex) {
|
|
20015
|
+
let indices = this.collapsedOccurrences.get(message);
|
|
20016
|
+
if (indices === void 0) {
|
|
20017
|
+
indices = /* @__PURE__ */ new Set();
|
|
20018
|
+
this.collapsedOccurrences.set(message, indices);
|
|
20019
|
+
}
|
|
20020
|
+
if (indices.has(imageIndex)) indices.delete(imageIndex);
|
|
20021
|
+
else indices.add(imageIndex);
|
|
20022
|
+
this.rebuildMessages();
|
|
20023
|
+
}
|
|
20024
|
+
/**
|
|
20025
|
+
* Show or hide the busy indicator on the row directly above the editor
|
|
20026
|
+
* border: while a turn is streaming or a tool is running (the runner
|
|
20027
|
+
* derives it from turn/start and turn/end). A compaction in flight keeps
|
|
20028
|
+
* the row live under its own label (see {@link setCompacting}).
|
|
20029
|
+
*/
|
|
20030
|
+
setWorking(active) {
|
|
18972
20031
|
this.workingActive = active;
|
|
18973
20032
|
this.reconcileWorkingRow();
|
|
18974
20033
|
this.requestRender();
|
|
@@ -19062,9 +20121,15 @@ var TuiApp = class TuiApp {
|
|
|
19062
20121
|
const welcomeHeight = this.welcomeCard.render(width).length;
|
|
19063
20122
|
const messageRow = rowInScroll + scrollTop - welcomeHeight;
|
|
19064
20123
|
if (messageRow < 0) return;
|
|
20124
|
+
this.refreshMessageRows();
|
|
19065
20125
|
let row = 0;
|
|
19066
20126
|
for (const entry of this.messageRows) {
|
|
19067
20127
|
if (messageRow < row + entry.height) {
|
|
20128
|
+
const inMessage = messageRow - row;
|
|
20129
|
+
for (const attachment of entry.attachments) if (inMessage >= attachment.start && inMessage < attachment.end) {
|
|
20130
|
+
this.toggleAttachmentCollapsed(entry.message, attachment.imageIndex);
|
|
20131
|
+
return;
|
|
20132
|
+
}
|
|
19068
20133
|
this.toggleMessageExpanded(entry.message);
|
|
19069
20134
|
return;
|
|
19070
20135
|
}
|
|
@@ -19082,6 +20147,19 @@ var TuiApp = class TuiApp {
|
|
|
19082
20147
|
* session switch must not leak the old session's click toggles). */
|
|
19083
20148
|
clearSessionOverrides() {
|
|
19084
20149
|
this.expandedOverride.clear();
|
|
20150
|
+
this.collapsedOccurrences.clear();
|
|
20151
|
+
this.disposeMessageComponents();
|
|
20152
|
+
}
|
|
20153
|
+
/** Dispose every cached message component, then clear the cache. The
|
|
20154
|
+
* component dispose is idempotent (thumbnails release their loader
|
|
20155
|
+
* subscription once). */
|
|
20156
|
+
disposeMessageComponents() {
|
|
20157
|
+
for (const entry of this.messageComponents.values()) {
|
|
20158
|
+
const component = entry.component;
|
|
20159
|
+
if (component?.dispose !== void 0) try {
|
|
20160
|
+
component.dispose();
|
|
20161
|
+
} catch {}
|
|
20162
|
+
}
|
|
19085
20163
|
this.messageComponents.clear();
|
|
19086
20164
|
}
|
|
19087
20165
|
/** Show or clear plan mode: header + footer badges and a warning-tinted editor border. */
|
|
@@ -19144,6 +20222,23 @@ var TuiApp = class TuiApp {
|
|
|
19144
20222
|
this.requestRender();
|
|
19145
20223
|
}
|
|
19146
20224
|
/**
|
|
20225
|
+
* Insert text at the editor cursor — the image-placeholder insertion
|
|
20226
|
+
* path (`/image`, Ctrl+V). A replacement editor without cursor insertion
|
|
20227
|
+
* falls back to appending at the end of the draft; the host editor
|
|
20228
|
+
* inserts at the cursor (fork `Editor.insertTextAtCursor`).
|
|
20229
|
+
*/
|
|
20230
|
+
insertIntoEditor(text) {
|
|
20231
|
+
if (this.viewerMode !== void 0) {
|
|
20232
|
+
this.draftBeforeViewer = (this.draftBeforeViewer ?? this.seatEditor().getText()) + text;
|
|
20233
|
+
return;
|
|
20234
|
+
}
|
|
20235
|
+
const editor = this.seatEditor();
|
|
20236
|
+
if (editor.insertTextAtCursor !== void 0) editor.insertTextAtCursor(text);
|
|
20237
|
+
else editor.setText(editor.getText() + text);
|
|
20238
|
+
this.editorSeatHolder.notifyChanged();
|
|
20239
|
+
this.requestRender();
|
|
20240
|
+
}
|
|
20241
|
+
/**
|
|
19147
20242
|
* Enter or leave the read-only subagent viewer. While viewing, the
|
|
19148
20243
|
* editor bar is covered by a placeholder (the real draft is preserved
|
|
19149
20244
|
* and restored on exit), the editor border switches to the accent color,
|
|
@@ -19897,6 +20992,7 @@ var TuiApp = class TuiApp {
|
|
|
19897
20992
|
col: remaining
|
|
19898
20993
|
});
|
|
19899
20994
|
},
|
|
20995
|
+
insertTextAtCursor: (text) => editor.insertTextAtCursor(text),
|
|
19900
20996
|
handleInput: (data) => editor.handleInput(data),
|
|
19901
20997
|
runWithoutChange: (task) => {
|
|
19902
20998
|
const onChange = editor.onChange;
|
|
@@ -20056,6 +21152,10 @@ var TuiApp = class TuiApp {
|
|
|
20056
21152
|
}
|
|
20057
21153
|
const rendererRevisionChanged = this.renderers !== void 0 && entry.rendererRevision !== this.renderers.snapshot().revision;
|
|
20058
21154
|
if (entry.boundary !== boundary || entry.themeRev !== this.themeRevision || entry.expanded !== expanded || rendererRevisionChanged || this.componentStale(entry, message)) {
|
|
21155
|
+
const previous = entry.component;
|
|
21156
|
+
if (previous?.dispose !== void 0) try {
|
|
21157
|
+
previous.dispose();
|
|
21158
|
+
} catch {}
|
|
20059
21159
|
const rebuilt = this.buildMessage(message, boundary, expanded);
|
|
20060
21160
|
entry.component = rebuilt.component;
|
|
20061
21161
|
entry.boundary = rebuilt.boundary;
|
|
@@ -20096,6 +21196,7 @@ var TuiApp = class TuiApp {
|
|
|
20096
21196
|
case "thinking":
|
|
20097
21197
|
case "system":
|
|
20098
21198
|
entry.text = message.text;
|
|
21199
|
+
entry.content = message.kind === "user" || message.kind === "assistant" ? message.content : void 0;
|
|
20099
21200
|
entry.running = message.kind === "thinking" ? message.running : void 0;
|
|
20100
21201
|
entry.label = message.kind === "system" ? message.label : void 0;
|
|
20101
21202
|
entry.summary = message.kind === "system" ? message.summary : void 0;
|
|
@@ -20107,6 +21208,7 @@ var TuiApp = class TuiApp {
|
|
|
20107
21208
|
entry.meta = message.meta;
|
|
20108
21209
|
entry.members = message.members;
|
|
20109
21210
|
entry.error = message.error;
|
|
21211
|
+
entry.resultBlocks = message.resultBlocks;
|
|
20110
21212
|
break;
|
|
20111
21213
|
case "summary": break;
|
|
20112
21214
|
case "compaction":
|
|
@@ -20121,10 +21223,10 @@ var TuiApp = class TuiApp {
|
|
|
20121
21223
|
componentStale(entry, message) {
|
|
20122
21224
|
switch (message.kind) {
|
|
20123
21225
|
case "user":
|
|
20124
|
-
case "assistant": return entry.text !== message.text;
|
|
21226
|
+
case "assistant": return entry.text !== message.text || entry.content !== message.content;
|
|
20125
21227
|
case "thinking": return entry.text !== message.text || entry.running !== message.running;
|
|
20126
21228
|
case "system": return entry.text !== message.text || entry.label !== message.label || entry.summary !== message.summary;
|
|
20127
|
-
case "tool": return entry.status !== message.status || entry.args !== message.args || entry.result !== message.result || entry.meta !== message.meta || entry.members !== message.members || entry.error !== message.error;
|
|
21229
|
+
case "tool": return entry.status !== message.status || entry.args !== message.args || entry.result !== message.result || entry.meta !== message.meta || entry.members !== message.members || entry.error !== message.error || entry.resultBlocks !== message.resultBlocks;
|
|
20128
21230
|
case "summary": return false;
|
|
20129
21231
|
case "compaction": return entry.text !== message.text || entry.items !== message.items || entry.tokens !== message.tokens || entry.running !== message.running || entry.errorText !== message.error;
|
|
20130
21232
|
}
|
|
@@ -20136,9 +21238,61 @@ var TuiApp = class TuiApp {
|
|
|
20136
21238
|
* renderer, so a throwing renderer is invoked exactly once per build
|
|
20137
21239
|
* and the host fallback is single-path).
|
|
20138
21240
|
*/
|
|
21241
|
+
/**
|
|
21242
|
+
* Render a message's content blocks IN ORDER (plan §15.2): text blocks
|
|
21243
|
+
* fold into one text component per run, image blocks render as inline
|
|
21244
|
+
* thumbnails between them — `[text, image, text]` stays `text → image →
|
|
21245
|
+
* text` on screen. Other block kinds (reasoning/tool-call) are skipped
|
|
21246
|
+
* exactly like the flat `textWithImageMarkers` path. Only reached when
|
|
21247
|
+
* the loader and theme are wired.
|
|
21248
|
+
*/
|
|
21249
|
+
renderBlockSequence(content, makeText, message) {
|
|
21250
|
+
if (this.imageLoader === void 0 || this.imageTheme === void 0) return makeText(textWithImageMarkers(content));
|
|
21251
|
+
const container = new Container();
|
|
21252
|
+
let buffer = "";
|
|
21253
|
+
const flush = () => {
|
|
21254
|
+
if (buffer !== "") {
|
|
21255
|
+
container.addChild(makeText(buffer));
|
|
21256
|
+
buffer = "";
|
|
21257
|
+
}
|
|
21258
|
+
};
|
|
21259
|
+
let imageIndex = 0;
|
|
21260
|
+
for (const block of content) if (block.type === "text") buffer += block.text;
|
|
21261
|
+
else if (block.type === "image") {
|
|
21262
|
+
flush();
|
|
21263
|
+
container.addChild(new ImageThumbnail(block.attachment, this.imageLoader, this.imageTheme, this.occurrenceCollapsedRef(message, imageIndex)));
|
|
21264
|
+
imageIndex += 1;
|
|
21265
|
+
}
|
|
21266
|
+
flush();
|
|
21267
|
+
return container;
|
|
21268
|
+
}
|
|
21269
|
+
/**
|
|
21270
|
+
* A user message with images: ONE bubble whose text keeps an inline
|
|
21271
|
+
* `🖼️ name` placeholder at every image's ORIGINAL position — the user's
|
|
21272
|
+
* own words then read like the draft they submitted (`这张图是啥 🖼️
|
|
21273
|
+
* shot.png`), instead of a bubble with the image silently moved to its
|
|
21274
|
+
* own row. The thumbnails follow as attachment rows in block order; the
|
|
21275
|
+
* bubble marker carries the position, the thumbnail carries the picture.
|
|
21276
|
+
*/
|
|
21277
|
+
renderUserBlocks(content, message) {
|
|
21278
|
+
const container = new Container();
|
|
21279
|
+
container.addChild(new UserBubbleComponent(new Text(textWithImageMarkers(content), 0, 0), `${color.roleUser("❯")} `, color.roleUserBg));
|
|
21280
|
+
let imageIndex = 0;
|
|
21281
|
+
for (const block of content) if (block.type === "image") {
|
|
21282
|
+
container.addChild(new ImageThumbnail(block.attachment, this.imageLoader, this.imageTheme, this.occurrenceCollapsedRef(message, imageIndex)));
|
|
21283
|
+
imageIndex += 1;
|
|
21284
|
+
}
|
|
21285
|
+
return container;
|
|
21286
|
+
}
|
|
20139
21287
|
renderMessage(message, boundary) {
|
|
20140
|
-
if (message.kind === "user")
|
|
20141
|
-
|
|
21288
|
+
if (message.kind === "user") {
|
|
21289
|
+
if (message.content !== void 0 && this.imageLoader !== void 0 && this.imageTheme !== void 0) return this.renderUserBlocks(message.content, message);
|
|
21290
|
+
return new UserBubbleComponent(new Text(message.text, 0, 0), `${color.roleUser("❯")} `, color.roleUserBg);
|
|
21291
|
+
}
|
|
21292
|
+
if (message.kind === "assistant") {
|
|
21293
|
+
if (message.content !== void 0 && this.imageLoader !== void 0 && this.imageTheme !== void 0) return this.renderBlockSequence(message.content, (text) => new BulletedComponent(new Markdown(text, 0, 0, markdownTheme), `${color.primary("🐋")} `), message);
|
|
21294
|
+
return new BulletedComponent(new Markdown(message.text, 0, 0, markdownTheme), `${color.primary("🐋")} `);
|
|
21295
|
+
}
|
|
20142
21296
|
if (message.kind === "thinking") {
|
|
20143
21297
|
if (message.turn >= boundary || this.expandedOverride.get(message) === true) return new Text(color.textDimItalic(`🐳 ${message.text}`), 0, 0);
|
|
20144
21298
|
const lines = message.text.split("\n").filter((line) => line !== "");
|
|
@@ -20164,14 +21318,17 @@ var TuiApp = class TuiApp {
|
|
|
20164
21318
|
const emoji = message.emoji ?? "📎";
|
|
20165
21319
|
if (expanded) {
|
|
20166
21320
|
row.addChild(new Text(color.textMuted(`${emoji} Context injection ${message.label}`), 0, 0));
|
|
20167
|
-
|
|
21321
|
+
const body = systemContextBody(message.text);
|
|
21322
|
+
if (body === void 0) row.addChild(new Text(color.textDim(message.text), 0, 0));
|
|
21323
|
+
else for (const line of body) row.addChild(new Text(color.textDim(line), 0, 0));
|
|
20168
21324
|
} else {
|
|
20169
|
-
const summary = message.summary === void 0 ?
|
|
21325
|
+
const summary = message.summary === void 0 ? skillFoldedPreview(message.text) : ` — ${message.summary}`;
|
|
20170
21326
|
row.addChild(new Text(truncateToWidth(color.textMuted(`${emoji} Context injection ${message.label}${summary} (ctrl+o to expand)`), this.terminal.columns, "…"), 0, 0));
|
|
20171
21327
|
}
|
|
20172
21328
|
return row;
|
|
20173
21329
|
}
|
|
20174
|
-
|
|
21330
|
+
const unwrapped = systemContextBody(message.text)?.join("\n") ?? message.text;
|
|
21331
|
+
return new Text(expanded ? `${color.textMuted("§")} ${color.textDim(unwrapped)}` : color.textMuted(`§ ${truncateToWidth(preview(unwrapped, 2), Math.max(1, this.terminal.columns - 22), "…")} (ctrl+o to expand)`), 0, 0);
|
|
20175
21332
|
}
|
|
20176
21333
|
if (message.kind === "summary") return new Text(color.textDim(message.text), 0, 0);
|
|
20177
21334
|
if (message.kind === "compaction") {
|
|
@@ -20205,6 +21362,9 @@ var TuiApp = class TuiApp {
|
|
|
20205
21362
|
const foldedCall = header.summary === "" ? foldedCallPreview(message.name, message.args) : "";
|
|
20206
21363
|
let resultPreview;
|
|
20207
21364
|
if (message.name === "read") resultPreview = readFoldedPreview(message.result);
|
|
21365
|
+
else if (message.name === "write") resultPreview = writeFoldedPreview(message.result);
|
|
21366
|
+
else if (message.name === "skill") resultPreview = skillFoldedPreview(message.result);
|
|
21367
|
+
else if (message.name === "read_image") resultPreview = imageFoldedPreview(message.result);
|
|
20208
21368
|
else if (message.name === "ask_user_question") {
|
|
20209
21369
|
const summary = message.error === void 0 ? askAnswersSummary(message.result) : void 0;
|
|
20210
21370
|
resultPreview = summary === void 0 ? "" : ` — ${summary}`;
|
|
@@ -20214,7 +21374,8 @@ var TuiApp = class TuiApp {
|
|
|
20214
21374
|
} else if (FOLDED_JSON_RESULT_TOOLS.has(message.name)) {
|
|
20215
21375
|
const summary = foldedResultSummaryFor(message.name, message.result);
|
|
20216
21376
|
resultPreview = summary === void 0 || summary === "" ? "" : ` — ${summary}`;
|
|
20217
|
-
} else
|
|
21377
|
+
} else if (XML_ENVELOPE_RESULT_TOOLS.has(message.name) && message.result.trimStart().startsWith("<")) resultPreview = "";
|
|
21378
|
+
else resultPreview = message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
|
|
20218
21379
|
const callHead = foldedCall === "" ? "" : foldedCall;
|
|
20219
21380
|
const headWithPreview = `${head}${callHead}${resultPreview}`;
|
|
20220
21381
|
if (callPreview?.kind === "bash" && callPreview.command !== "") {
|
|
@@ -20402,7 +21563,7 @@ var TuiApp = class TuiApp {
|
|
|
20402
21563
|
this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
|
|
20403
21564
|
if (callView.rawInput !== void 0) for (const line of genericRawInputLines(message.name, callView.rawInput)) card.addChild(new Text(color.textDim(line), 0, 0));
|
|
20404
21565
|
for (const block of callView.content ?? []) if (block.type === "text") card.addChild(new Text(color.textDim(block.text), 0, 0));
|
|
20405
|
-
else card.addChild(new Text(color.textDim(
|
|
21566
|
+
else card.addChild(new Text(color.textDim(this.blockDisplayText(block)), 0, 0));
|
|
20406
21567
|
return;
|
|
20407
21568
|
}
|
|
20408
21569
|
}
|
|
@@ -20475,8 +21636,25 @@ var TuiApp = class TuiApp {
|
|
|
20475
21636
|
case "generic": {
|
|
20476
21637
|
const content = resultView.content ?? [];
|
|
20477
21638
|
if (content.length > 0) {
|
|
20478
|
-
|
|
20479
|
-
|
|
21639
|
+
if (content.some((block) => block.type === "image") && this.imageLoader !== void 0 && this.imageTheme !== void 0) {
|
|
21640
|
+
let buffer = "";
|
|
21641
|
+
const flush = () => {
|
|
21642
|
+
if (buffer !== "") {
|
|
21643
|
+
card.addChild(new Text(color.textDim(buffer), 0, 0));
|
|
21644
|
+
buffer = "";
|
|
21645
|
+
}
|
|
21646
|
+
};
|
|
21647
|
+
for (const block of content) if (block.type === "text") buffer += block.text;
|
|
21648
|
+
else if (block.type === "image") {
|
|
21649
|
+
flush();
|
|
21650
|
+
card.addChild(new ImageThumbnail(block.attachment, this.imageLoader, this.imageTheme));
|
|
21651
|
+
} else {
|
|
21652
|
+
flush();
|
|
21653
|
+
card.addChild(new Text(color.textDim(JSON.stringify(block, null, 2)), 0, 0));
|
|
21654
|
+
}
|
|
21655
|
+
flush();
|
|
21656
|
+
} else for (const block of content) if (block.type === "text") card.addChild(new Text(color.textDim(block.text), 0, 0));
|
|
21657
|
+
else card.addChild(new Text(color.textDim(this.blockDisplayText(block)), 0, 0));
|
|
20480
21658
|
return;
|
|
20481
21659
|
}
|
|
20482
21660
|
break;
|
|
@@ -20504,19 +21682,87 @@ var TuiApp = class TuiApp {
|
|
|
20504
21682
|
});
|
|
20505
21683
|
return;
|
|
20506
21684
|
}
|
|
21685
|
+
if (message.status === "ok") return;
|
|
21686
|
+
}
|
|
21687
|
+
if (message.name === "skill") {
|
|
21688
|
+
const envelope = parseSkillEnvelope(message.result);
|
|
21689
|
+
if (envelope !== void 0) {
|
|
21690
|
+
card.addChild(new Text(color.textMuted(` skill: ${envelope.name}`), 0, 0));
|
|
21691
|
+
for (const line of envelope.instructions.split("\n")) card.addChild(new Text(color.textDim(line), 0, 0));
|
|
21692
|
+
return;
|
|
21693
|
+
}
|
|
21694
|
+
if (message.status === "ok") return;
|
|
21695
|
+
}
|
|
21696
|
+
if (message.name === "read_image") {
|
|
21697
|
+
const envelope = parseImageEnvelope(message.result);
|
|
21698
|
+
if (envelope !== void 0) {
|
|
21699
|
+
card.addChild(new Text(color.textDim(` ${envelope.summary}`), 0, 0));
|
|
21700
|
+
card.addChild(new Text(color.textMuted(` path: ${relativizeToCwd(envelope.path, this.workspaceRoot)}`), 0, 0));
|
|
21701
|
+
this.renderResultImageBlocks(card, message.resultBlocks);
|
|
21702
|
+
return;
|
|
21703
|
+
}
|
|
21704
|
+
if (message.status === "ok") {
|
|
21705
|
+
this.renderResultImageBlocks(card, message.resultBlocks);
|
|
21706
|
+
return;
|
|
21707
|
+
}
|
|
21708
|
+
}
|
|
21709
|
+
if (message.name === "write") {
|
|
21710
|
+
const envelope = parseWriteEnvelope(message.result);
|
|
21711
|
+
if (envelope !== void 0) {
|
|
21712
|
+
card.addChild(new Text(color.textDim(` ${envelope.verb} ${relativizeToCwd(envelope.path, this.workspaceRoot)}`), 0, 0));
|
|
21713
|
+
return;
|
|
21714
|
+
}
|
|
21715
|
+
if (message.status === "ok") return;
|
|
20507
21716
|
}
|
|
20508
21717
|
if (isDiffResult(message.name, message.result)) for (const line of renderDiffLines(message.result)) card.addChild(new Text(line, 0, 0));
|
|
20509
21718
|
else {
|
|
20510
21719
|
this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
|
|
20511
21720
|
const blocks = message.resultBlocks ?? [];
|
|
20512
|
-
|
|
20513
|
-
|
|
20514
|
-
|
|
20515
|
-
|
|
20516
|
-
|
|
21721
|
+
if (blocks.some((block) => block.type === "image") && this.imageLoader !== void 0 && this.imageTheme !== void 0) {
|
|
21722
|
+
let buffer = "";
|
|
21723
|
+
const flush = () => {
|
|
21724
|
+
if (buffer !== "") {
|
|
21725
|
+
card.addChild(new Text(color.textDim(buffer), 0, 0));
|
|
21726
|
+
buffer = "";
|
|
21727
|
+
}
|
|
21728
|
+
};
|
|
21729
|
+
for (const block of blocks) if (block.type === "text") buffer += block.text;
|
|
21730
|
+
else if (block.type === "image") {
|
|
21731
|
+
flush();
|
|
21732
|
+
card.addChild(new ImageThumbnail(block.attachment, this.imageLoader, this.imageTheme));
|
|
21733
|
+
} else {
|
|
21734
|
+
flush();
|
|
21735
|
+
card.addChild(new Text(color.textDim(JSON.stringify(block, null, 2)), 0, 0));
|
|
21736
|
+
}
|
|
21737
|
+
flush();
|
|
21738
|
+
} else {
|
|
21739
|
+
const lines = blocks.length > 0 ? resultTextLines(blocks, message.status === "error" ? {
|
|
21740
|
+
name: "error",
|
|
21741
|
+
code: "tool"
|
|
21742
|
+
} : void 0) : [message.result];
|
|
21743
|
+
for (const line of lines) card.addChild(new Text(color.textDim(line), 0, 0));
|
|
21744
|
+
}
|
|
20517
21745
|
}
|
|
20518
21746
|
}
|
|
20519
21747
|
/**
|
|
21748
|
+
* Display form for a non-text block WITHOUT the image pipeline: image
|
|
21749
|
+
* blocks project to a compact placeholder — never a JSON dump (the
|
|
21750
|
+
* read_image envelope rule, review finding); everything else keeps the
|
|
21751
|
+
* legacy pretty-JSON form.
|
|
21752
|
+
*/
|
|
21753
|
+
blockDisplayText(block) {
|
|
21754
|
+
if (block.type === "image") return "[image]";
|
|
21755
|
+
return JSON.stringify(block, null, 2);
|
|
21756
|
+
}
|
|
21757
|
+
/** Append the result's IMAGE blocks as thumbnails (any tool card that
|
|
21758
|
+
* returns images but has its own summary path — read_image today, MCP
|
|
21759
|
+
* image results and screenshot tools next). No-op without the pipeline
|
|
21760
|
+
* or without image blocks. */
|
|
21761
|
+
renderResultImageBlocks(card, blocks) {
|
|
21762
|
+
if (blocks === void 0 || this.imageLoader === void 0 || this.imageTheme === void 0) return;
|
|
21763
|
+
for (const block of blocks) if (block.type === "image") card.addChild(new ImageThumbnail(block.attachment, this.imageLoader, this.imageTheme));
|
|
21764
|
+
}
|
|
21765
|
+
/**
|
|
20520
21766
|
* Fill a tool card with a diff body: LCS-aligned clustered lines, capped
|
|
20521
21767
|
* at {@link DIFF_PREVIEW_LINES} in the default recent-turn view and full
|
|
20522
21768
|
* when the card was explicitly expanded (kimi parity).
|
|
@@ -20857,7 +22103,7 @@ var TuiApp = class TuiApp {
|
|
|
20857
22103
|
const shownNotices = noticeRows.slice(0, TuiApp.MAX_NOTICE_ROWS);
|
|
20858
22104
|
for (const item of userRows) {
|
|
20859
22105
|
const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth("❯ ")), "…");
|
|
20860
|
-
lines.push(`${color.
|
|
22106
|
+
lines.push(`${color.roleUser("❯")} ${truncated}`);
|
|
20861
22107
|
}
|
|
20862
22108
|
for (const item of shownNotices) {
|
|
20863
22109
|
const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth("⏳ ")), "…");
|
|
@@ -20901,7 +22147,7 @@ var TuiApp = class TuiApp {
|
|
|
20901
22147
|
submitDraft(forceQueue = false) {
|
|
20902
22148
|
if (this.disposed) return;
|
|
20903
22149
|
const text = this.getDraft();
|
|
20904
|
-
if (text.trim() === "") return;
|
|
22150
|
+
if (text.trim() === "" && this.events.isImageDraft?.() !== true) return;
|
|
20905
22151
|
this.rememberInput(text);
|
|
20906
22152
|
this.clearNotify();
|
|
20907
22153
|
if (this.viewerMode === void 0) {
|
|
@@ -24675,6 +25921,500 @@ function createDiag(options = {}) {
|
|
|
24675
25921
|
};
|
|
24676
25922
|
}
|
|
24677
25923
|
//#endregion
|
|
25924
|
+
//#region src/image/admission.ts
|
|
25925
|
+
/** Total encoded bytes of the referenced images (preflight input). */
|
|
25926
|
+
function imageSegmentsBytes(segments) {
|
|
25927
|
+
let total = 0;
|
|
25928
|
+
for (const segment of segments) if (segment.type === "image") total += segment.image.byteLength;
|
|
25929
|
+
return total;
|
|
25930
|
+
}
|
|
25931
|
+
/**
|
|
25932
|
+
* Build the ordered `ContentBlock[]` for one draft (plan §11): every text
|
|
25933
|
+
* segment becomes a text block, every image segment an image block backed by
|
|
25934
|
+
* the durable refs (index-aligned with the image segments in order). Empty
|
|
25935
|
+
* text segments were already dropped by the placeholder expansion.
|
|
25936
|
+
* @param segments - the expanded draft segments.
|
|
25937
|
+
* @param refs - durable refs in image-segment order.
|
|
25938
|
+
*/
|
|
25939
|
+
function buildContentBlocks(segments, refs) {
|
|
25940
|
+
const blocks = [];
|
|
25941
|
+
let refIndex = 0;
|
|
25942
|
+
for (const segment of segments) {
|
|
25943
|
+
if (segment.type === "text") {
|
|
25944
|
+
if (segment.text !== "") blocks.push({
|
|
25945
|
+
type: "text",
|
|
25946
|
+
text: segment.text
|
|
25947
|
+
});
|
|
25948
|
+
continue;
|
|
25949
|
+
}
|
|
25950
|
+
const ref = refs[refIndex];
|
|
25951
|
+
if (ref === void 0) throw new ImageAdmissionError("An image draft could not be admitted (reference mismatch).");
|
|
25952
|
+
refIndex += 1;
|
|
25953
|
+
blocks.push({
|
|
25954
|
+
type: "image",
|
|
25955
|
+
attachment: ref
|
|
25956
|
+
});
|
|
25957
|
+
}
|
|
25958
|
+
return blocks;
|
|
25959
|
+
}
|
|
25960
|
+
/**
|
|
25961
|
+
* Admit every staged image of a draft (plan §10.2): one batched
|
|
25962
|
+
* `saveImages()` keeps input/ref ordering and avoids half-success semantics.
|
|
25963
|
+
* Count/aggregate preflights come from the LIVE `imageLimits`; the harness
|
|
25964
|
+
* re-validates bytes at admission — the TUI never duplicates normalization.
|
|
25965
|
+
* RECALLED images (pulled back from the queue) are ALREADY durable: their
|
|
25966
|
+
* ref is reused as-is and they are excluded from the save batch — the
|
|
25967
|
+
* harness object is content-addressed, so re-submitting the same ref never
|
|
25968
|
+
* duplicates storage (dequeue recall, review finding 3).
|
|
25969
|
+
* @param segments - the expanded draft segments.
|
|
25970
|
+
* @param attachments - the live `ctx.attachments` service.
|
|
25971
|
+
* @returns the admitted blocks and refs.
|
|
25972
|
+
*/
|
|
25973
|
+
async function admitDraftImages(segments, attachments) {
|
|
25974
|
+
const images = segments.filter((segment) => segment.type === "image");
|
|
25975
|
+
if (images.length === 0) return {
|
|
25976
|
+
blocks: buildContentBlocks(segments, []),
|
|
25977
|
+
refs: []
|
|
25978
|
+
};
|
|
25979
|
+
const limits = attachments.imageLimits;
|
|
25980
|
+
if (images.length > limits.maxImagesPerMessage) throw new ImageAdmissionError(`Too many images: ${images.length} attached, the current limit is ${limits.maxImagesPerMessage} per message.`);
|
|
25981
|
+
const aggregate = imageSegmentsBytes(images);
|
|
25982
|
+
if (aggregate > limits.maxMessageImageBytes) throw new ImageAdmissionError(`Images total ${aggregate} bytes; the current aggregate limit is ${limits.maxMessageImageBytes} bytes.`);
|
|
25983
|
+
const refs = new Array(images.length);
|
|
25984
|
+
const toSave = [];
|
|
25985
|
+
images.forEach((segment, index) => {
|
|
25986
|
+
const recalled = segment.image.recalledRef;
|
|
25987
|
+
if (recalled !== void 0) refs[index] = recalled;
|
|
25988
|
+
else toSave.push({
|
|
25989
|
+
input: {
|
|
25990
|
+
data: segment.image.bytes,
|
|
25991
|
+
mediaType: segment.image.mediaType,
|
|
25992
|
+
...segment.image.name !== void 0 ? { name: segment.image.name } : {}
|
|
25993
|
+
},
|
|
25994
|
+
index
|
|
25995
|
+
});
|
|
25996
|
+
});
|
|
25997
|
+
if (toSave.length > 0) {
|
|
25998
|
+
const saved = await attachments.saveImages(toSave.map((entry) => entry.input));
|
|
25999
|
+
if (saved.length !== toSave.length) throw new ImageAdmissionError(`The attachment service returned ${saved.length} references for ${toSave.length} images.`);
|
|
26000
|
+
toSave.forEach((entry, savedIndex) => {
|
|
26001
|
+
refs[entry.index] = saved[savedIndex];
|
|
26002
|
+
});
|
|
26003
|
+
}
|
|
26004
|
+
return {
|
|
26005
|
+
blocks: buildContentBlocks(segments, refs),
|
|
26006
|
+
refs
|
|
26007
|
+
};
|
|
26008
|
+
}
|
|
26009
|
+
//#endregion
|
|
26010
|
+
//#region src/image/capability.ts
|
|
26011
|
+
/**
|
|
26012
|
+
* Model image-capability gating (plan M5, §12).
|
|
26013
|
+
*
|
|
26014
|
+
* The decision comes from the CURRENT live provider/model via
|
|
26015
|
+
* `ctx.llm.resolveModelInfo()` — never a cached agent-startup model, because
|
|
26016
|
+
* the TUI supports runtime model switching (/model). Semantics:
|
|
26017
|
+
* - `inputModalities` ABSENT (undefined): capability unknown — do NOT
|
|
26018
|
+
* reject client-side; the harness admission remains the authority;
|
|
26019
|
+
* - explicitly present WITHOUT `'image'`: a declared text-only model —
|
|
26020
|
+
* reject with an actionable error, never silently drop images (§12).
|
|
26021
|
+
* @module @xmoon76/dsh-pi-tui/image/capability
|
|
26022
|
+
*/
|
|
26023
|
+
/**
|
|
26024
|
+
* Whether a resolved model can accept image input: unknown metadata is
|
|
26025
|
+
* treated as capable (the harness still re-checks at admission).
|
|
26026
|
+
*/
|
|
26027
|
+
function modelSupportsImages(info) {
|
|
26028
|
+
if (info.inputModalities === void 0) return true;
|
|
26029
|
+
return info.inputModalities.includes("image");
|
|
26030
|
+
}
|
|
26031
|
+
/**
|
|
26032
|
+
* Gate one submission against the CURRENT model's image capability.
|
|
26033
|
+
* @param llm - the live `ctx.llm` service (structural).
|
|
26034
|
+
* @param provider - the current provider route id.
|
|
26035
|
+
* @param model - the current model id.
|
|
26036
|
+
* @throws ModelImageUnsupportedError for a declared text-only model;
|
|
26037
|
+
* `resolveModelInfo` failures propagate (the submission path surfaces
|
|
26038
|
+
* them as actionable notices).
|
|
26039
|
+
*/
|
|
26040
|
+
async function assertModelSupportsImages(llm, provider, model) {
|
|
26041
|
+
if (!modelSupportsImages(await llm.resolveModelInfo(provider, model))) throw new ModelImageUnsupportedError(provider, model);
|
|
26042
|
+
}
|
|
26043
|
+
//#endregion
|
|
26044
|
+
//#region src/image/placeholder.ts
|
|
26045
|
+
/** The canonical editor placeholder for one draft image. */
|
|
26046
|
+
function formatImagePlaceholder(id, width, height) {
|
|
26047
|
+
return `[image #${id} (${width}×${height})]`;
|
|
26048
|
+
}
|
|
26049
|
+
/**
|
|
26050
|
+
* Expand the draft text into ordered segments: every canonical placeholder
|
|
26051
|
+
* whose draft is still staged becomes an image segment, everything else
|
|
26052
|
+
* stays text. Adjacent text segments are merged and empty text segments are
|
|
26053
|
+
* dropped, so a submission can map the segments one-to-one onto
|
|
26054
|
+
* `ContentBlock`s without re-normalizing whitespace (§6.2 items 4-5).
|
|
26055
|
+
* @param text - the editor draft.
|
|
26056
|
+
* @param store - the live draft store (structural; only `values()` is read).
|
|
26057
|
+
* @returns the segments in original order.
|
|
26058
|
+
*/
|
|
26059
|
+
function expandImagePlaceholders(text, store) {
|
|
26060
|
+
const candidates = store.values().map((image) => ({
|
|
26061
|
+
image,
|
|
26062
|
+
placeholder: image.placeholder
|
|
26063
|
+
}));
|
|
26064
|
+
if (candidates.length === 0) return text === "" ? [] : [{
|
|
26065
|
+
type: "text",
|
|
26066
|
+
text
|
|
26067
|
+
}];
|
|
26068
|
+
const segments = [];
|
|
26069
|
+
let cursor = 0;
|
|
26070
|
+
while (cursor < text.length) {
|
|
26071
|
+
let bestIndex = -1;
|
|
26072
|
+
let best;
|
|
26073
|
+
for (const candidate of candidates) {
|
|
26074
|
+
const index = text.indexOf(candidate.placeholder, cursor);
|
|
26075
|
+
if (index !== -1 && (best === void 0 || index < bestIndex)) {
|
|
26076
|
+
bestIndex = index;
|
|
26077
|
+
best = candidate.image;
|
|
26078
|
+
}
|
|
26079
|
+
}
|
|
26080
|
+
if (best === void 0) break;
|
|
26081
|
+
if (bestIndex > cursor) segments.push({
|
|
26082
|
+
type: "text",
|
|
26083
|
+
text: text.slice(cursor, bestIndex)
|
|
26084
|
+
});
|
|
26085
|
+
segments.push({
|
|
26086
|
+
type: "image",
|
|
26087
|
+
image: best
|
|
26088
|
+
});
|
|
26089
|
+
cursor = bestIndex + best.placeholder.length;
|
|
26090
|
+
}
|
|
26091
|
+
if (cursor < text.length) segments.push({
|
|
26092
|
+
type: "text",
|
|
26093
|
+
text: text.slice(cursor)
|
|
26094
|
+
});
|
|
26095
|
+
const merged = [];
|
|
26096
|
+
for (const segment of segments) {
|
|
26097
|
+
const previous = merged[merged.length - 1];
|
|
26098
|
+
if (segment.type === "text" && segment.text === "") continue;
|
|
26099
|
+
if (segment.type === "text" && previous !== void 0 && previous.type === "text") merged[merged.length - 1] = {
|
|
26100
|
+
type: "text",
|
|
26101
|
+
text: previous.text + segment.text
|
|
26102
|
+
};
|
|
26103
|
+
else merged.push(segment);
|
|
26104
|
+
}
|
|
26105
|
+
return merged;
|
|
26106
|
+
}
|
|
26107
|
+
//#endregion
|
|
26108
|
+
//#region src/image/submit.ts
|
|
26109
|
+
/**
|
|
26110
|
+
* Draft → UserMessage preparation for every agent-bound path (plan M6,
|
|
26111
|
+
* §13): followup, steer and queue all receive the SAME prepared message, so
|
|
26112
|
+
* no path can silently drop an `ImageBlock` (queue messages are durable
|
|
26113
|
+
* `UserMessage`s in the agent inbox — the queue never re-derives images
|
|
26114
|
+
* from drafts, §13.3).
|
|
26115
|
+
*
|
|
26116
|
+
* The pipeline: expand placeholders → (image present) model capability gate
|
|
26117
|
+
* → batched `saveImages()` → ordered ContentBlocks → `createUserMessage`.
|
|
26118
|
+
* A text-only draft keeps the exact legacy path (single text block, no
|
|
26119
|
+
* service calls), so the no-image fast path is unchanged.
|
|
26120
|
+
* @module @xmoon76/dsh-pi-tui/image/submit
|
|
26121
|
+
*/
|
|
26122
|
+
/** Whether the draft text references any staged image (the image-only
|
|
26123
|
+
* prompt gate shared by every submit path). */
|
|
26124
|
+
function draftHasImages(text, store) {
|
|
26125
|
+
return expandImagePlaceholders(text, store).some((segment) => segment.type === "image");
|
|
26126
|
+
}
|
|
26127
|
+
/**
|
|
26128
|
+
* Remove ONLY the drafts a submission actually consumed (plan §14): the
|
|
26129
|
+
* image ids referenced by the submitted text. Never a wholesale clear — a
|
|
26130
|
+
* concurrent /image or Ctrl+V intake racing a submission keeps its newly
|
|
26131
|
+
* staged draft (round-5 finding 1).
|
|
26132
|
+
*/
|
|
26133
|
+
function consumeDraftImages(text, store) {
|
|
26134
|
+
for (const segment of expandImagePlaceholders(text, store)) if (segment.type === "image") store.remove(segment.image.id);
|
|
26135
|
+
}
|
|
26136
|
+
/**
|
|
26137
|
+
* Drop every draft the CURRENT editor text no longer references (review
|
|
26138
|
+
* finding 2): deleting a placeholder (or Ctrl+C clearing the editor) leaves
|
|
26139
|
+
* the staged bytes in the store until capacity runs out — 16 stale
|
|
26140
|
+
* attachments then block the next /image with "Too many staged images".
|
|
26141
|
+
* Called BEFORE a new attach (the editor text at that moment is the truth
|
|
26142
|
+
* of what is still wanted). DRAFTS PINNED BY AN IN-FLIGHT SUBMISSION are
|
|
26143
|
+
* kept — the editor is cleared before dispatch, so an attach must never
|
|
26144
|
+
* delete the images a pending prepareUserMessage is about to admit
|
|
26145
|
+
* (review finding 1).
|
|
26146
|
+
*/
|
|
26147
|
+
function pruneUnreferencedDrafts(text, store) {
|
|
26148
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
26149
|
+
for (const segment of expandImagePlaceholders(text, store)) if (segment.type === "image") referenced.add(segment.image.id);
|
|
26150
|
+
for (const image of store.values()) if (!referenced.has(image.id) && !store.isPinned(image.id)) store.remove(image.id);
|
|
26151
|
+
}
|
|
26152
|
+
/**
|
|
26153
|
+
* Prepare the immutable `UserMessage` for one submission.
|
|
26154
|
+
* @param text - the editor draft text.
|
|
26155
|
+
* @param store - the live draft store.
|
|
26156
|
+
* @param deps - the service surface.
|
|
26157
|
+
* @returns the frozen user message.
|
|
26158
|
+
* @throws ImageAdmissionError when the deployment has no attachment service
|
|
26159
|
+
* but the draft references images; capability/admission errors otherwise.
|
|
26160
|
+
*/
|
|
26161
|
+
async function prepareUserMessage(text, store, deps) {
|
|
26162
|
+
const segments = expandImagePlaceholders(text, store);
|
|
26163
|
+
if (!segments.some((segment) => segment.type === "image")) return createUserMessage({
|
|
26164
|
+
content: [{
|
|
26165
|
+
type: "text",
|
|
26166
|
+
text
|
|
26167
|
+
}],
|
|
26168
|
+
source: { kind: "user" }
|
|
26169
|
+
});
|
|
26170
|
+
if (deps.attachments === void 0) throw new ImageAdmissionError("Image attachments are unavailable in this deployment.");
|
|
26171
|
+
const current = deps.currentModel();
|
|
26172
|
+
if (deps.llm !== void 0 && current !== void 0) await assertModelSupportsImages(deps.llm, current.provider, current.model);
|
|
26173
|
+
const admitted = await admitDraftImages(segments, deps.attachments);
|
|
26174
|
+
return createUserMessage({
|
|
26175
|
+
content: [...admitted.blocks],
|
|
26176
|
+
source: { kind: "user" }
|
|
26177
|
+
});
|
|
26178
|
+
}
|
|
26179
|
+
//#endregion
|
|
26180
|
+
//#region src/image/intake.ts
|
|
26181
|
+
/**
|
|
26182
|
+
* Image intake: file → validated draft bytes (plan M2, §7-§8).
|
|
26183
|
+
*
|
|
26184
|
+
* The intake is split into PURE functions (magic-byte sniffing, lightweight
|
|
26185
|
+
* header-only dimension parsing, limits preflight) and one IO function
|
|
26186
|
+
* (`readImageFile`: resolve → stat → read → sniff → parse → preflight).
|
|
26187
|
+
* The pure half is fully unit-testable without a filesystem; the IO half
|
|
26188
|
+
* stays narrow so the /image command handler and the headless tests can
|
|
26189
|
+
* drive it with real files.
|
|
26190
|
+
*
|
|
26191
|
+
* Design rules from the plan:
|
|
26192
|
+
* - extension is NEVER the MIME authority (magic bytes only, §7.2);
|
|
26193
|
+
* - stat BEFORE readFile so an over-limit file never enters memory (§7.3);
|
|
26194
|
+
* - only regular files are accepted (symlinks resolve to their target,
|
|
26195
|
+
* §7.1);
|
|
26196
|
+
* - the harness's `ctx.attachments.imageLimits` is the ONLY limit source —
|
|
26197
|
+
* nothing here hardcodes a deployment default (§21);
|
|
26198
|
+
* - metadata parsing is header-only; no image decoder is imported (§8).
|
|
26199
|
+
* @module @xmoon76/dsh-pi-tui/image/intake
|
|
26200
|
+
*/
|
|
26201
|
+
const PNG_SIGNATURE = [
|
|
26202
|
+
137,
|
|
26203
|
+
80,
|
|
26204
|
+
78,
|
|
26205
|
+
71,
|
|
26206
|
+
13,
|
|
26207
|
+
10,
|
|
26208
|
+
26,
|
|
26209
|
+
10
|
|
26210
|
+
];
|
|
26211
|
+
const JPEG_SIGNATURE = [
|
|
26212
|
+
255,
|
|
26213
|
+
216,
|
|
26214
|
+
255
|
|
26215
|
+
];
|
|
26216
|
+
/** True when `bytes` starts with every byte of `signature`. */
|
|
26217
|
+
function startsWith(bytes, signature) {
|
|
26218
|
+
if (bytes.length < signature.length) return false;
|
|
26219
|
+
for (let index = 0; index < signature.length; index += 1) if (bytes[index] !== signature[index]) return false;
|
|
26220
|
+
return true;
|
|
26221
|
+
}
|
|
26222
|
+
/** Detect the raster media type from magic bytes only. */
|
|
26223
|
+
function sniffMediaType(bytes) {
|
|
26224
|
+
if (startsWith(bytes, PNG_SIGNATURE)) return "image/png";
|
|
26225
|
+
if (startsWith(bytes, JPEG_SIGNATURE)) return "image/jpeg";
|
|
26226
|
+
if (bytes.length >= 6) {
|
|
26227
|
+
const signature = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]);
|
|
26228
|
+
if (signature === "GIF87a" || signature === "GIF89a") return "image/gif";
|
|
26229
|
+
}
|
|
26230
|
+
if (bytes.length >= 12) {
|
|
26231
|
+
const riff = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
|
|
26232
|
+
const webp = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
|
|
26233
|
+
if (riff === "RIFF" && webp === "WEBP") return "image/webp";
|
|
26234
|
+
}
|
|
26235
|
+
}
|
|
26236
|
+
function readUInt16BE(bytes, offset) {
|
|
26237
|
+
return bytes[offset] << 8 | bytes[offset + 1];
|
|
26238
|
+
}
|
|
26239
|
+
function readUInt16LE(bytes, offset) {
|
|
26240
|
+
return bytes[offset] | bytes[offset + 1] << 8;
|
|
26241
|
+
}
|
|
26242
|
+
function readUInt32BE(bytes, offset) {
|
|
26243
|
+
return (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
|
|
26244
|
+
}
|
|
26245
|
+
/** PNG: IHDR at offset 8; width/height are big-endian u32 at 16/20. */
|
|
26246
|
+
function pngDimensions(bytes) {
|
|
26247
|
+
if (bytes.length < 24) return void 0;
|
|
26248
|
+
if (String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]) !== "IHDR") return void 0;
|
|
26249
|
+
return {
|
|
26250
|
+
width: readUInt32BE(bytes, 16),
|
|
26251
|
+
height: readUInt32BE(bytes, 20)
|
|
26252
|
+
};
|
|
26253
|
+
}
|
|
26254
|
+
/**
|
|
26255
|
+
* JPEG dimensions from the SOFn marker scan (SOF0-15, excluding the
|
|
26256
|
+
* arithmetic/table markers C4/C8/CC). Height precedes width in the frame
|
|
26257
|
+
* header, both big-endian.
|
|
26258
|
+
*/
|
|
26259
|
+
function jpegDimensions(bytes) {
|
|
26260
|
+
if (bytes.length < 4) return void 0;
|
|
26261
|
+
let offset = 2;
|
|
26262
|
+
while (offset + 9 <= bytes.length) {
|
|
26263
|
+
if (bytes[offset] !== 255) {
|
|
26264
|
+
offset += 1;
|
|
26265
|
+
continue;
|
|
26266
|
+
}
|
|
26267
|
+
let marker = bytes[offset + 1];
|
|
26268
|
+
if (marker === 255) {
|
|
26269
|
+
offset += 1;
|
|
26270
|
+
continue;
|
|
26271
|
+
}
|
|
26272
|
+
if (marker === 216 || marker >= 208 && marker <= 215 || marker === 1) {
|
|
26273
|
+
offset += 2;
|
|
26274
|
+
continue;
|
|
26275
|
+
}
|
|
26276
|
+
const length = readUInt16BE(bytes, offset + 2);
|
|
26277
|
+
if (length < 2) return void 0;
|
|
26278
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
|
|
26279
|
+
if (length < 8 || offset + 2 + length > bytes.length) return void 0;
|
|
26280
|
+
return {
|
|
26281
|
+
height: readUInt16BE(bytes, offset + 5),
|
|
26282
|
+
width: readUInt16BE(bytes, offset + 7)
|
|
26283
|
+
};
|
|
26284
|
+
}
|
|
26285
|
+
offset += 2 + length;
|
|
26286
|
+
}
|
|
26287
|
+
}
|
|
26288
|
+
/** GIF logical screen descriptor: little-endian width/height at 6/8. */
|
|
26289
|
+
function gifDimensions(bytes) {
|
|
26290
|
+
if (bytes.length < 10) return void 0;
|
|
26291
|
+
return {
|
|
26292
|
+
width: readUInt16LE(bytes, 6),
|
|
26293
|
+
height: readUInt16LE(bytes, 8)
|
|
26294
|
+
};
|
|
26295
|
+
}
|
|
26296
|
+
/** WebP dimensions for the VP8 (lossy) / VP8L (lossless) / VP8X (extended)
|
|
26297
|
+
* chunk variants (the same offsets the vendored pi-tui fork parses). */
|
|
26298
|
+
function webpDimensions(bytes) {
|
|
26299
|
+
if (bytes.length < 30) return void 0;
|
|
26300
|
+
const chunk = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
|
|
26301
|
+
if (chunk === "VP8 ") return {
|
|
26302
|
+
width: readUInt16LE(bytes, 26) & 16383,
|
|
26303
|
+
height: readUInt16LE(bytes, 28) & 16383
|
|
26304
|
+
};
|
|
26305
|
+
if (chunk === "VP8L") {
|
|
26306
|
+
if (bytes.length < 25) return void 0;
|
|
26307
|
+
const bits = bytes[21] | bytes[22] << 8 | bytes[23] << 16 | bytes[24] << 24;
|
|
26308
|
+
return {
|
|
26309
|
+
width: (bits & 16383) + 1,
|
|
26310
|
+
height: (bits >>> 14 & 16383) + 1
|
|
26311
|
+
};
|
|
26312
|
+
}
|
|
26313
|
+
if (chunk === "VP8X") return {
|
|
26314
|
+
width: (bytes[24] | bytes[25] << 8 | bytes[26] << 16) + 1,
|
|
26315
|
+
height: (bytes[27] | bytes[28] << 8 | bytes[29] << 16) + 1
|
|
26316
|
+
};
|
|
26317
|
+
}
|
|
26318
|
+
/**
|
|
26319
|
+
* Parse raster metadata from raw bytes: magic-byte MIME detection plus
|
|
26320
|
+
* header-only dimension parsing. Returns undefined for non-images,
|
|
26321
|
+
* truncated/corrupt headers, or impossible dimensions (<= 0 or non-finite
|
|
26322
|
+
* — round-2 finding 7; the harness re-validates at admission).
|
|
26323
|
+
*/
|
|
26324
|
+
function parseImageMetadata(bytes) {
|
|
26325
|
+
const mediaType = sniffMediaType(bytes);
|
|
26326
|
+
if (mediaType === void 0) return void 0;
|
|
26327
|
+
const parsed = mediaType === "image/png" ? pngDimensions(bytes) : mediaType === "image/jpeg" ? jpegDimensions(bytes) : mediaType === "image/gif" ? gifDimensions(bytes) : webpDimensions(bytes);
|
|
26328
|
+
if (parsed === void 0) return void 0;
|
|
26329
|
+
if (!(parsed.width > 0) || !(parsed.height > 0) || !Number.isFinite(parsed.width) || !Number.isFinite(parsed.height)) return;
|
|
26330
|
+
return {
|
|
26331
|
+
mediaType,
|
|
26332
|
+
width: parsed.width,
|
|
26333
|
+
height: parsed.height
|
|
26334
|
+
};
|
|
26335
|
+
}
|
|
26336
|
+
/**
|
|
26337
|
+
* Preflight one parsed image against the deployment's attachment limits.
|
|
26338
|
+
* Throws the actionable intake errors; the harness re-checks at admission.
|
|
26339
|
+
* @param meta - sniffed metadata.
|
|
26340
|
+
* @param byteLength - exact encoded byte length.
|
|
26341
|
+
* @param limits - the deployment image policy (or undefined = no preflight).
|
|
26342
|
+
*/
|
|
26343
|
+
function checkImageLimits(meta, byteLength, limits) {
|
|
26344
|
+
if (limits === void 0) return;
|
|
26345
|
+
if (!limits.mediaTypes.includes(meta.mediaType)) throw new UnsupportedImageTypeError(`The deployment does not accept ${meta.mediaType}.`);
|
|
26346
|
+
if (byteLength > limits.maxImageBytes) throw new ImageTooLargeError(byteLength, limits.maxImageBytes);
|
|
26347
|
+
if (meta.width * meta.height > limits.maxImagePixels) throw new ImageDimensionError(`Image is ${meta.width}×${meta.height} (${meta.width * meta.height} px); the pixel limit is ${limits.maxImagePixels} px.`);
|
|
26348
|
+
if (meta.width > limits.maxImageDimension || meta.height > limits.maxImageDimension) throw new ImageDimensionError(`Image is ${meta.width}×${meta.height}; the dimension limit is ${limits.maxImageDimension} px per side.`);
|
|
26349
|
+
}
|
|
26350
|
+
/** Expand a leading `~` to the home directory (no other shell expansion). */
|
|
26351
|
+
function expandHome(raw) {
|
|
26352
|
+
if (raw === "~") return homedir();
|
|
26353
|
+
if (raw.startsWith("~/") || raw.startsWith("~\\")) return `${homedir()}${raw.slice(1)}`;
|
|
26354
|
+
return raw;
|
|
26355
|
+
}
|
|
26356
|
+
/**
|
|
26357
|
+
* Resolve one path argument to a canonical regular-file path.
|
|
26358
|
+
* @param raw - the raw argument (already shell-word parsed).
|
|
26359
|
+
* @param cwd - the TUI working directory for relative paths.
|
|
26360
|
+
* @returns the canonical real path.
|
|
26361
|
+
* @throws ImageInputError when the target is missing, unreadable, or not a
|
|
26362
|
+
* regular file (directory/FIFO/socket/device are rejected, §7.1). Error
|
|
26363
|
+
* messages name the BASENAME only — full private paths are never logged
|
|
26364
|
+
* (§21). ASYNC throughout (fs/promises) — the intake must never block the
|
|
26365
|
+
* TUI event loop on slow disks or NFS (review finding).
|
|
26366
|
+
*/
|
|
26367
|
+
async function resolveImagePath(raw, cwd) {
|
|
26368
|
+
const expanded = expandHome(raw);
|
|
26369
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
|
26370
|
+
const label = basename(absolute);
|
|
26371
|
+
let real;
|
|
26372
|
+
try {
|
|
26373
|
+
real = await realpath(absolute);
|
|
26374
|
+
} catch (error) {
|
|
26375
|
+
throw new ImageInputError(`Image file not found: ${label}`);
|
|
26376
|
+
}
|
|
26377
|
+
let stats;
|
|
26378
|
+
try {
|
|
26379
|
+
stats = await stat(real);
|
|
26380
|
+
} catch (error) {
|
|
26381
|
+
throw new ImageInputError(`Image file cannot be read: ${label}`);
|
|
26382
|
+
}
|
|
26383
|
+
if (!stats.isFile()) throw new ImageInputError(`Not a regular file: ${label}`);
|
|
26384
|
+
return real;
|
|
26385
|
+
}
|
|
26386
|
+
/**
|
|
26387
|
+
* Read one image file end to end: resolve → stat → size preflight → read →
|
|
26388
|
+
* sniff → parse → dimension preflight. Throws the actionable errors from
|
|
26389
|
+
* errors.ts; the caller stages the result into the draft store. Fully
|
|
26390
|
+
* async — never blocks the TUI event loop (review finding).
|
|
26391
|
+
* @param rawPath - the command argument (shell-word parsed already).
|
|
26392
|
+
* @param cwd - the working directory for relative paths.
|
|
26393
|
+
* @param limits - the deployment image policy (undefined = the TUI safety
|
|
26394
|
+
* cap applies instead of the attachment limit).
|
|
26395
|
+
* @param extraSafetyCap - an additional caller-side byte cap (e.g. the
|
|
26396
|
+
* draft store's remaining resident budget); the smallest of all caps
|
|
26397
|
+
* wins. Undefined = no extra cap.
|
|
26398
|
+
*/
|
|
26399
|
+
async function readImageFile(rawPath, cwd, limits, extraSafetyCap) {
|
|
26400
|
+
const path = await resolveImagePath(rawPath, cwd);
|
|
26401
|
+
const stats = await stat(path);
|
|
26402
|
+
const sizeCap = Math.min(limits?.maxImageBytes ?? 67108864, extraSafetyCap ?? 67108864);
|
|
26403
|
+
if (stats.size > sizeCap) throw new ImageTooLargeError(stats.size, sizeCap);
|
|
26404
|
+
const bytes = new Uint8Array(await readFile(path));
|
|
26405
|
+
const meta = parseImageMetadata(bytes);
|
|
26406
|
+
if (meta === void 0) throw new UnsupportedImageTypeError();
|
|
26407
|
+
checkImageLimits(meta, bytes.byteLength, limits);
|
|
26408
|
+
return {
|
|
26409
|
+
bytes,
|
|
26410
|
+
mediaType: meta.mediaType,
|
|
26411
|
+
width: meta.width,
|
|
26412
|
+
height: meta.height,
|
|
26413
|
+
name: basename(path),
|
|
26414
|
+
path
|
|
26415
|
+
};
|
|
26416
|
+
}
|
|
26417
|
+
//#endregion
|
|
24678
26418
|
//#region src/model-menu.ts
|
|
24679
26419
|
/**
|
|
24680
26420
|
* In-place submenu components for `/model`: the model list and the
|
|
@@ -25885,17 +27625,19 @@ function readProviderOptions(ctx) {
|
|
|
25885
27625
|
settingsPath: ["providers", option.label]
|
|
25886
27626
|
});
|
|
25887
27627
|
}
|
|
25888
|
-
/** Build the merged /login picker rows: reference targets
|
|
25889
|
-
* configured/available/custom groups
|
|
25890
|
-
*
|
|
25891
|
-
*
|
|
25892
|
-
*
|
|
27628
|
+
/** Build the merged /login picker rows: reference targets (the API-key
|
|
27629
|
+
* path) keep their configured/available/custom groups, prefixed with the
|
|
27630
|
+
* `API key` category so the two credential planes are visible at a glance
|
|
27631
|
+
* (the fork renders a non-interactive header row per group); authorization
|
|
27632
|
+
* targets (the provider sign-in path) get their own group, and the Add New
|
|
27633
|
+
* Platform action row is pinned last. Authorization row values carry the
|
|
27634
|
+
* AUTH_VALUE_PREFIX so they can never collide with a route. */
|
|
25893
27635
|
function mergedPickerRows(merged) {
|
|
25894
27636
|
const rows = [];
|
|
25895
27637
|
const groupLabels = {
|
|
25896
|
-
configured: "configured",
|
|
25897
|
-
available: "
|
|
25898
|
-
custom: "custom",
|
|
27638
|
+
configured: "API key · configured",
|
|
27639
|
+
available: "API key · available",
|
|
27640
|
+
custom: "API key · custom",
|
|
25899
27641
|
authorization: "sign in with provider"
|
|
25900
27642
|
};
|
|
25901
27643
|
for (const target of merged) if (target.kind === "reference") {
|
|
@@ -26185,7 +27927,7 @@ async function askAddProvider(ctx, app, signal, prefilledRoute) {
|
|
|
26185
27927
|
}
|
|
26186
27928
|
return {
|
|
26187
27929
|
kind: "ok",
|
|
26188
|
-
text: key === "" ? `provider ${routeValue} added (no key; provider-native authentication)` :
|
|
27930
|
+
text: key === "" ? `provider ${routeValue} added (no key; provider-native authentication)` : `API key ${ref} set · provider ${routeValue} added`
|
|
26189
27931
|
};
|
|
26190
27932
|
}
|
|
26191
27933
|
/**
|
|
@@ -26251,6 +27993,10 @@ function registerTuiCommands(runner, initial) {
|
|
|
26251
27993
|
};
|
|
26252
27994
|
/** The advertised names of the currently installed completion list. */
|
|
26253
27995
|
let claims = /* @__PURE__ */ new Set();
|
|
27996
|
+
/** Slash commands whose single argument is a path: the fork's
|
|
27997
|
+
* `getArgumentCompletions` extension point completes it against the LIVE
|
|
27998
|
+
* session cwd (natural typing shows candidates, Tab accepts them). */
|
|
27999
|
+
const PATH_ARGUMENT_COMMANDS = /* @__PURE__ */ new Set(["image"]);
|
|
26254
28000
|
/**
|
|
26255
28001
|
* Install one completion list (sorted, claims refreshed). The single
|
|
26256
28002
|
* synchronous seam every catalog commit funnels through.
|
|
@@ -26261,7 +28007,8 @@ function registerTuiCommands(runner, initial) {
|
|
|
26261
28007
|
app.setCommandCompletions(sorted.map((command) => ({
|
|
26262
28008
|
name: command.name,
|
|
26263
28009
|
description: command.description,
|
|
26264
|
-
argumentHint: command.input?.hint
|
|
28010
|
+
argumentHint: command.input?.hint,
|
|
28011
|
+
...PATH_ARGUMENT_COMMANDS.has(command.name) ? { getArgumentCompletions: (argument) => suggestPathArgument(argument, runner.sessionCwd()) } : {}
|
|
26265
28012
|
})), runner.sessionCwd(), fdPath, extensionAutocomplete === void 0 ? void 0 : async (query) => {
|
|
26266
28013
|
const result = await extensionAutocomplete.suggest(query, (id, error) => {
|
|
26267
28014
|
recordExtensionError?.("autocomplete", id, error);
|
|
@@ -26751,17 +28498,17 @@ function registerTuiCommands(runner, initial) {
|
|
|
26751
28498
|
text: `skill "${name}" returned a malformed definition`
|
|
26752
28499
|
};
|
|
26753
28500
|
const line = args.trim() === "" ? "/" + skill.name : "/" + skill.name + " " + args.trimStart();
|
|
26754
|
-
const
|
|
26755
|
-
|
|
26756
|
-
|
|
26757
|
-
|
|
26758
|
-
|
|
26759
|
-
|
|
26760
|
-
}
|
|
28501
|
+
const releasePin = runner.imageStore.pinReferenced(line);
|
|
28502
|
+
let userMessage;
|
|
28503
|
+
try {
|
|
28504
|
+
userMessage = await runner.prepareDraftMessage(line);
|
|
28505
|
+
agent.steer(userMessage);
|
|
28506
|
+
consumeDraftImages(line, runner.imageStore);
|
|
28507
|
+
} finally {
|
|
28508
|
+
releasePin();
|
|
28509
|
+
}
|
|
26761
28510
|
const hostSkillLoader = ctx.get("tools")?.get?.("skill", agent);
|
|
26762
|
-
|
|
26763
|
-
agent.steer(userMessage);
|
|
26764
|
-
if (!hostLoadsSkillBody) {
|
|
28511
|
+
if (!(hostSkillLoader !== void 0 && typeof hostSkillLoader.execute === "function")) {
|
|
26765
28512
|
const body = typeof skill.content === "string" && skill.content !== "" ? skill.content : skill.description;
|
|
26766
28513
|
const resourceBase = readResourceBase(skill.resourceBase);
|
|
26767
28514
|
agent.inject(createUserMessage({
|
|
@@ -27052,7 +28799,14 @@ function registerTuiCommands(runner, initial) {
|
|
|
27052
28799
|
setup: composition.setup
|
|
27053
28800
|
});
|
|
27054
28801
|
const error = await runner.swapTo(next);
|
|
27055
|
-
if (error !== void 0)
|
|
28802
|
+
if (error !== void 0) {
|
|
28803
|
+
app.notify(error, "error");
|
|
28804
|
+
return {
|
|
28805
|
+
kind: "error",
|
|
28806
|
+
text: error
|
|
28807
|
+
};
|
|
28808
|
+
}
|
|
28809
|
+
runner.imageStore.clearUnpinned();
|
|
27056
28810
|
return {
|
|
27057
28811
|
kind: "success",
|
|
27058
28812
|
text: "started a fresh session"
|
|
@@ -27380,6 +29134,60 @@ function registerTuiCommands(runner, initial) {
|
|
|
27380
29134
|
};
|
|
27381
29135
|
}
|
|
27382
29136
|
});
|
|
29137
|
+
commands.register({
|
|
29138
|
+
name: "image",
|
|
29139
|
+
description: "Attach an image file to the draft (tab completes the path; [image #N (W×H)] placeholder)",
|
|
29140
|
+
input: { hint: "<path>" },
|
|
29141
|
+
handler: (invocation) => {
|
|
29142
|
+
const words = parseShellWords(invocation.rawInput);
|
|
29143
|
+
if (words.length !== 1 || words[0] === "") return {
|
|
29144
|
+
kind: "error",
|
|
29145
|
+
text: "Usage: /image <path>"
|
|
29146
|
+
};
|
|
29147
|
+
const raw = words[0];
|
|
29148
|
+
const intakeGeneration = runner.sessionGeneration;
|
|
29149
|
+
const detach = (label, task) => {
|
|
29150
|
+
runDetached(label, task, {
|
|
29151
|
+
diag: runner.diag,
|
|
29152
|
+
sessionId: () => runner.liveAgent?.session.id,
|
|
29153
|
+
notify: (message) => app.notify(message, "error"),
|
|
29154
|
+
recoverable: () => true
|
|
29155
|
+
});
|
|
29156
|
+
};
|
|
29157
|
+
detach("image intake", () => {
|
|
29158
|
+
runOwned("image intake", () => {
|
|
29159
|
+
pruneUnreferencedDrafts(app.getDraft(), runner.imageStore);
|
|
29160
|
+
return readImageFile(raw, runner.sessionCwd(), runner.imageLimits(), runner.imageStore.remainingBytes()).then((resolved) => {
|
|
29161
|
+
if (runner.sessionGeneration !== intakeGeneration) {
|
|
29162
|
+
app.notify("the session changed while reading the image — try again", "error");
|
|
29163
|
+
return;
|
|
29164
|
+
}
|
|
29165
|
+
pruneUnreferencedDrafts(app.getDraft(), runner.imageStore);
|
|
29166
|
+
const draft = runner.imageStore.add({
|
|
29167
|
+
bytes: resolved.bytes,
|
|
29168
|
+
mediaType: resolved.mediaType,
|
|
29169
|
+
width: resolved.width,
|
|
29170
|
+
height: resolved.height,
|
|
29171
|
+
source: {
|
|
29172
|
+
type: "path",
|
|
29173
|
+
path: resolved.path
|
|
29174
|
+
},
|
|
29175
|
+
name: resolved.name
|
|
29176
|
+
});
|
|
29177
|
+
runner.insertIntoEditor(`${draft.placeholder} `);
|
|
29178
|
+
app.notify(`attached ${draft.placeholder} — Enter to send`);
|
|
29179
|
+
});
|
|
29180
|
+
}, {
|
|
29181
|
+
diag: runner.diag,
|
|
29182
|
+
sessionId: () => runner.liveAgent?.session.id,
|
|
29183
|
+
onError: (error) => {
|
|
29184
|
+
app.notify(safeErrorMessage(error), "error");
|
|
29185
|
+
}
|
|
29186
|
+
});
|
|
29187
|
+
});
|
|
29188
|
+
return { kind: "success" };
|
|
29189
|
+
}
|
|
29190
|
+
});
|
|
27383
29191
|
commands.register({
|
|
27384
29192
|
name: "export",
|
|
27385
29193
|
description: "Export this session log (JSONL by default, `md` for a readable transcript)",
|
|
@@ -27448,7 +29256,14 @@ function registerTuiCommands(runner, initial) {
|
|
|
27448
29256
|
seed
|
|
27449
29257
|
});
|
|
27450
29258
|
const error = await runner.swapTo(next);
|
|
27451
|
-
if (error !== void 0)
|
|
29259
|
+
if (error !== void 0) {
|
|
29260
|
+
app.notify(error, "error");
|
|
29261
|
+
return {
|
|
29262
|
+
kind: "error",
|
|
29263
|
+
text: error
|
|
29264
|
+
};
|
|
29265
|
+
}
|
|
29266
|
+
runner.imageStore.clearUnpinned();
|
|
27452
29267
|
return {
|
|
27453
29268
|
kind: "success",
|
|
27454
29269
|
text: `forked as ${next.agent.session.id}`
|
|
@@ -27492,7 +29307,7 @@ function registerTuiCommands(runner, initial) {
|
|
|
27492
29307
|
});
|
|
27493
29308
|
commands.register({
|
|
27494
29309
|
name: "login",
|
|
27495
|
-
description: "Sign in — deepseek official or an llm-pi-ai provider route
|
|
29310
|
+
description: "Sign in with a provider or set an API key — deepseek official or an llm-pi-ai provider route",
|
|
27496
29311
|
input: { hint: "[<route|env-var>]" },
|
|
27497
29312
|
handler: async (invocation) => {
|
|
27498
29313
|
const credentials = ctx.get("credentials");
|
|
@@ -27598,7 +29413,7 @@ function registerTuiCommands(runner, initial) {
|
|
|
27598
29413
|
try {
|
|
27599
29414
|
const key = (await app.askQuestions([{
|
|
27600
29415
|
id: "key",
|
|
27601
|
-
question: `
|
|
29416
|
+
question: `Enter the API key for ${label}:`,
|
|
27602
29417
|
masked: true
|
|
27603
29418
|
}]))[0]?.custom ?? "";
|
|
27604
29419
|
if (key === "") return {
|
|
@@ -27608,7 +29423,7 @@ function registerTuiCommands(runner, initial) {
|
|
|
27608
29423
|
await credentials.set(targetRef, key);
|
|
27609
29424
|
return {
|
|
27610
29425
|
kind: "success",
|
|
27611
|
-
text:
|
|
29426
|
+
text: `API key ${targetRef} set`
|
|
27612
29427
|
};
|
|
27613
29428
|
} catch {
|
|
27614
29429
|
return {
|
|
@@ -27647,7 +29462,7 @@ function registerTuiCommands(runner, initial) {
|
|
|
27647
29462
|
await credentials.unset(resolved);
|
|
27648
29463
|
return {
|
|
27649
29464
|
kind: "success",
|
|
27650
|
-
text:
|
|
29465
|
+
text: `API key ${resolved} cleared`
|
|
27651
29466
|
};
|
|
27652
29467
|
}
|
|
27653
29468
|
const flow = flowForRoute(targets, arg.toLowerCase());
|
|
@@ -27693,7 +29508,7 @@ function registerTuiCommands(runner, initial) {
|
|
|
27693
29508
|
await credentials.unset(targetRef);
|
|
27694
29509
|
return {
|
|
27695
29510
|
kind: "success",
|
|
27696
|
-
text:
|
|
29511
|
+
text: `API key ${targetRef} cleared`
|
|
27697
29512
|
};
|
|
27698
29513
|
}
|
|
27699
29514
|
});
|
|
@@ -27924,6 +29739,684 @@ function appendHistoryLine(file, text, lastContent) {
|
|
|
27924
29739
|
return true;
|
|
27925
29740
|
}
|
|
27926
29741
|
//#endregion
|
|
29742
|
+
//#region src/image/draft-store.ts
|
|
29743
|
+
/**
|
|
29744
|
+
* The per-TUI draft image registry (plan M1, §5.2).
|
|
29745
|
+
*
|
|
29746
|
+
* Holds staged clipboard/file bytes under numeric ids, mirroring kimi's
|
|
29747
|
+
* `ImageAttachmentStore` UX. The store is a MEMORY registry only: it never
|
|
29748
|
+
* writes durable attachments (that is `ctx.attachments.saveImages()`), and
|
|
29749
|
+
* `clear()` never touches anything the harness already accepted — a cleared
|
|
29750
|
+
* draft leaves durable refs unreachable-but-alive per harness retention
|
|
29751
|
+
* semantics (plan §14).
|
|
29752
|
+
*
|
|
29753
|
+
* Bounded (plan §21): a per-draft byte cap and an aggregate byte cap reject
|
|
29754
|
+
* new drafts before they are staged, so repeated `/image`/Ctrl+V pastes can
|
|
29755
|
+
* never pin unbounded memory in the TUI.
|
|
29756
|
+
* @module @xmoon76/dsh-pi-tui/image/draft-store
|
|
29757
|
+
*/
|
|
29758
|
+
/** In-memory draft registry. */
|
|
29759
|
+
var DraftImageStore = class {
|
|
29760
|
+
nextId = 1;
|
|
29761
|
+
images = /* @__PURE__ */ new Map();
|
|
29762
|
+
bytesHeld = 0;
|
|
29763
|
+
maxImages;
|
|
29764
|
+
maxBytes;
|
|
29765
|
+
/**
|
|
29766
|
+
* In-flight submission reservations (review finding 1): ids referenced by
|
|
29767
|
+
* a submission that STARTED but has not committed yet. The editor is
|
|
29768
|
+
* cleared before dispatch, so an attach-time prune would otherwise delete
|
|
29769
|
+
* the very drafts the in-flight prepareUserMessage still needs. A pinned
|
|
29770
|
+
* draft survives pruning until its submission settles.
|
|
29771
|
+
*/
|
|
29772
|
+
pins = /* @__PURE__ */ new Map();
|
|
29773
|
+
/**
|
|
29774
|
+
* TUI-OWNED memory protection, NOT an attachment limit: the caps bound
|
|
29775
|
+
* what the TUI stages in RAM before submission (review finding). The
|
|
29776
|
+
* harness's `imageLimits` (20 images / 200 MiB per message by default)
|
|
29777
|
+
* is a SEPARATE policy enforced at admission; a draft rejected here can
|
|
29778
|
+
* still be valid for the backend once submitted in smaller batches.
|
|
29779
|
+
* @param maxImages - entry cap (default 16).
|
|
29780
|
+
* @param maxBytes - aggregate encoded-byte cap (default 64 MiB). */
|
|
29781
|
+
constructor(maxImages = 16, maxBytes = 67108864) {
|
|
29782
|
+
this.maxImages = maxImages;
|
|
29783
|
+
this.maxBytes = maxBytes;
|
|
29784
|
+
}
|
|
29785
|
+
/** Stage one image and return its draft (ids increment from 1).
|
|
29786
|
+
* @throws ImageTooLargeError when the aggregate RESIDENT cap would be
|
|
29787
|
+
* exceeded; ImageAdmissionError when the entry cap is reached. A
|
|
29788
|
+
* RECALLED input (`recalledRef` present) carries no local bytes: its
|
|
29789
|
+
* LOGICAL size (the durable ref's recorded bytes) counts toward the
|
|
29790
|
+
* message aggregate, but its RESIDENT size is 0 — the bytes live in
|
|
29791
|
+
* harness storage, so the TUI RAM budget never counts them (review
|
|
29792
|
+
* finding 3). */
|
|
29793
|
+
add(input) {
|
|
29794
|
+
if (this.images.size >= this.maxImages) throw new ImageAdmissionError(`Too many staged images: the draft holds ${this.images.size} (limit ${this.maxImages}). Send or remove one first.`);
|
|
29795
|
+
const recalled = input.recalledRef;
|
|
29796
|
+
const bytes = input.bytes ?? /* @__PURE__ */ new Uint8Array(0);
|
|
29797
|
+
const logicalByteLength = recalled !== void 0 ? recalled.bytes : bytes.byteLength;
|
|
29798
|
+
const residentBytes = bytes.byteLength;
|
|
29799
|
+
if (this.bytesHeld + residentBytes > this.maxBytes) throw new ImageTooLargeError(residentBytes, this.maxBytes - this.bytesHeld);
|
|
29800
|
+
const id = this.nextId++;
|
|
29801
|
+
const image = {
|
|
29802
|
+
id,
|
|
29803
|
+
kind: "image",
|
|
29804
|
+
source: recalled !== void 0 ? { type: "recalled" } : input.source ?? { type: "clipboard" },
|
|
29805
|
+
bytes,
|
|
29806
|
+
mediaType: input.mediaType,
|
|
29807
|
+
width: input.width,
|
|
29808
|
+
height: input.height,
|
|
29809
|
+
byteLength: logicalByteLength,
|
|
29810
|
+
...input.name !== void 0 ? { name: input.name } : {},
|
|
29811
|
+
...recalled !== void 0 ? { recalledRef: recalled } : {},
|
|
29812
|
+
placeholder: formatImagePlaceholder(id, input.width, input.height)
|
|
29813
|
+
};
|
|
29814
|
+
this.images.set(id, image);
|
|
29815
|
+
this.bytesHeld += residentBytes;
|
|
29816
|
+
return image;
|
|
29817
|
+
}
|
|
29818
|
+
/** The staged draft for an id, or undefined when unknown/removed. */
|
|
29819
|
+
get(id) {
|
|
29820
|
+
return this.images.get(id);
|
|
29821
|
+
}
|
|
29822
|
+
/** Remove one draft; returns whether it was staged. */
|
|
29823
|
+
remove(id) {
|
|
29824
|
+
const image = this.images.get(id);
|
|
29825
|
+
if (image === void 0) return false;
|
|
29826
|
+
this.bytesHeld -= image.bytes.byteLength;
|
|
29827
|
+
return this.images.delete(id);
|
|
29828
|
+
}
|
|
29829
|
+
/** Drop every staged draft (submit, /clear, session switch, dispose). */
|
|
29830
|
+
/** Drop every staged draft (TUI dispose / full teardown). */
|
|
29831
|
+
clear() {
|
|
29832
|
+
this.images.clear();
|
|
29833
|
+
this.bytesHeld = 0;
|
|
29834
|
+
this.pins.clear();
|
|
29835
|
+
}
|
|
29836
|
+
/**
|
|
29837
|
+
* Drop every staged draft EXCEPT those reserved by an in-flight
|
|
29838
|
+
* submission (session switch, /new, /fork — review finding): a stale
|
|
29839
|
+
* submission that detects the switch still restores its editor text
|
|
29840
|
+
* (mergeDraft), and that text's placeholders must keep their backing
|
|
29841
|
+
* drafts until the submission's pin releases. Unpinned drafts go; the
|
|
29842
|
+
* pin map survives (released pins become prunable normally).
|
|
29843
|
+
*/
|
|
29844
|
+
clearUnpinned() {
|
|
29845
|
+
for (const [id, image] of this.images) {
|
|
29846
|
+
if (this.isPinned(id)) continue;
|
|
29847
|
+
this.bytesHeld -= image.bytes.byteLength;
|
|
29848
|
+
this.images.delete(id);
|
|
29849
|
+
}
|
|
29850
|
+
}
|
|
29851
|
+
/** All staged drafts in insertion order. */
|
|
29852
|
+
values() {
|
|
29853
|
+
return [...this.images.values()];
|
|
29854
|
+
}
|
|
29855
|
+
/** How many drafts are staged. */
|
|
29856
|
+
size() {
|
|
29857
|
+
return this.images.size;
|
|
29858
|
+
}
|
|
29859
|
+
/** Aggregate RESIDENT staged bytes (observability/tests; recalled
|
|
29860
|
+
* images hold no local bytes and count 0). */
|
|
29861
|
+
bytes() {
|
|
29862
|
+
return this.bytesHeld;
|
|
29863
|
+
}
|
|
29864
|
+
/** The remaining RESIDENT byte budget (review finding: used as the
|
|
29865
|
+
* intake's extra pre-read safety cap, so a file that could never fit the
|
|
29866
|
+
* draft store is refused before any read). */
|
|
29867
|
+
remainingBytes() {
|
|
29868
|
+
return Math.max(0, this.maxBytes - this.bytesHeld);
|
|
29869
|
+
}
|
|
29870
|
+
/**
|
|
29871
|
+
* Reserve every draft referenced by `text` for the duration of one
|
|
29872
|
+
* in-flight submission (review finding 1): the editor is cleared before
|
|
29873
|
+
* dispatch, so without a reservation an attach-time prune could delete
|
|
29874
|
+
* the drafts the submission is about to admit. The returned release
|
|
29875
|
+
* function MUST be called (idempotent; safe in finally).
|
|
29876
|
+
* @param text - the submitted text (placeholders already expanded).
|
|
29877
|
+
* @returns the release function.
|
|
29878
|
+
*/
|
|
29879
|
+
pinReferenced(text) {
|
|
29880
|
+
const ids = [];
|
|
29881
|
+
for (const segment of expandImagePlaceholders(text, this)) {
|
|
29882
|
+
if (segment.type !== "image") continue;
|
|
29883
|
+
const id = segment.image.id;
|
|
29884
|
+
if (!this.images.has(id)) continue;
|
|
29885
|
+
this.pins.set(id, (this.pins.get(id) ?? 0) + 1);
|
|
29886
|
+
ids.push(id);
|
|
29887
|
+
}
|
|
29888
|
+
let released = false;
|
|
29889
|
+
return () => {
|
|
29890
|
+
if (released) return;
|
|
29891
|
+
released = true;
|
|
29892
|
+
for (const id of ids) {
|
|
29893
|
+
const count = this.pins.get(id);
|
|
29894
|
+
if (count === void 0) continue;
|
|
29895
|
+
if (count <= 1) this.pins.delete(id);
|
|
29896
|
+
else this.pins.set(id, count - 1);
|
|
29897
|
+
}
|
|
29898
|
+
};
|
|
29899
|
+
}
|
|
29900
|
+
/** Whether a draft is reserved by an in-flight submission. */
|
|
29901
|
+
isPinned(id) {
|
|
29902
|
+
return (this.pins.get(id) ?? 0) > 0;
|
|
29903
|
+
}
|
|
29904
|
+
};
|
|
29905
|
+
//#endregion
|
|
29906
|
+
//#region src/image/clipboard.ts
|
|
29907
|
+
/**
|
|
29908
|
+
* Clipboard image reading (plan M3, §9).
|
|
29909
|
+
*
|
|
29910
|
+
* The paste action probes the platform clipboard ONCE, then: an image
|
|
29911
|
+
* becomes a draft (placeholder into the editor); plain text keeps the
|
|
29912
|
+
* ordinary paste behavior; "clipboard has no image" is NOT an error (a
|
|
29913
|
+
* text Ctrl+V must never pop "No image found" — §20). The platform matrix
|
|
29914
|
+
* follows the plan: Wayland `wl-paste`, X11 `xclip`, WSL/macOS/Windows
|
|
29915
|
+
* PowerShell+AppKit PNG round-trips, graceful unsupported elsewhere.
|
|
29916
|
+
*
|
|
29917
|
+
* Every platform runs through an injected `RunCommand`, so CI exercises
|
|
29918
|
+
* the decision trees with mocks (plan §26) and real machines verify one
|
|
29919
|
+
* native path.
|
|
29920
|
+
* @module @xmoon76/dsh-pi-tui/image/clipboard
|
|
29921
|
+
*/
|
|
29922
|
+
/**
|
|
29923
|
+
* Whether a command is available on $PATH (review finding): a bare
|
|
29924
|
+
* `existsSync(command)` only checks the CURRENT directory, so normal
|
|
29925
|
+
* Wayland/X11 installations would look "helper missing". Walks the PATH
|
|
29926
|
+
* entries; empty entries fall back to the CWD like a real shell.
|
|
29927
|
+
* - a command containing a path separator resolves DIRECTLY (shell lookup
|
|
29928
|
+
* semantics: `./wl-paste` never walks PATH);
|
|
29929
|
+
* - win32 PATH entries split on `;` ONLY — drive-letter colons (`C:\…`)
|
|
29930
|
+
* survive (follow-up finding); POSIX splits on `:`.
|
|
29931
|
+
*/
|
|
29932
|
+
function commandOnPath(command, pathVar, platform) {
|
|
29933
|
+
if (command.includes("/") || platform === "win32" && command.includes("\\")) return existsSync(command);
|
|
29934
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
29935
|
+
const dirs = (pathVar ?? "").split(separator);
|
|
29936
|
+
if (dirs.length === 0 || dirs.some((dir) => dir === "")) {
|
|
29937
|
+
if (existsSync(command)) return true;
|
|
29938
|
+
}
|
|
29939
|
+
for (const dir of dirs) {
|
|
29940
|
+
if (dir === "") continue;
|
|
29941
|
+
try {
|
|
29942
|
+
if (existsSync(join(dir, command))) return true;
|
|
29943
|
+
} catch {}
|
|
29944
|
+
}
|
|
29945
|
+
return false;
|
|
29946
|
+
}
|
|
29947
|
+
/** WSL detection: the interop marker env or the kernel banner. */
|
|
29948
|
+
function isWsl(env) {
|
|
29949
|
+
return env.env.WSL_DISTRO_NAME !== void 0 || env.env.WSL_INTEROP !== void 0 || env.env.WSLENV !== void 0;
|
|
29950
|
+
}
|
|
29951
|
+
/** Whether the termux environment is active. */
|
|
29952
|
+
function isTermux(env) {
|
|
29953
|
+
return env.env.TERMUX_VERSION !== void 0 || env.env.PREFIX === "/data/data/com.termux/files/usr";
|
|
29954
|
+
}
|
|
29955
|
+
/** Wait-free bounded run helper for clipboard commands. */
|
|
29956
|
+
async function runWithTimeout(run, command, args, timeoutMs) {
|
|
29957
|
+
const result = await run(command, args, { timeoutMs });
|
|
29958
|
+
if (result.code !== 0 && result.stdout.length === 0) throw new ClipboardImageError(`clipboard command failed: ${command} (exit ${result.code})`);
|
|
29959
|
+
return {
|
|
29960
|
+
stdout: result.stdout,
|
|
29961
|
+
code: result.code
|
|
29962
|
+
};
|
|
29963
|
+
}
|
|
29964
|
+
/** Parse the bytes once a binary clipboard payload arrives. */
|
|
29965
|
+
function imageFromBytes(bytes) {
|
|
29966
|
+
const meta = parseImageMetadata(bytes);
|
|
29967
|
+
if (meta === void 0) return {
|
|
29968
|
+
kind: "text",
|
|
29969
|
+
text: ""
|
|
29970
|
+
};
|
|
29971
|
+
return {
|
|
29972
|
+
kind: "image",
|
|
29973
|
+
bytes,
|
|
29974
|
+
mediaType: meta.mediaType,
|
|
29975
|
+
width: meta.width,
|
|
29976
|
+
height: meta.height
|
|
29977
|
+
};
|
|
29978
|
+
}
|
|
29979
|
+
async function waylandProbe(run) {
|
|
29980
|
+
const list = (await runWithTimeout(run, "wl-paste", ["--list-types"], 1500)).stdout.toString("utf8");
|
|
29981
|
+
if (!/[iI]mage\//.test(list)) return {
|
|
29982
|
+
kind: "text",
|
|
29983
|
+
text: (await runWithTimeout(run, "wl-paste", [], 1500)).stdout.toString("utf8")
|
|
29984
|
+
};
|
|
29985
|
+
for (const mediaType of [
|
|
29986
|
+
"image/png",
|
|
29987
|
+
"image/jpeg",
|
|
29988
|
+
"image/webp",
|
|
29989
|
+
"image/gif"
|
|
29990
|
+
]) {
|
|
29991
|
+
if (!list.includes(mediaType)) continue;
|
|
29992
|
+
const data = await runWithTimeout(run, "wl-paste", ["-t", mediaType], 2e3);
|
|
29993
|
+
if (data.stdout.length === 0) continue;
|
|
29994
|
+
return imageFromBytes(data.stdout);
|
|
29995
|
+
}
|
|
29996
|
+
const data = await runWithTimeout(run, "wl-paste", [], 2e3);
|
|
29997
|
+
if (data.stdout.length === 0) return {
|
|
29998
|
+
kind: "text",
|
|
29999
|
+
text: ""
|
|
30000
|
+
};
|
|
30001
|
+
return imageFromBytes(data.stdout);
|
|
30002
|
+
}
|
|
30003
|
+
async function x11Probe(run) {
|
|
30004
|
+
const list = (await runWithTimeout(run, "xclip", [
|
|
30005
|
+
"-selection",
|
|
30006
|
+
"clipboard",
|
|
30007
|
+
"-t",
|
|
30008
|
+
"TARGETS",
|
|
30009
|
+
"-o"
|
|
30010
|
+
], 1500)).stdout.toString("utf8");
|
|
30011
|
+
if (!/[mM]age\//.test(list)) return {
|
|
30012
|
+
kind: "text",
|
|
30013
|
+
text: (await runWithTimeout(run, "xclip", [
|
|
30014
|
+
"-selection",
|
|
30015
|
+
"clipboard",
|
|
30016
|
+
"-o"
|
|
30017
|
+
], 1500)).stdout.toString("utf8")
|
|
30018
|
+
};
|
|
30019
|
+
for (const mediaType of [
|
|
30020
|
+
"image/png",
|
|
30021
|
+
"image/jpeg",
|
|
30022
|
+
"image/webp",
|
|
30023
|
+
"image/gif"
|
|
30024
|
+
]) {
|
|
30025
|
+
if (!list.includes(mediaType)) continue;
|
|
30026
|
+
const data = await runWithTimeout(run, "xclip", [
|
|
30027
|
+
"-selection",
|
|
30028
|
+
"clipboard",
|
|
30029
|
+
"-t",
|
|
30030
|
+
mediaType,
|
|
30031
|
+
"-o"
|
|
30032
|
+
], 2e3);
|
|
30033
|
+
if (data.stdout.length === 0) continue;
|
|
30034
|
+
return imageFromBytes(data.stdout);
|
|
30035
|
+
}
|
|
30036
|
+
const data = await runWithTimeout(run, "xclip", [
|
|
30037
|
+
"-selection",
|
|
30038
|
+
"clipboard",
|
|
30039
|
+
"-o"
|
|
30040
|
+
], 2e3);
|
|
30041
|
+
if (data.stdout.length === 0) return {
|
|
30042
|
+
kind: "text",
|
|
30043
|
+
text: ""
|
|
30044
|
+
};
|
|
30045
|
+
return imageFromBytes(data.stdout);
|
|
30046
|
+
}
|
|
30047
|
+
/** macOS: AppKit NSPasteboard PNG → temp file. */
|
|
30048
|
+
async function macosProbe(run) {
|
|
30049
|
+
const file = join(tmpdir(), `dsh-clipboard-${randomUUID()}.png`);
|
|
30050
|
+
const osascript = [
|
|
30051
|
+
"use framework \"AppKit\"",
|
|
30052
|
+
`set thePasteboard to current application's NSPasteboard's generalPasteboard()`,
|
|
30053
|
+
`set theData to thePasteboard's dataForType:(current application's NSPasteboardTypePNG)`,
|
|
30054
|
+
"if theData is missing value then return \"\"",
|
|
30055
|
+
`set theFile to POSIX file "${file}"`,
|
|
30056
|
+
"theData's writeToFile:theFile atomically:true",
|
|
30057
|
+
"return \"ok\""
|
|
30058
|
+
].join("\n");
|
|
30059
|
+
try {
|
|
30060
|
+
const result = await run("osascript", ["-e", osascript], { timeoutMs: 5e3 });
|
|
30061
|
+
if (result.code !== 0) throw new ClipboardImageError(`osascript failed (exit ${result.code})`);
|
|
30062
|
+
let bytes;
|
|
30063
|
+
try {
|
|
30064
|
+
bytes = new Uint8Array(readFileSync(file));
|
|
30065
|
+
} catch {
|
|
30066
|
+
return {
|
|
30067
|
+
kind: "text",
|
|
30068
|
+
text: (await run("osascript", ["-e", "the clipboard as text"], { timeoutMs: 3e3 })).stdout.toString("utf8")
|
|
30069
|
+
};
|
|
30070
|
+
}
|
|
30071
|
+
return imageFromBytes(bytes);
|
|
30072
|
+
} finally {
|
|
30073
|
+
rmSync(file, { force: true });
|
|
30074
|
+
}
|
|
30075
|
+
}
|
|
30076
|
+
/** Windows / WSL: PowerShell clipboard → temp PNG. Under WSL the Linux
|
|
30077
|
+
* temp path is converted with `wslpath -w` first — PowerShell cannot
|
|
30078
|
+
* address `/tmp/...` directly (review finding 5). */
|
|
30079
|
+
async function powershellProbe(run, wsl) {
|
|
30080
|
+
const file = join(tmpdir(), `dsh-clipboard-${randomUUID()}.png`);
|
|
30081
|
+
let target = file;
|
|
30082
|
+
if (wsl) {
|
|
30083
|
+
const converted = await run("wslpath", ["-w", file], { timeoutMs: 2e3 });
|
|
30084
|
+
if (converted.code !== 0) throw new ClipboardImageError(`wslpath failed (exit ${converted.code})`);
|
|
30085
|
+
target = converted.stdout.toString("utf8").trim();
|
|
30086
|
+
}
|
|
30087
|
+
const script = [
|
|
30088
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
30089
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
30090
|
+
"if ($null -eq $img) { exit 0 }",
|
|
30091
|
+
`$img.Save('${target.replaceAll("'", "''")}')`
|
|
30092
|
+
].join("; ");
|
|
30093
|
+
try {
|
|
30094
|
+
const result = await run("powershell.exe", [
|
|
30095
|
+
"-NoProfile",
|
|
30096
|
+
"-Command",
|
|
30097
|
+
script
|
|
30098
|
+
], { timeoutMs: 8e3 });
|
|
30099
|
+
if (result.code !== 0) throw new ClipboardImageError(`powershell failed (exit ${result.code})`);
|
|
30100
|
+
let bytes;
|
|
30101
|
+
try {
|
|
30102
|
+
bytes = new Uint8Array(readFileSync(file));
|
|
30103
|
+
} catch {
|
|
30104
|
+
return {
|
|
30105
|
+
kind: "text",
|
|
30106
|
+
text: (await run("powershell.exe", [
|
|
30107
|
+
"-NoProfile",
|
|
30108
|
+
"-Command",
|
|
30109
|
+
"Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::GetText()"
|
|
30110
|
+
], { timeoutMs: 3e3 })).stdout.toString("utf8")
|
|
30111
|
+
};
|
|
30112
|
+
}
|
|
30113
|
+
return imageFromBytes(bytes);
|
|
30114
|
+
} finally {
|
|
30115
|
+
rmSync(file, { force: true });
|
|
30116
|
+
}
|
|
30117
|
+
}
|
|
30118
|
+
/**
|
|
30119
|
+
* Probe the clipboard for an image. Never throws for "no image": plain
|
|
30120
|
+
* text and empty clipboards resolve to `{ kind: 'text' }`; platform
|
|
30121
|
+
* failures throw {@link ClipboardImageError} (a clipboard that DOES carry
|
|
30122
|
+
* an image but cannot be read must be distinguished from no image).
|
|
30123
|
+
*/
|
|
30124
|
+
async function readClipboardImage(run, env) {
|
|
30125
|
+
if (isTermux(env)) return { kind: "unsupported" };
|
|
30126
|
+
if (env.platform === "darwin") return macosProbe(run);
|
|
30127
|
+
if (env.platform === "win32") return powershellProbe(run, false);
|
|
30128
|
+
if (isWsl(env)) return powershellProbe(run, true);
|
|
30129
|
+
if (env.env.WAYLAND_DISPLAY !== void 0 && env.exists("wl-paste")) return waylandProbe(run);
|
|
30130
|
+
if (env.env.DISPLAY !== void 0 && env.exists("xclip")) return x11Probe(run);
|
|
30131
|
+
return { kind: "unsupported" };
|
|
30132
|
+
}
|
|
30133
|
+
//#endregion
|
|
30134
|
+
//#region src/image/cache.ts
|
|
30135
|
+
/** Bounded LRU image cache. */
|
|
30136
|
+
var ImageCache = class {
|
|
30137
|
+
entries = /* @__PURE__ */ new Map();
|
|
30138
|
+
bytesHeld = 0;
|
|
30139
|
+
maxEntries;
|
|
30140
|
+
maxBytes;
|
|
30141
|
+
constructor(maxEntries = 32, maxBytes = 67108864) {
|
|
30142
|
+
this.maxEntries = maxEntries;
|
|
30143
|
+
this.maxBytes = maxBytes;
|
|
30144
|
+
}
|
|
30145
|
+
/** The cached entry for an attachment id, or undefined. */
|
|
30146
|
+
get(attachmentId) {
|
|
30147
|
+
const entry = this.entries.get(attachmentId);
|
|
30148
|
+
if (entry === void 0) return void 0;
|
|
30149
|
+
this.entries.delete(attachmentId);
|
|
30150
|
+
this.entries.set(attachmentId, entry);
|
|
30151
|
+
return entry;
|
|
30152
|
+
}
|
|
30153
|
+
/** Store one entry, evicting least-recently-used entries past the caps. */
|
|
30154
|
+
set(attachmentId, entry) {
|
|
30155
|
+
const existing = this.entries.get(attachmentId);
|
|
30156
|
+
if (existing !== void 0) this.bytesHeld -= entryBytes(existing);
|
|
30157
|
+
this.entries.delete(attachmentId);
|
|
30158
|
+
this.entries.set(attachmentId, entry);
|
|
30159
|
+
this.bytesHeld += entryBytes(entry);
|
|
30160
|
+
this.evict();
|
|
30161
|
+
}
|
|
30162
|
+
/** Whether the cache holds a ready entry for an attachment id. */
|
|
30163
|
+
has(attachmentId) {
|
|
30164
|
+
return this.entries.has(attachmentId);
|
|
30165
|
+
}
|
|
30166
|
+
/** Drop one attachment (transcript trim / explicit invalidation). */
|
|
30167
|
+
delete(attachmentId) {
|
|
30168
|
+
const entry = this.entries.get(attachmentId);
|
|
30169
|
+
if (entry !== void 0) {
|
|
30170
|
+
this.bytesHeld -= entryBytes(entry);
|
|
30171
|
+
this.entries.delete(attachmentId);
|
|
30172
|
+
}
|
|
30173
|
+
}
|
|
30174
|
+
/** Drop everything (session switch / TUI dispose). */
|
|
30175
|
+
clear() {
|
|
30176
|
+
this.entries.clear();
|
|
30177
|
+
this.bytesHeld = 0;
|
|
30178
|
+
}
|
|
30179
|
+
/** Current entry count (observability/tests). */
|
|
30180
|
+
size() {
|
|
30181
|
+
return this.entries.size;
|
|
30182
|
+
}
|
|
30183
|
+
/** Current held bytes (observability/tests). */
|
|
30184
|
+
bytes() {
|
|
30185
|
+
return this.bytesHeld;
|
|
30186
|
+
}
|
|
30187
|
+
/** Enforce the caps from the NEWEST slot backwards. */
|
|
30188
|
+
evict() {
|
|
30189
|
+
while (this.entries.size > this.maxEntries || this.bytesHeld > this.maxBytes) {
|
|
30190
|
+
const oldest = this.entries.keys().next().value;
|
|
30191
|
+
if (oldest === void 0) return;
|
|
30192
|
+
const entry = this.entries.get(oldest);
|
|
30193
|
+
if (entry !== void 0) this.bytesHeld -= entryBytes(entry);
|
|
30194
|
+
this.entries.delete(oldest);
|
|
30195
|
+
}
|
|
30196
|
+
}
|
|
30197
|
+
};
|
|
30198
|
+
function entryBytes(entry) {
|
|
30199
|
+
return entry.state === "ready" ? entry.byteLength + Math.ceil(entry.byteLength * 1.33) : 0;
|
|
30200
|
+
}
|
|
30201
|
+
//#endregion
|
|
30202
|
+
//#region src/image/loader.ts
|
|
30203
|
+
/**
|
|
30204
|
+
* Durable image loading for the transcript (plan M8, §16).
|
|
30205
|
+
*
|
|
30206
|
+
* History images come ONLY from `ctx.attachments.readImage(ref)` — never
|
|
30207
|
+
* from the draft store (§16). The loader is the async bridge a render
|
|
30208
|
+
* component consults synchronously:
|
|
30209
|
+
*
|
|
30210
|
+
* - `get(ref)` returns the CURRENT state (idle/loading/ready/error) without
|
|
30211
|
+
* awaiting — render methods cannot await (plan §16.1);
|
|
30212
|
+
* - `load(ref)` fires the underlying read ONCE per attachment id, deduping
|
|
30213
|
+
* concurrent loads from multiple components (§16.2);
|
|
30214
|
+
* - subscribers are notified after every settle so components can
|
|
30215
|
+
* invalidate and repaint with the resolved bytes.
|
|
30216
|
+
* @module @xmoon76/dsh-pi-tui/image/loader
|
|
30217
|
+
*/
|
|
30218
|
+
/** Cap on retained failure records (round-2 finding 3): failures are
|
|
30219
|
+
* diagnostic, never a growing leak on long transcripts. */
|
|
30220
|
+
const MAX_ERROR_ENTRIES = 64;
|
|
30221
|
+
/** The async image loader with concurrent-load dedupe + subscriber notify. */
|
|
30222
|
+
var ImageLoader = class {
|
|
30223
|
+
cache;
|
|
30224
|
+
inflight = /* @__PURE__ */ new Map();
|
|
30225
|
+
errors = /* @__PURE__ */ new Map();
|
|
30226
|
+
/** Per-attachment listeners: a settle notifies ONLY the attachments that
|
|
30227
|
+
* care, so N thumbnails loading in parallel never invalidate each other
|
|
30228
|
+
* (review finding 8 — no O(N²) repaint churn). */
|
|
30229
|
+
listeners = /* @__PURE__ */ new Map();
|
|
30230
|
+
read;
|
|
30231
|
+
/**
|
|
30232
|
+
* Invalidation generations (review finding 3): `invalidate(id)` bumps
|
|
30233
|
+
* ONLY that attachment's local generation, so a settle of an unrelated
|
|
30234
|
+
* in-flight read is never discarded. `clear()` bumps the GLOBAL
|
|
30235
|
+
* generation AND resets the per-id map — every in-flight settle is stale
|
|
30236
|
+
* after a session switch, including ones that had been locally
|
|
30237
|
+
* invalidated before (a per-id override must never let a pre-clear read
|
|
30238
|
+
* pass the global check; review finding: composite-single-number epochs
|
|
30239
|
+
* were unsound).
|
|
30240
|
+
*/
|
|
30241
|
+
globalEpoch = 0;
|
|
30242
|
+
perIdEpoch = /* @__PURE__ */ new Map();
|
|
30243
|
+
/** The epoch one read must match at settle time (a binary snapshot). */
|
|
30244
|
+
epochOf(id) {
|
|
30245
|
+
return {
|
|
30246
|
+
global: this.globalEpoch,
|
|
30247
|
+
local: this.perIdEpoch.get(id) ?? 0
|
|
30248
|
+
};
|
|
30249
|
+
}
|
|
30250
|
+
/** Whether a settle with the captured epoch is still current. */
|
|
30251
|
+
epochCurrent(id, captured) {
|
|
30252
|
+
return this.globalEpoch === captured.global && (this.perIdEpoch.get(id) ?? 0) === captured.local;
|
|
30253
|
+
}
|
|
30254
|
+
constructor(read, cache = new ImageCache()) {
|
|
30255
|
+
this.read = read;
|
|
30256
|
+
this.cache = cache;
|
|
30257
|
+
}
|
|
30258
|
+
/** The synchronous state view for one ref (never awaits). */
|
|
30259
|
+
get(ref) {
|
|
30260
|
+
const id = ref.attachmentId;
|
|
30261
|
+
const cached = this.cache.get(id);
|
|
30262
|
+
if (cached !== void 0) {
|
|
30263
|
+
if (cached.state === "ready") return {
|
|
30264
|
+
state: "ready",
|
|
30265
|
+
bytes: cached.bytes,
|
|
30266
|
+
base64: cached.base64
|
|
30267
|
+
};
|
|
30268
|
+
return {
|
|
30269
|
+
state: "error",
|
|
30270
|
+
error: cached.error
|
|
30271
|
+
};
|
|
30272
|
+
}
|
|
30273
|
+
const failed = this.errors.get(id);
|
|
30274
|
+
if (failed !== void 0) return {
|
|
30275
|
+
state: "error",
|
|
30276
|
+
error: failed
|
|
30277
|
+
};
|
|
30278
|
+
if (this.inflight.has(id)) return { state: "loading" };
|
|
30279
|
+
return { state: "idle" };
|
|
30280
|
+
}
|
|
30281
|
+
/** Whether a ref is fully resolved (cache hit). */
|
|
30282
|
+
isReady(ref) {
|
|
30283
|
+
return this.cache.has(ref.attachmentId);
|
|
30284
|
+
}
|
|
30285
|
+
/**
|
|
30286
|
+
* Fire the async load for a ref (no-op when already loading/ready). One
|
|
30287
|
+
* underlying `readImage` per attachment id: concurrent callers share the
|
|
30288
|
+
* in-flight promise. On settle, the cache/error maps update and every
|
|
30289
|
+
* subscriber is notified.
|
|
30290
|
+
*/
|
|
30291
|
+
load(ref) {
|
|
30292
|
+
const id = ref.attachmentId;
|
|
30293
|
+
if (this.cache.has(id) || this.inflight.has(id)) return;
|
|
30294
|
+
const epoch = this.epochOf(id);
|
|
30295
|
+
const pending = Promise.resolve().then(() => this.read(ref)).then((stored) => {
|
|
30296
|
+
if (!this.epochCurrent(id, epoch)) return stored;
|
|
30297
|
+
const data = stored.data;
|
|
30298
|
+
this.cache.set(id, {
|
|
30299
|
+
state: "ready",
|
|
30300
|
+
bytes: data,
|
|
30301
|
+
base64: bytesToBase64(data),
|
|
30302
|
+
byteLength: data.byteLength
|
|
30303
|
+
});
|
|
30304
|
+
this.errors.delete(id);
|
|
30305
|
+
return stored;
|
|
30306
|
+
}).catch((error) => {
|
|
30307
|
+
if (!this.epochCurrent(id, epoch)) return { data: /* @__PURE__ */ new Uint8Array(0) };
|
|
30308
|
+
this.recordError(id, error instanceof Error ? error : new ImageLoadError(String(error)));
|
|
30309
|
+
return { data: /* @__PURE__ */ new Uint8Array(0) };
|
|
30310
|
+
}).finally(() => {
|
|
30311
|
+
this.inflight.delete(id);
|
|
30312
|
+
this.notify(id);
|
|
30313
|
+
});
|
|
30314
|
+
this.inflight.set(id, pending);
|
|
30315
|
+
}
|
|
30316
|
+
/** Record one load failure, bounding the error map (round-2 finding 3). */
|
|
30317
|
+
recordError(id, error) {
|
|
30318
|
+
this.errors.set(id, error);
|
|
30319
|
+
if (this.errors.size > MAX_ERROR_ENTRIES) {
|
|
30320
|
+
const oldest = this.errors.keys().next().value;
|
|
30321
|
+
if (oldest !== void 0) this.errors.delete(oldest);
|
|
30322
|
+
}
|
|
30323
|
+
}
|
|
30324
|
+
/**
|
|
30325
|
+
* Subscribe to one attachment's settles; returns the unsubscribe
|
|
30326
|
+
* function. A settle notifies ONLY its attachment's listeners — N
|
|
30327
|
+
* thumbnails loading in parallel never invalidate each other (review
|
|
30328
|
+
* finding 8). `clear()` still broadcasts to every subscriber.
|
|
30329
|
+
*/
|
|
30330
|
+
subscribe(attachmentId, listener) {
|
|
30331
|
+
let set = this.listeners.get(attachmentId);
|
|
30332
|
+
if (set === void 0) {
|
|
30333
|
+
set = /* @__PURE__ */ new Set();
|
|
30334
|
+
this.listeners.set(attachmentId, set);
|
|
30335
|
+
}
|
|
30336
|
+
set.add(listener);
|
|
30337
|
+
return () => {
|
|
30338
|
+
const owned = this.listeners.get(attachmentId);
|
|
30339
|
+
if (owned === void 0) return;
|
|
30340
|
+
owned.delete(listener);
|
|
30341
|
+
if (owned.size === 0) this.listeners.delete(attachmentId);
|
|
30342
|
+
};
|
|
30343
|
+
}
|
|
30344
|
+
/** Drop one attachment's cached state (transcript trim). In-flight reads
|
|
30345
|
+
* for THIS attachment settle into the void (its per-id generation bumps);
|
|
30346
|
+
* unrelated in-flight reads keep their generations and settle normally
|
|
30347
|
+
* (review finding 3). */
|
|
30348
|
+
invalidate(attachmentId) {
|
|
30349
|
+
this.perIdEpoch.set(attachmentId, this.epochOf(attachmentId).local + 1);
|
|
30350
|
+
this.cache.delete(attachmentId);
|
|
30351
|
+
this.errors.delete(attachmentId);
|
|
30352
|
+
}
|
|
30353
|
+
/** Drop everything (session switch / dispose): the GLOBAL generation
|
|
30354
|
+
* bumps AND the per-id map resets — a pre-clear local invalidation can
|
|
30355
|
+
* never mask the global invalidation for a later settle (review
|
|
30356
|
+
* finding), and the map cannot grow unboundedly. Every subscriber hears
|
|
30357
|
+
* the global invalidation and repaints once. */
|
|
30358
|
+
clear() {
|
|
30359
|
+
this.globalEpoch += 1;
|
|
30360
|
+
this.perIdEpoch.clear();
|
|
30361
|
+
this.cache.clear();
|
|
30362
|
+
this.errors.clear();
|
|
30363
|
+
this.notifyAll();
|
|
30364
|
+
}
|
|
30365
|
+
/** Current cache size (observability/tests). */
|
|
30366
|
+
cacheSize() {
|
|
30367
|
+
return this.cache.size();
|
|
30368
|
+
}
|
|
30369
|
+
/** Current subscriber count (observability/tests). */
|
|
30370
|
+
listenerCount() {
|
|
30371
|
+
let total = 0;
|
|
30372
|
+
for (const set of this.listeners.values()) total += set.size;
|
|
30373
|
+
return total;
|
|
30374
|
+
}
|
|
30375
|
+
/** Notify the listeners of ONE attachment (settle fan-out). */
|
|
30376
|
+
notify(attachmentId) {
|
|
30377
|
+
const set = this.listeners.get(attachmentId);
|
|
30378
|
+
if (set === void 0) return;
|
|
30379
|
+
for (const listener of set) try {
|
|
30380
|
+
listener();
|
|
30381
|
+
} catch {}
|
|
30382
|
+
}
|
|
30383
|
+
/** Notify every subscriber (global invalidation). */
|
|
30384
|
+
notifyAll() {
|
|
30385
|
+
for (const set of this.listeners.values()) for (const listener of set) try {
|
|
30386
|
+
listener();
|
|
30387
|
+
} catch {}
|
|
30388
|
+
}
|
|
30389
|
+
};
|
|
30390
|
+
/** Base64 of a byte buffer (the pi-tui Image component's input). */
|
|
30391
|
+
function bytesToBase64(bytes) {
|
|
30392
|
+
let binary = "";
|
|
30393
|
+
const chunk = 32768;
|
|
30394
|
+
for (let index = 0; index < bytes.length; index += chunk) binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
|
|
30395
|
+
return btoa(binary);
|
|
30396
|
+
}
|
|
30397
|
+
//#endregion
|
|
30398
|
+
//#region src/image/submit-flow.ts
|
|
30399
|
+
/**
|
|
30400
|
+
* Run one pinned submission end to end. The reservation is established
|
|
30401
|
+
* FIRST (the caller must call this synchronously, before any await — the
|
|
30402
|
+
* editor was already cleared by the submit path); on failure the editor
|
|
30403
|
+
* is restored BEFORE the reservation releases; the release always runs.
|
|
30404
|
+
* @returns when the submission committed (or was aborted normally).
|
|
30405
|
+
* @throws the run failure after the restore (the caller's runOwned
|
|
30406
|
+
* onError then notifies — never a second restore).
|
|
30407
|
+
*/
|
|
30408
|
+
async function runReservedSubmit(flow, text) {
|
|
30409
|
+
const release = flow.reserve(text);
|
|
30410
|
+
try {
|
|
30411
|
+
await flow.run(text);
|
|
30412
|
+
} catch (error) {
|
|
30413
|
+
flow.restore(text);
|
|
30414
|
+
throw error;
|
|
30415
|
+
} finally {
|
|
30416
|
+
release();
|
|
30417
|
+
}
|
|
30418
|
+
}
|
|
30419
|
+
//#endregion
|
|
27927
30420
|
//#region src/dsh-version.ts
|
|
27928
30421
|
/**
|
|
27929
30422
|
* The installed dsh (DeepSeek Harness CLI) version, resolved from the
|
|
@@ -28425,7 +30918,7 @@ async function steerAll(deps, text, options = {}) {
|
|
|
28425
30918
|
return "ok";
|
|
28426
30919
|
}
|
|
28427
30920
|
const draft = text.trim();
|
|
28428
|
-
const messages = [...current, ...draft === "" ? [] : [deps.createDraft(
|
|
30921
|
+
const messages = [...current, ...draft === "" ? [] : [deps.createDraft(text)]];
|
|
28429
30922
|
for (const message of current) now.inbox.remove(message.id);
|
|
28430
30923
|
for (const message of messages) now.steer(message);
|
|
28431
30924
|
if (!forced) deps.notify(messages.length === 1 ? "steering 1 message" : `steering ${messages.length} messages`, "info");
|
|
@@ -29240,6 +31733,7 @@ const SESSIONLESS_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
29240
31733
|
"exit",
|
|
29241
31734
|
"settings",
|
|
29242
31735
|
"help",
|
|
31736
|
+
"image",
|
|
29243
31737
|
"login",
|
|
29244
31738
|
"logout",
|
|
29245
31739
|
"model",
|
|
@@ -29269,6 +31763,7 @@ const LOCAL_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
29269
31763
|
"export",
|
|
29270
31764
|
"fork",
|
|
29271
31765
|
"help",
|
|
31766
|
+
"image",
|
|
29272
31767
|
"kill",
|
|
29273
31768
|
"login",
|
|
29274
31769
|
"logout",
|
|
@@ -29290,6 +31785,23 @@ const LOCAL_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
29290
31785
|
"yolo"
|
|
29291
31786
|
]);
|
|
29292
31787
|
/**
|
|
31788
|
+
* Command semantics matrix (plan §19.3/M12): a LOCAL command line carrying a
|
|
31789
|
+
* staged image placeholder is REJECTED — local commands are pure UI
|
|
31790
|
+
* controls, never LLM prompts. AGENT-FACING input (plain prompts AND
|
|
31791
|
+
* per-skill slash invocations like `/grilling`) SUPPORTS images: the skill
|
|
31792
|
+
* wrapper builds its message through the same prepared-input path, so an
|
|
31793
|
+
* image-bearing skill line is a real multimodal prompt (review finding 4).
|
|
31794
|
+
* There is never a silent drop.
|
|
31795
|
+
* @param parsed - the parsed slash command, undefined for a plain prompt.
|
|
31796
|
+
* @param text - the submission text.
|
|
31797
|
+
* @param store - the live draft store.
|
|
31798
|
+
* @param isLocal - whether the command name is a LOCAL (TUI-owned/UI)
|
|
31799
|
+
* command; skill names answer false.
|
|
31800
|
+
*/
|
|
31801
|
+
function commandRejectsImages(parsed, text, store, isLocal) {
|
|
31802
|
+
return parsed !== void 0 && isLocal(parsed.name) && draftHasImages(text, store);
|
|
31803
|
+
}
|
|
31804
|
+
/**
|
|
29293
31805
|
* The AUTHORITATIVE host-owned command catalog (P1-04): every command name
|
|
29294
31806
|
* the TUI registers itself (registerTuiCommands in commands.ts) plus the
|
|
29295
31807
|
* ownership sets (LOCAL_COMMANDS and SESSIONLESS_COMMANDS — /kill and
|
|
@@ -29318,12 +31830,38 @@ const HOST_COMMAND_CATALOG = /* @__PURE__ */ new Set([
|
|
|
29318
31830
|
function shouldSteerOnEnter(parsed, running, busyEnter, forceQueue, isDynamicLocal) {
|
|
29319
31831
|
if (forceQueue) return false;
|
|
29320
31832
|
if (parsed !== void 0) {
|
|
31833
|
+
if (parsed.name === "skill" && (parsed.rawInput?.trim() ?? "") !== "") return running && busyEnter === "steer";
|
|
29321
31834
|
if (LOCAL_COMMANDS.has(parsed.name)) return false;
|
|
29322
31835
|
if (isDynamicLocal !== void 0 && isDynamicLocal(parsed.name)) return false;
|
|
29323
31836
|
}
|
|
29324
31837
|
return running && busyEnter === "steer";
|
|
29325
31838
|
}
|
|
29326
31839
|
/**
|
|
31840
|
+
* Normalize an explicit `/skill <name> <args>` invocation to the skill's
|
|
31841
|
+
* own slash line `/<name> <args>` (review finding 2). The harness's
|
|
31842
|
+
* explicit skill gesture scans for `/<skill-name>` — it would extract
|
|
31843
|
+
* `skill` from a raw `/skill grilling ...` line and never inject the
|
|
31844
|
+
* grilling body. The command handler already performs this conversion
|
|
31845
|
+
* (`loadSkill` builds `'/' + skill.name + ' ' + args`); the busy-Enter
|
|
31846
|
+
* steer path must use the SAME normalized line so the body injects and
|
|
31847
|
+
* any image placeholders ride along.
|
|
31848
|
+
* @param text - the submitted line.
|
|
31849
|
+
* @returns the normalized `/<name> <args>` line, or undefined when the
|
|
31850
|
+
* line is not an explicit skill invocation (plain prompt, other
|
|
31851
|
+
* commands, or the bare `/skill` picker).
|
|
31852
|
+
*/
|
|
31853
|
+
function normalizeSkillInvocation(text) {
|
|
31854
|
+
const parsed = parseCommand(text);
|
|
31855
|
+
if (parsed?.name !== "skill") return void 0;
|
|
31856
|
+
const raw = parsed.rawInput.trim();
|
|
31857
|
+
if (raw === "") return void 0;
|
|
31858
|
+
const match = /^([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+([\s\S]*))?$/.exec(raw);
|
|
31859
|
+
if (match === null) return void 0;
|
|
31860
|
+
const name = match[1];
|
|
31861
|
+
const args = match[2];
|
|
31862
|
+
return args === void 0 || args.trim() === "" ? `/${name}` : `/${name} ${args.trimStart()}`;
|
|
31863
|
+
}
|
|
31864
|
+
/**
|
|
29327
31865
|
* The advertised-claim miss decision (pure, exported for the headless
|
|
29328
31866
|
* suite): a slash input whose name was advertised by the completion list at
|
|
29329
31867
|
* submit time but that the REAL session's catalog lacks is CONSUMED with an
|
|
@@ -29490,6 +32028,20 @@ function subagentNoticeIsFailure(source) {
|
|
|
29490
32028
|
* skipped, and a newly-reported id is ADDED here so a re-render can never
|
|
29491
32029
|
* double-notify.
|
|
29492
32030
|
*/
|
|
32031
|
+
/**
|
|
32032
|
+
* The queue-pane display text of one message's content (review finding 5):
|
|
32033
|
+
* text blocks verbatim, image blocks as a compact `🖼️ name` summary (the
|
|
32034
|
+
* marker carries U+FE0F so fonts with an emoji face render it 2 cells wide
|
|
32035
|
+
* — the width math's expectation — and never overlap the name) — an
|
|
32036
|
+
* image-only queued message shows `🖼️ shot.png` instead of an empty row,
|
|
32037
|
+
* and a mixed message advertises its image. The queue row stays one line.
|
|
32038
|
+
*/
|
|
32039
|
+
function queueTextOf(content) {
|
|
32040
|
+
const parts = [];
|
|
32041
|
+
for (const block of content) if (block.type === "text") parts.push(block.text);
|
|
32042
|
+
else if (block.type === "image") parts.push(`🖼️ ${block.attachment.name ?? "image"}`);
|
|
32043
|
+
return parts.join(" ");
|
|
32044
|
+
}
|
|
29493
32045
|
function foldQueueRows(messages, mode, notified) {
|
|
29494
32046
|
const rows = [];
|
|
29495
32047
|
const failures = [];
|
|
@@ -29504,7 +32056,7 @@ function foldQueueRows(messages, mode, notified) {
|
|
|
29504
32056
|
}
|
|
29505
32057
|
rows.push({
|
|
29506
32058
|
id: message.id,
|
|
29507
|
-
text:
|
|
32059
|
+
text: queueTextOf(message.content),
|
|
29508
32060
|
mode,
|
|
29509
32061
|
notice: !isUserQueueInput(source)
|
|
29510
32062
|
});
|
|
@@ -30247,7 +32799,9 @@ function apply(ctx, config) {
|
|
|
30247
32799
|
},
|
|
30248
32800
|
setup: composition.setup
|
|
30249
32801
|
});
|
|
30250
|
-
|
|
32802
|
+
const error = await swapTo(next);
|
|
32803
|
+
if (error === void 0) draftImages.clearUnpinned();
|
|
32804
|
+
return error;
|
|
30251
32805
|
} catch (error) {
|
|
30252
32806
|
const message = safeErrorMessage(error);
|
|
30253
32807
|
ctx.logger.warn(`tui-runner: switch to ${sessionId} failed: ${message}`);
|
|
@@ -30626,11 +33180,65 @@ function apply(ctx, config) {
|
|
|
30626
33180
|
const failSubmission = (draft) => (error) => {
|
|
30627
33181
|
app.setEditorText(mergeDraft(app.getDraft(), draft));
|
|
30628
33182
|
const message = safeErrorMessage(error);
|
|
33183
|
+
if (error instanceof ImageInputError) {
|
|
33184
|
+
app.notify(message, "error");
|
|
33185
|
+
return;
|
|
33186
|
+
}
|
|
30629
33187
|
try {
|
|
30630
33188
|
ctx.logger.error(`tui-runner: session creation failed: ${message}`);
|
|
30631
33189
|
} catch {}
|
|
30632
33190
|
app.notify(`could not start a session: ${message}`, "error");
|
|
30633
33191
|
};
|
|
33192
|
+
/**
|
|
33193
|
+
* Restore the submitted text into the editor after a failed submission
|
|
33194
|
+
* (review finding: the restore MUST run BEFORE the reservation pin
|
|
33195
|
+
* releases — the restored placeholders must keep their backing drafts
|
|
33196
|
+
* against concurrent attach-time prunes). Correctness side effect
|
|
33197
|
+
* first; never throws.
|
|
33198
|
+
*/
|
|
33199
|
+
const restoreSubmissionDraft = (draft) => {
|
|
33200
|
+
app.setEditorText(mergeDraft(app.getDraft(), draft));
|
|
33201
|
+
};
|
|
33202
|
+
/**
|
|
33203
|
+
* Notify one submission failure WITHOUT restoring (the task's catch
|
|
33204
|
+
* already restored; restoring twice would re-merge the draft). Image
|
|
33205
|
+
* intake/admission/capability failures are THEIR OWN actionable errors
|
|
33206
|
+
* ("Current model ... does not support image input") — wrapping them in
|
|
33207
|
+
* "could not start a session" misleads when a session already exists.
|
|
33208
|
+
* Diagnostics are owned by runOwned.
|
|
33209
|
+
*/
|
|
33210
|
+
const notifySubmissionFailure = (error) => {
|
|
33211
|
+
const message = safeErrorMessage(error);
|
|
33212
|
+
if (error instanceof ImageInputError) {
|
|
33213
|
+
app.notify(message, "error");
|
|
33214
|
+
return;
|
|
33215
|
+
}
|
|
33216
|
+
try {
|
|
33217
|
+
ctx.logger.error(`tui-runner: submission failed: ${message}`);
|
|
33218
|
+
} catch {}
|
|
33219
|
+
const prefix = liveAgent === void 0 ? "could not start a session" : "submission failed";
|
|
33220
|
+
app.notify(`${prefix}: ${message}`, "error");
|
|
33221
|
+
};
|
|
33222
|
+
/** The image submission surface (plan §13): the live attachment/llm
|
|
33223
|
+
* services + the CURRENT provider/model, re-read at submit time (the
|
|
33224
|
+
* TUI supports runtime model switching — never a startup snapshot). */
|
|
33225
|
+
const submitDeps = {
|
|
33226
|
+
attachments: ctx.get("attachments"),
|
|
33227
|
+
llm: ctx.get("llm"),
|
|
33228
|
+
currentModel: () => {
|
|
33229
|
+
const current = selected.current;
|
|
33230
|
+
if (current !== void 0) return {
|
|
33231
|
+
provider: current.provider,
|
|
33232
|
+
model: current.model
|
|
33233
|
+
};
|
|
33234
|
+
if (liveAgent === void 0) return void 0;
|
|
33235
|
+
const { provider, model } = liveAgent.options;
|
|
33236
|
+
return provider === void 0 || model === void 0 ? void 0 : {
|
|
33237
|
+
provider,
|
|
33238
|
+
model
|
|
33239
|
+
};
|
|
33240
|
+
}
|
|
33241
|
+
};
|
|
30634
33242
|
/** The session-backed dispatch: create the session lazily (the first
|
|
30635
33243
|
* user input is the deferred trigger), guard against cross-process
|
|
30636
33244
|
* divergence, then execute a registered slash command or follow up. */
|
|
@@ -30638,80 +33246,109 @@ function apply(ctx, config) {
|
|
|
30638
33246
|
const parsedAtSubmit = parseCommand(text);
|
|
30639
33247
|
const extensionCommandId = parsedAtSubmit === void 0 ? void 0 : extensionService?.commands.idFor(parsedAtSubmit.name);
|
|
30640
33248
|
const wasAdvertised = parsedAtSubmit !== void 0 && wasAdvertisedClaim?.(parsedAtSubmit.name) === true;
|
|
30641
|
-
runOwned("submit", () =>
|
|
30642
|
-
|
|
30643
|
-
|
|
30644
|
-
|
|
30645
|
-
|
|
30646
|
-
|
|
30647
|
-
const
|
|
30648
|
-
|
|
30649
|
-
|
|
30650
|
-
|
|
30651
|
-
|
|
30652
|
-
|
|
30653
|
-
|
|
30654
|
-
|
|
30655
|
-
|
|
30656
|
-
|
|
30657
|
-
|
|
30658
|
-
|
|
30659
|
-
|
|
30660
|
-
|
|
30661
|
-
|
|
30662
|
-
|
|
30663
|
-
|
|
30664
|
-
|
|
30665
|
-
const
|
|
30666
|
-
|
|
30667
|
-
|
|
30668
|
-
|
|
30669
|
-
|
|
30670
|
-
|
|
30671
|
-
|
|
30672
|
-
|
|
30673
|
-
|
|
30674
|
-
|
|
30675
|
-
|
|
30676
|
-
|
|
30677
|
-
|
|
30678
|
-
|
|
30679
|
-
}, liveAgent, sessionGeneration)) agent.followup(createUserMessage({
|
|
30680
|
-
content: [{
|
|
30681
|
-
type: "text",
|
|
30682
|
-
text
|
|
30683
|
-
}],
|
|
30684
|
-
source: { kind: "user" }
|
|
30685
|
-
}));
|
|
30686
|
-
else {
|
|
30687
|
-
const merged = mergeDraft(app.getDraft(), text);
|
|
30688
|
-
app.setEditorText(merged);
|
|
30689
|
-
app.notify(merged === text ? "the session changed while sending — try again" : "the draft changed while sending — review it before submitting again (the earlier text was preserved below)", "error");
|
|
33249
|
+
runOwned("submit", () => runReservedSubmit({
|
|
33250
|
+
reserve: (t) => draftImages.pinReferenced(t),
|
|
33251
|
+
run: async () => {
|
|
33252
|
+
await ensureSession();
|
|
33253
|
+
const agent = liveAgent;
|
|
33254
|
+
if (agent === void 0) return;
|
|
33255
|
+
const generation = sessionGeneration;
|
|
33256
|
+
const verdict = await guardSend("submit", text);
|
|
33257
|
+
if (verdict.kind === "blocked") {
|
|
33258
|
+
const merged = mergeDraft(app.getDraft(), text);
|
|
33259
|
+
app.setEditorText(merged);
|
|
33260
|
+
app.notify(merged === text ? verdict.reason === "removed" ? GUARD_REMOVED_NOTIFY("submit") : verdict.reason === "tail-mismatch" ? GUARD_TAIL_MISMATCH_NOTIFY("submit") : GUARD_BLOCKED_NOTIFY("submit") : "the draft changed while sending — review it before submitting again (the earlier text was preserved below)", "error");
|
|
33261
|
+
return;
|
|
33262
|
+
}
|
|
33263
|
+
if (!sessionUnchanged({
|
|
33264
|
+
agent,
|
|
33265
|
+
generation
|
|
33266
|
+
}, liveAgent, sessionGeneration)) {
|
|
33267
|
+
const merged = mergeDraft(app.getDraft(), text);
|
|
33268
|
+
app.setEditorText(merged);
|
|
33269
|
+
app.notify(merged === text ? "the session changed while sending — try again" : "the draft changed while sending — review it before submitting again (the earlier text was preserved below)", "error");
|
|
33270
|
+
return;
|
|
33271
|
+
}
|
|
33272
|
+
if (verdict.kind === "forced") app.notify(GUARD_FORCED_NOTIFY, "error");
|
|
33273
|
+
const commands = ctx.get("commands");
|
|
33274
|
+
if (commands !== void 0) {
|
|
33275
|
+
const parsed = parseCommand(text);
|
|
33276
|
+
const toggled = parsed?.name === "plan" && parsed.rawInput.trim() === "" && foldPlanMode(agent.session.events ?? []) ? "/plan off" : text;
|
|
33277
|
+
const fallbackPin = draftImages.pinReferenced(text);
|
|
33278
|
+
runOwned("command execution", () => commands.execute(agent, toggled, [], signal), {
|
|
33279
|
+
diag,
|
|
33280
|
+
sessionId: () => agent.session.id,
|
|
33281
|
+
onResult: (execution) => {
|
|
33282
|
+
if (extensionCommandId !== void 0 && execution !== void 0) extensionService?._clearRegistryError("command", extensionCommandId);
|
|
33283
|
+
if (shouldConsumeAdvertisedMiss(execution, wasAdvertised)) {
|
|
33284
|
+
app.notify(`/${parsedAtSubmit?.name ?? "?"} is not available in the created session`, "error");
|
|
33285
|
+
fallbackPin();
|
|
33286
|
+
return;
|
|
30690
33287
|
}
|
|
33288
|
+
if (execution === void 0) {
|
|
33289
|
+
if (sessionUnchanged({
|
|
33290
|
+
agent,
|
|
33291
|
+
generation
|
|
33292
|
+
}, liveAgent, sessionGeneration)) runOwned("image submit", () => runReservedSubmit({
|
|
33293
|
+
reserve: () => fallbackPin,
|
|
33294
|
+
run: async () => {
|
|
33295
|
+
const message = await prepareUserMessage(text, draftImages, submitDeps);
|
|
33296
|
+
if (!sessionUnchanged({
|
|
33297
|
+
agent,
|
|
33298
|
+
generation
|
|
33299
|
+
}, liveAgent, sessionGeneration)) {
|
|
33300
|
+
const merged = mergeDraft(app.getDraft(), text);
|
|
33301
|
+
app.setEditorText(merged);
|
|
33302
|
+
app.notify(merged === text ? "the session changed while sending — try again" : "the draft changed while sending — review it before submitting again (the earlier text was preserved below)", "error");
|
|
33303
|
+
return;
|
|
33304
|
+
}
|
|
33305
|
+
agent.followup(message);
|
|
33306
|
+
consumeDraftImages(text, draftImages);
|
|
33307
|
+
},
|
|
33308
|
+
restore: (t) => restoreSubmissionDraft(t)
|
|
33309
|
+
}, text), {
|
|
33310
|
+
diag,
|
|
33311
|
+
sessionId: () => agent.session.id,
|
|
33312
|
+
onError: notifySubmissionFailure
|
|
33313
|
+
});
|
|
33314
|
+
else {
|
|
33315
|
+
fallbackPin();
|
|
33316
|
+
const merged = mergeDraft(app.getDraft(), text);
|
|
33317
|
+
app.setEditorText(merged);
|
|
33318
|
+
app.notify(merged === text ? "the session changed while sending — try again" : "the draft changed while sending — review it before submitting again (the earlier text was preserved below)", "error");
|
|
33319
|
+
}
|
|
33320
|
+
} else fallbackPin();
|
|
33321
|
+
},
|
|
33322
|
+
onError: (error) => {
|
|
33323
|
+
fallbackPin();
|
|
33324
|
+
if (extensionCommandId !== void 0) extensionService?._recordRegistryError("command", extensionCommandId, error);
|
|
33325
|
+
const message = safeErrorMessage(error);
|
|
33326
|
+
try {
|
|
33327
|
+
ctx.logger.error(`tui-runner: command execution failed: ${message}`);
|
|
33328
|
+
} catch {}
|
|
33329
|
+
app.notify(message, "error");
|
|
30691
33330
|
}
|
|
30692
|
-
}
|
|
30693
|
-
|
|
30694
|
-
|
|
30695
|
-
|
|
30696
|
-
|
|
30697
|
-
|
|
30698
|
-
|
|
30699
|
-
|
|
30700
|
-
|
|
30701
|
-
|
|
30702
|
-
|
|
30703
|
-
|
|
30704
|
-
|
|
30705
|
-
|
|
30706
|
-
|
|
30707
|
-
|
|
30708
|
-
|
|
30709
|
-
|
|
30710
|
-
}));
|
|
30711
|
-
}), {
|
|
33331
|
+
});
|
|
33332
|
+
return;
|
|
33333
|
+
}
|
|
33334
|
+
const message = await prepareUserMessage(text, draftImages, submitDeps);
|
|
33335
|
+
if (!sessionUnchanged({
|
|
33336
|
+
agent,
|
|
33337
|
+
generation
|
|
33338
|
+
}, liveAgent, sessionGeneration)) {
|
|
33339
|
+
const merged = mergeDraft(app.getDraft(), text);
|
|
33340
|
+
app.setEditorText(merged);
|
|
33341
|
+
app.notify(merged === text ? "the session changed while sending — try again" : "the draft changed while sending — review it before submitting again (the earlier text was preserved below)", "error");
|
|
33342
|
+
return;
|
|
33343
|
+
}
|
|
33344
|
+
agent.followup(message);
|
|
33345
|
+
consumeDraftImages(text, draftImages);
|
|
33346
|
+
},
|
|
33347
|
+
restore: (t) => restoreSubmissionDraft(t)
|
|
33348
|
+
}, text), {
|
|
30712
33349
|
diag,
|
|
30713
33350
|
sessionId: () => liveAgent?.session.id,
|
|
30714
|
-
onError:
|
|
33351
|
+
onError: notifySubmissionFailure
|
|
30715
33352
|
});
|
|
30716
33353
|
};
|
|
30717
33354
|
/**
|
|
@@ -30778,45 +33415,45 @@ function apply(ctx, config) {
|
|
|
30778
33415
|
}
|
|
30779
33416
|
app.clearSettledLocalMessages();
|
|
30780
33417
|
if (onlyDraft) {
|
|
30781
|
-
if (text.trim() === "") return;
|
|
33418
|
+
if (text.trim() === "" && !draftHasImages(text, draftImages)) return;
|
|
30782
33419
|
} else if (liveAgent !== void 0) {
|
|
30783
|
-
if ([...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].length === 0 && text.trim() === "") return;
|
|
30784
|
-
} else if (text.trim() === "") return;
|
|
30785
|
-
runOwned("steer", () =>
|
|
30786
|
-
|
|
30787
|
-
|
|
30788
|
-
|
|
30789
|
-
|
|
30790
|
-
|
|
30791
|
-
|
|
30792
|
-
|
|
30793
|
-
|
|
30794
|
-
|
|
30795
|
-
|
|
30796
|
-
|
|
30797
|
-
|
|
30798
|
-
|
|
30799
|
-
|
|
30800
|
-
|
|
30801
|
-
|
|
30802
|
-
|
|
30803
|
-
|
|
30804
|
-
|
|
30805
|
-
|
|
30806
|
-
|
|
30807
|
-
|
|
30808
|
-
|
|
30809
|
-
|
|
30810
|
-
|
|
30811
|
-
|
|
30812
|
-
|
|
30813
|
-
|
|
30814
|
-
|
|
30815
|
-
|
|
30816
|
-
}), {
|
|
33420
|
+
if ([...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].length === 0 && text.trim() === "" && !draftHasImages(text, draftImages)) return;
|
|
33421
|
+
} else if (text.trim() === "" && !draftHasImages(text, draftImages)) return;
|
|
33422
|
+
runOwned("steer", () => runReservedSubmit({
|
|
33423
|
+
reserve: (t) => draftImages.pinReferenced(t),
|
|
33424
|
+
run: async () => {
|
|
33425
|
+
await ensureSession();
|
|
33426
|
+
if (liveAgent === void 0) return;
|
|
33427
|
+
const prepared = await prepareUserMessage(text, draftImages, submitDeps);
|
|
33428
|
+
if (await steerAll({
|
|
33429
|
+
currentAgent: () => liveAgent,
|
|
33430
|
+
currentGeneration: () => sessionGeneration,
|
|
33431
|
+
guard: { run: async (identity) => {
|
|
33432
|
+
const verdict = await guardSend("save", identity);
|
|
33433
|
+
if (verdict.kind === "blocked") return {
|
|
33434
|
+
kind: "blocked",
|
|
33435
|
+
reason: verdict.reason
|
|
33436
|
+
};
|
|
33437
|
+
return { kind: verdict.kind === "forced" ? "forced" : "ok" };
|
|
33438
|
+
} },
|
|
33439
|
+
notify: (message, kind) => app.notify(message, kind),
|
|
33440
|
+
restoreDraft: (draft) => {
|
|
33441
|
+
const merged = mergeDraft(app.getDraft(), draft);
|
|
33442
|
+
app.setEditorText(merged);
|
|
33443
|
+
return merged === draft;
|
|
33444
|
+
},
|
|
33445
|
+
createDraft: () => prepared,
|
|
33446
|
+
blockedNotice: (reason) => reason === "removed" ? GUARD_REMOVED_NOTIFY("save") : reason === "tail-mismatch" ? GUARD_TAIL_MISMATCH_NOTIFY("save") : GUARD_BLOCKED_NOTIFY("save"),
|
|
33447
|
+
forcedNotice: () => GUARD_FORCED_NOTIFY,
|
|
33448
|
+
staleNotice: () => "the queue or session changed while sending — try again",
|
|
33449
|
+
mergedNotice: () => "the draft changed while sending — review it before submitting again (the earlier text was preserved below)"
|
|
33450
|
+
}, text, onlyDraft ? { onlyDraft: true } : void 0) === "ok") consumeDraftImages(text, draftImages);
|
|
33451
|
+
},
|
|
33452
|
+
restore: (t) => restoreSubmissionDraft(t)
|
|
33453
|
+
}, text), {
|
|
30817
33454
|
diag,
|
|
30818
33455
|
sessionId: () => liveAgent?.session.id,
|
|
30819
|
-
onError:
|
|
33456
|
+
onError: notifySubmissionFailure
|
|
30820
33457
|
});
|
|
30821
33458
|
};
|
|
30822
33459
|
/**
|
|
@@ -30846,7 +33483,7 @@ function apply(ctx, config) {
|
|
|
30846
33483
|
}
|
|
30847
33484
|
app.clearSettledLocalMessages();
|
|
30848
33485
|
const trimmed = text.trim();
|
|
30849
|
-
if (trimmed !== "" && trimmed !== lastHistoryContent) {
|
|
33486
|
+
if (trimmed !== "" && trimmed !== lastHistoryContent && !draftHasImages(text, draftImages)) {
|
|
30850
33487
|
const file = historyFilePath(dshHome(process.env), sessionCwd());
|
|
30851
33488
|
runDetached("input history write", () => {
|
|
30852
33489
|
appendHistoryLine(file, trimmed, lastHistoryContent);
|
|
@@ -30867,22 +33504,85 @@ function apply(ctx, config) {
|
|
|
30867
33504
|
return;
|
|
30868
33505
|
}
|
|
30869
33506
|
const parsed = parseCommand(text);
|
|
33507
|
+
if (commandRejectsImages(parsed, text, draftImages, (name) => {
|
|
33508
|
+
if (name === "skill" && (parsed?.rawInput.trim() ?? "") !== "") return false;
|
|
33509
|
+
return LOCAL_COMMANDS.has(name) || (extensionService?.commands.isLocal(name, LOCAL_COMMANDS) ?? false);
|
|
33510
|
+
})) {
|
|
33511
|
+
app.setEditorText(mergeDraft(app.getDraft(), text));
|
|
33512
|
+
app.notify("Images cannot be attached to a command.", "error");
|
|
33513
|
+
return;
|
|
33514
|
+
}
|
|
30870
33515
|
const isSessionless = parsed !== void 0 && (SESSIONLESS_COMMANDS.has(parsed.name) || (extensionService?.commands.isSessionless(parsed.name, SESSIONLESS_COMMANDS) ?? false));
|
|
30871
33516
|
if (parsed !== void 0 && liveAgent === void 0 && isSessionless) {
|
|
30872
33517
|
runLocalCommand(parsed, text);
|
|
30873
33518
|
return;
|
|
30874
33519
|
}
|
|
30875
33520
|
if (shouldSteerOnEnter(parsed, liveAgent?.status === "running", tuiSettings?.get().busyEnter, forceQueue, (name) => extensionService?.commands.isLocal(name, LOCAL_COMMANDS) ?? false)) {
|
|
30876
|
-
steerNow(text, true);
|
|
33521
|
+
steerNow(normalizeSkillInvocation(text) ?? text, true);
|
|
30877
33522
|
return;
|
|
30878
33523
|
}
|
|
30879
33524
|
dispatchViaSession(text);
|
|
30880
33525
|
};
|
|
30881
33526
|
extensionService = ctx.get(PI_TUI_EXTENSIONS_SERVICE);
|
|
30882
33527
|
if (extensionService !== void 0) extensionHost = new SurfaceHost(extensionService._ledger(), () => app.requestRender());
|
|
33528
|
+
const imageLoader = new ImageLoader((ref) => {
|
|
33529
|
+
const attachments = ctx.get("attachments");
|
|
33530
|
+
if (attachments === void 0) throw new ImageLoadError("Image attachments are unavailable in this deployment.");
|
|
33531
|
+
return attachments.readImage(ref);
|
|
33532
|
+
});
|
|
33533
|
+
/** The clipboard bridge (plan M3): a bounded execFile runner with a
|
|
33534
|
+
* generous buffer (clipboard payloads can be multi-MB). */
|
|
33535
|
+
const runClipboardCommand = (command, args, options) => new Promise((resolve) => {
|
|
33536
|
+
execFile(command, args, {
|
|
33537
|
+
timeout: options?.timeoutMs ?? 2e3,
|
|
33538
|
+
maxBuffer: 67108864
|
|
33539
|
+
}, (error, stdout, stderr) => {
|
|
33540
|
+
const code = error === null ? 0 : typeof error.code === "number" ? error.code : 1;
|
|
33541
|
+
resolve({
|
|
33542
|
+
stdout: Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout),
|
|
33543
|
+
stderr: Buffer.isBuffer(stderr) ? stderr : Buffer.from(stderr ?? ""),
|
|
33544
|
+
code
|
|
33545
|
+
});
|
|
33546
|
+
});
|
|
33547
|
+
});
|
|
33548
|
+
const clipboardEnv = {
|
|
33549
|
+
platform: process.platform,
|
|
33550
|
+
env: process.env,
|
|
33551
|
+
exists: (command) => commandOnPath(command, process.env.PATH, process.platform)
|
|
33552
|
+
};
|
|
30883
33553
|
app = startProcessTui({
|
|
30884
33554
|
onSubmit: (text) => dispatchUserInput(text),
|
|
30885
|
-
|
|
33555
|
+
isImageDraft: () => draftHasImages(app.getDraft(), draftImages),
|
|
33556
|
+
shouldRememberInput: (text) => !draftHasImages(text, draftImages),
|
|
33557
|
+
onClipboardPaste: () => {
|
|
33558
|
+
const pasteGeneration = sessionGeneration;
|
|
33559
|
+
runOwned("clipboard paste", () => readClipboardImage(runClipboardCommand, clipboardEnv).then((result) => {
|
|
33560
|
+
if (sessionGeneration !== pasteGeneration) return;
|
|
33561
|
+
if (result.kind === "image") {
|
|
33562
|
+
pruneUnreferencedDrafts(app.getDraft(), draftImages);
|
|
33563
|
+
const limits = ctx.get("attachments")?.imageLimits;
|
|
33564
|
+
if (limits !== void 0) checkImageLimits({
|
|
33565
|
+
mediaType: result.mediaType,
|
|
33566
|
+
width: result.width,
|
|
33567
|
+
height: result.height
|
|
33568
|
+
}, result.bytes.byteLength, limits);
|
|
33569
|
+
const draft = draftImages.add({
|
|
33570
|
+
bytes: result.bytes,
|
|
33571
|
+
mediaType: result.mediaType,
|
|
33572
|
+
width: result.width,
|
|
33573
|
+
height: result.height,
|
|
33574
|
+
source: { type: "clipboard" }
|
|
33575
|
+
});
|
|
33576
|
+
app.insertIntoEditor(`${draft.placeholder} `);
|
|
33577
|
+
app.notify(`attached ${draft.placeholder} — Enter to send`);
|
|
33578
|
+
} else if (result.kind === "text" && result.text !== "") app.insertIntoEditor(result.text);
|
|
33579
|
+
}), {
|
|
33580
|
+
diag,
|
|
33581
|
+
sessionId: () => liveAgent?.session.id,
|
|
33582
|
+
onError: (error) => app.notify(safeErrorMessage(error), "error")
|
|
33583
|
+
});
|
|
33584
|
+
},
|
|
33585
|
+
onQueueSubmit: (input) => dispatchUserInput(input, true),
|
|
30886
33586
|
runOwned: (label, task, options) => {
|
|
30887
33587
|
runOwned(label, task, {
|
|
30888
33588
|
...options,
|
|
@@ -31041,14 +33741,43 @@ function apply(ctx, config) {
|
|
|
31041
33741
|
if (liveAgent === void 0) return;
|
|
31042
33742
|
const queued = [...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].filter((message) => isUserQueueInput(message.source));
|
|
31043
33743
|
if (queued.length === 0) return;
|
|
33744
|
+
let recalledText = "";
|
|
33745
|
+
const staged = [];
|
|
33746
|
+
try {
|
|
33747
|
+
const lines = [];
|
|
33748
|
+
for (const message of queued) {
|
|
33749
|
+
const parts = [];
|
|
33750
|
+
for (const block of message.content) if (block.type === "text") parts.push(block.text);
|
|
33751
|
+
else if (block.type === "image") {
|
|
33752
|
+
const attachment = block.attachment;
|
|
33753
|
+
const draft = draftImages.add({
|
|
33754
|
+
mediaType: attachment.mediaType,
|
|
33755
|
+
width: attachment.width,
|
|
33756
|
+
height: attachment.height,
|
|
33757
|
+
...attachment.name !== void 0 ? { name: attachment.name } : {},
|
|
33758
|
+
source: { type: "recalled" },
|
|
33759
|
+
recalledRef: attachment
|
|
33760
|
+
});
|
|
33761
|
+
staged.push(draft.id);
|
|
33762
|
+
parts.push(draft.placeholder);
|
|
33763
|
+
}
|
|
33764
|
+
lines.push(parts.join(""));
|
|
33765
|
+
}
|
|
33766
|
+
recalledText = lines.join("\n\n");
|
|
33767
|
+
} catch (error) {
|
|
33768
|
+
for (const id of staged) draftImages.remove(id);
|
|
33769
|
+
app.notify(safeErrorMessage(error), "error");
|
|
33770
|
+
return;
|
|
33771
|
+
}
|
|
31044
33772
|
for (const message of queued) liveAgent.inbox.remove(message.id);
|
|
31045
|
-
const queuedText = queued.map((message) => textOf(message.content)).join("\n\n");
|
|
31046
33773
|
const current = app.getDraft();
|
|
31047
|
-
app.setDraft([
|
|
33774
|
+
app.setDraft([recalledText, current].filter((part) => part.trim() !== "").join("\n\n"));
|
|
31048
33775
|
refreshQueue();
|
|
31049
33776
|
},
|
|
31050
33777
|
onOpenTasks: () => openTasksBrowser()
|
|
31051
33778
|
}, {
|
|
33779
|
+
imageLoader,
|
|
33780
|
+
imageTheme: { fallbackColor: color.textDim },
|
|
31052
33781
|
present,
|
|
31053
33782
|
workspaceRoot: cwd,
|
|
31054
33783
|
extensionHost,
|
|
@@ -31532,6 +34261,7 @@ function apply(ctx, config) {
|
|
|
31532
34261
|
diag.warn("skills/change subscription unavailable", { error: safeErrorMessage(error) });
|
|
31533
34262
|
}
|
|
31534
34263
|
};
|
|
34264
|
+
const draftImages = new DraftImageStore();
|
|
31535
34265
|
const runner = {
|
|
31536
34266
|
ctx,
|
|
31537
34267
|
app,
|
|
@@ -31549,6 +34279,10 @@ function apply(ctx, config) {
|
|
|
31549
34279
|
agents,
|
|
31550
34280
|
sessions: { flush: (session) => sessions.flush(session) },
|
|
31551
34281
|
cwd,
|
|
34282
|
+
imageStore: draftImages,
|
|
34283
|
+
imageLimits: () => ctx.get("attachments")?.imageLimits,
|
|
34284
|
+
insertIntoEditor: (text) => app.insertIntoEditor(text),
|
|
34285
|
+
prepareDraftMessage: (text) => prepareUserMessage(text, draftImages, submitDeps),
|
|
31552
34286
|
get extensions() {
|
|
31553
34287
|
return extensionService === void 0 ? void 0 : {
|
|
31554
34288
|
commands: extensionService.commands,
|
|
@@ -31843,4 +34577,4 @@ function apply(ctx, config) {
|
|
|
31843
34577
|
});
|
|
31844
34578
|
}
|
|
31845
34579
|
//#endregion
|
|
31846
|
-
export {
|
|
34580
|
+
export { subagentJobViewHint as A, registerCredentialSurfaceRefresh as C, shouldConsumeAdvertisedMiss as D, runningProfile as E, inject$1 as F, name$1 as I, PI_TUI_EXTENSIONS_SERVICE as L, dshVersion as M, forkSeed as N, shouldSteerOnEnter as O, apply$1 as P, API_VERSION as R, recordedPreset as S, resumeCommand as T, isUserQueueInput as _, SessionLockRefusedError as a, normalizeSkillInvocation as b, commandRejectsImages as c, dangerCommand as d, foldCompactionEvent as f, isSubagentSettlementNotice as g, isPlainExitPrompt as h, SESSIONLESS_COMMANDS as i, subagentNoticeIsFailure as j, subagentJobTranscriptId as k, compactingFromLog as l, inject as m, HOST_COMMAND_CATALOG as n, apply as o, foldQueueRows as p, LOCAL_COMMANDS as r, busyAfterTurnBoundary as s, Config as t, composeAgent as u, matchPendingSubagentCall as v, resolveInitialCatalog as w, recomposeBlank as x, name as y, describeKey as z };
|