@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.
@@ -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: `&amp;`/`&quot;`/`&lt;`) 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("&quot;", "\"").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
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 text = textOf(event.data.content);
1314
- if (text === "") break;
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 text = textOf(event.data.message.content);
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) entry.text = text;
1347
- else if (text !== "") {
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 = textOf(event.data.content);
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 = textOf(event.data.message.content);
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 = textOf(block?.content ?? []);
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: "#FFCB6B",
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: "#9A4A00",
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
- return this.inner.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? false;
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). Mirrors kimi's reopenAutocompleteAfterInput
17094
- * for the @-mention case only. */
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.messageComponents.clear();
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 mirror. */
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
- * Show or hide the busy indicator on the row directly above the editor
18967
- * border: while a turn is streaming or a tool is running (the runner
18968
- * derives it from turn/start and turn/end). A compaction in flight keeps
18969
- * the row live under its own label (see {@link setCompacting}).
18970
- */
18971
- setWorking(active) {
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") return new BulletedComponent(new Text(message.text, 0, 0), `${color.roleUser("❯")} `);
20141
- if (message.kind === "assistant") return new BulletedComponent(new Markdown(message.text, 0, 0, markdownTheme), `${color.primary("🐋")} `);
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
- row.addChild(new Text(color.textDim(message.text), 0, 0));
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 ? "" : ` — ${message.summary}`;
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
- return new Text(expanded ? `${color.textMuted("§")} ${color.textDim(message.text)}` : color.textMuted(`§ ${truncateToWidth(preview(message.text, 2), Math.max(1, this.terminal.columns - 22), "…")} (ctrl+o to expand)`), 0, 0);
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 resultPreview = message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
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(JSON.stringify(block, null, 2)), 0, 0));
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
- for (const block of content) if (block.type === "text") card.addChild(new Text(color.textDim(block.text), 0, 0));
20479
- else card.addChild(new Text(color.textDim(JSON.stringify(block, null, 2)), 0, 0));
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
- const lines = blocks.length > 0 ? resultTextLines(blocks, message.status === "error" ? {
20513
- name: "error",
20514
- code: "tool"
20515
- } : void 0) : [message.result];
20516
- for (const line of lines) card.addChild(new Text(color.textDim(line), 0, 0));
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.accent("❯")} ${truncated}`);
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 keep their
25889
- * configured/available/custom groups (the fork renders a non-interactive
25890
- * header row per group), authorization targets get their own group, and the
25891
- * Add New Platform action row is pinned last. Authorization row values
25892
- * carry the AUTH_VALUE_PREFIX so they can never collide with a route. */
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: "available · catalog",
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)` : `${ref} set · provider ${routeValue} added`
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 userMessage = createUserMessage({
26755
- content: [{
26756
- type: "text",
26757
- text: line
26758
- }],
26759
- source: { kind: "user" }
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
- const hostLoadsSkillBody = hostSkillLoader !== void 0 && typeof hostSkillLoader.execute === "function";
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) app.notify(error, "error");
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) app.notify(error, "error");
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 (API key or provider-native flow)",
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: `Paste the API key for ${label}:`,
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: `${targetRef} set`
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: `${resolved} cleared`
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: `${targetRef} cleared`
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(draft)]];
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: textOf(message.content),
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
- return swapTo(next);
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", () => ensureSession().then(async () => {
30642
- const agent = liveAgent;
30643
- if (agent === void 0) return;
30644
- const generation = sessionGeneration;
30645
- const verdict = await guardSend("submit", text);
30646
- if (verdict.kind === "blocked") {
30647
- const merged = mergeDraft(app.getDraft(), text);
30648
- app.setEditorText(merged);
30649
- 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");
30650
- return;
30651
- }
30652
- if (!sessionUnchanged({
30653
- agent,
30654
- generation
30655
- }, liveAgent, sessionGeneration)) {
30656
- const merged = mergeDraft(app.getDraft(), text);
30657
- app.setEditorText(merged);
30658
- 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");
30659
- return;
30660
- }
30661
- if (verdict.kind === "forced") app.notify(GUARD_FORCED_NOTIFY, "error");
30662
- const commands = ctx.get("commands");
30663
- if (commands !== void 0) {
30664
- const parsed = parseCommand(text);
30665
- const toggled = parsed?.name === "plan" && parsed.rawInput.trim() === "" && foldPlanMode(agent.session.events ?? []) ? "/plan off" : text;
30666
- runOwned("command execution", () => commands.execute(agent, toggled, [], signal), {
30667
- diag,
30668
- sessionId: () => agent.session.id,
30669
- onResult: (execution) => {
30670
- if (extensionCommandId !== void 0 && execution !== void 0) extensionService?._clearRegistryError("command", extensionCommandId);
30671
- if (shouldConsumeAdvertisedMiss(execution, wasAdvertised)) {
30672
- app.notify(`/${parsedAtSubmit?.name ?? "?"} is not available in the created session`, "error");
30673
- return;
30674
- }
30675
- if (execution === void 0) {
30676
- if (sessionUnchanged({
30677
- agent,
30678
- generation
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
- onError: (error) => {
30694
- if (extensionCommandId !== void 0) extensionService?._recordRegistryError("command", extensionCommandId, error);
30695
- const message = safeErrorMessage(error);
30696
- try {
30697
- ctx.logger.error(`tui-runner: command execution failed: ${message}`);
30698
- } catch {}
30699
- app.notify(message, "error");
30700
- }
30701
- });
30702
- return;
30703
- }
30704
- agent.followup(createUserMessage({
30705
- content: [{
30706
- type: "text",
30707
- text
30708
- }],
30709
- source: { kind: "user" }
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: failSubmission(text)
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", () => ensureSession().then(async () => {
30786
- if (liveAgent === void 0) return;
30787
- await steerAll({
30788
- currentAgent: () => liveAgent,
30789
- currentGeneration: () => sessionGeneration,
30790
- guard: { run: async (identity) => {
30791
- const verdict = await guardSend("save", identity);
30792
- if (verdict.kind === "blocked") return {
30793
- kind: "blocked",
30794
- reason: verdict.reason
30795
- };
30796
- return { kind: verdict.kind === "forced" ? "forced" : "ok" };
30797
- } },
30798
- notify: (message, kind) => app.notify(message, kind),
30799
- restoreDraft: (draft) => {
30800
- const merged = mergeDraft(app.getDraft(), draft);
30801
- app.setEditorText(merged);
30802
- return merged === draft;
30803
- },
30804
- createDraft: (draft) => createUserMessage({
30805
- content: [{
30806
- type: "text",
30807
- text: draft
30808
- }],
30809
- source: { kind: "user" }
30810
- }),
30811
- blockedNotice: (reason) => reason === "removed" ? GUARD_REMOVED_NOTIFY("save") : reason === "tail-mismatch" ? GUARD_TAIL_MISMATCH_NOTIFY("save") : GUARD_BLOCKED_NOTIFY("save"),
30812
- forcedNotice: () => GUARD_FORCED_NOTIFY,
30813
- staleNotice: () => "the queue or session changed while sending — try again",
30814
- mergedNotice: () => "the draft changed while sending — review it before submitting again (the earlier text was preserved below)"
30815
- }, text, onlyDraft ? { onlyDraft: true } : void 0);
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: failSubmission(text)
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
- onQueueSubmit: (text) => dispatchUserInput(text, true),
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([queuedText, current].filter((part) => part.trim() !== "").join("\n\n"));
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 { dshVersion as A, resumeCommand as C, subagentJobTranscriptId as D, shouldSteerOnEnter as E, PI_TUI_EXTENSIONS_SERVICE as F, API_VERSION as I, describeKey as L, apply$1 as M, inject$1 as N, subagentJobViewHint as O, name$1 as P, resolveInitialCatalog as S, shouldConsumeAdvertisedMiss as T, matchPendingSubagentCall as _, SessionLockRefusedError as a, recordedPreset as b, compactingFromLog as c, foldCompactionEvent as d, foldQueueRows as f, isUserQueueInput as g, isSubagentSettlementNotice as h, SESSIONLESS_COMMANDS as i, forkSeed as j, subagentNoticeIsFailure as k, composeAgent as l, isPlainExitPrompt as m, HOST_COMMAND_CATALOG as n, apply as o, inject as p, LOCAL_COMMANDS as r, busyAfterTurnBoundary as s, Config as t, dangerCommand as u, name as v, runningProfile as w, registerCredentialSurfaceRefresh as x, recomposeBlank as y };
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 };