@hydra-acp/cli 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -749,6 +749,11 @@ var init_hydra_commands = __esm({
749
749
  name: "hydra agent",
750
750
  argsHint: "<agent>",
751
751
  description: "Swap the agent backing this session, preserving context"
752
+ },
753
+ {
754
+ verb: "kill",
755
+ name: "hydra kill",
756
+ description: "Close this session (kills the agent; record is kept so it can be resumed later)"
752
757
  }
753
758
  ];
754
759
  VERB_INDEX = new Map(HYDRA_COMMANDS.map((c) => [c.verb, c]));
@@ -1635,6 +1640,8 @@ var init_session = __esm({
1635
1640
  return this.runTitleCommand(arg);
1636
1641
  case "agent":
1637
1642
  return this.runAgentCommand(arg);
1643
+ case "kill":
1644
+ return this.runKillCommand();
1638
1645
  default: {
1639
1646
  const err = new Error(
1640
1647
  `no dispatcher for /hydra verb ${verb}`
@@ -1746,6 +1753,17 @@ var init_session = __esm({
1746
1753
  return { stopReason: "end_turn" };
1747
1754
  });
1748
1755
  }
1756
+ // Close this session in-place. Bypasses enqueuePrompt deliberately so a
1757
+ // mid-turn /hydra kill takes effect immediately — agent.kill() will tear
1758
+ // down any in-flight request as a side effect. The record is kept
1759
+ // (deleteRecord:false) so the session goes cold and can be resurrected.
1760
+ // Returns end_turn so the prompt() caller's response resolves normally,
1761
+ // but every attached client has already received hydra-acp/session_closed
1762
+ // by the time this returns.
1763
+ async runKillCommand() {
1764
+ await this.close({ deleteRecord: false });
1765
+ return { stopReason: "end_turn" };
1766
+ }
1749
1767
  // Walk the persisted history and produce a labeled transcript suitable
1750
1768
  // for handing to a fresh agent. Includes user prompts, agent replies,
1751
1769
  // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
@@ -2058,7 +2076,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2058
2076
  }
2059
2077
  async enqueuePrompt(task) {
2060
2078
  return new Promise((resolve5, reject) => {
2061
- const run2 = async () => {
2079
+ const run3 = async () => {
2062
2080
  try {
2063
2081
  const result = await task();
2064
2082
  resolve5(result);
@@ -2066,7 +2084,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2066
2084
  reject(err);
2067
2085
  }
2068
2086
  };
2069
- this.promptQueue.push(run2);
2087
+ this.promptQueue.push(run3);
2070
2088
  void this.drainQueue();
2071
2089
  });
2072
2090
  }
@@ -3410,8 +3428,15 @@ async function pickSession(term, opts) {
3410
3428
  let total = 1 + visible.length;
3411
3429
  let selectedIdx = 0;
3412
3430
  let scrollOffset = 0;
3431
+ if (opts.currentSessionId !== void 0) {
3432
+ const idx = visible.findIndex((s) => s.sessionId === opts.currentSessionId);
3433
+ if (idx >= 0) {
3434
+ selectedIdx = idx + 1;
3435
+ }
3436
+ }
3413
3437
  let searchActive = false;
3414
3438
  let searchTerm = "";
3439
+ let cwdOnly = false;
3415
3440
  let mode = "normal";
3416
3441
  let pendingAction = null;
3417
3442
  let transientStatus = null;
@@ -3440,10 +3465,14 @@ async function pickSession(term, opts) {
3440
3465
  computeLayout();
3441
3466
  };
3442
3467
  const applyFilter = () => {
3468
+ let base = allSessions;
3469
+ if (cwdOnly) {
3470
+ base = base.filter((s) => s.cwd === opts.cwd);
3471
+ }
3443
3472
  if (searchActive && searchTerm.length > 0) {
3444
- visible = allSessions.filter((s) => matchesSearch(s, searchTerm));
3473
+ visible = base.filter((s) => matchesSearch(s, searchTerm));
3445
3474
  } else {
3446
- visible = allSessions;
3475
+ visible = base;
3447
3476
  }
3448
3477
  rebuildRows();
3449
3478
  if (searchActive) {
@@ -3488,16 +3517,19 @@ async function pickSession(term, opts) {
3488
3517
  const formatIndicator = () => {
3489
3518
  const above = scrollOffset;
3490
3519
  const below = Math.max(0, visible.length - scrollOffset - viewportSize);
3491
- if (above === 0 && below === 0) {
3492
- return "";
3493
- }
3494
3520
  const parts = [];
3521
+ if (cwdOnly) {
3522
+ parts.push("cwd-only");
3523
+ }
3495
3524
  if (above > 0) {
3496
3525
  parts.push(`\u2191 ${above} above`);
3497
3526
  }
3498
3527
  if (below > 0) {
3499
3528
  parts.push(`\u2193 ${below} below`);
3500
3529
  }
3530
+ if (parts.length === 0) {
3531
+ return "";
3532
+ }
3501
3533
  return ` ${parts.join(" \xB7 ")}`;
3502
3534
  };
3503
3535
  const shortId2 = (sessionId) => stripHydraSessionPrefix(sessionId);
@@ -3721,6 +3753,38 @@ async function pickSession(term, opts) {
3721
3753
  renderFromScratch();
3722
3754
  return;
3723
3755
  }
3756
+ if (name === "n" || name === "N") {
3757
+ move(1);
3758
+ return;
3759
+ }
3760
+ if (name === "p" || name === "P") {
3761
+ move(-1);
3762
+ return;
3763
+ }
3764
+ if (name === "c" || name === "C") {
3765
+ cleanup();
3766
+ resolve5({ kind: "new" });
3767
+ return;
3768
+ }
3769
+ if (name === "q" || name === "Q") {
3770
+ cleanup();
3771
+ resolve5({ kind: "abort" });
3772
+ return;
3773
+ }
3774
+ if (name === "o" || name === "O") {
3775
+ const keepId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
3776
+ cwdOnly = !cwdOnly;
3777
+ applyFilter();
3778
+ if (keepId !== void 0) {
3779
+ const idx = visible.findIndex((s) => s.sessionId === keepId);
3780
+ if (idx >= 0) {
3781
+ selectedIdx = idx + 1;
3782
+ adjustScroll();
3783
+ }
3784
+ }
3785
+ renderFromScratch();
3786
+ return;
3787
+ }
3724
3788
  if (name === "r" || name === "R") {
3725
3789
  const currentId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
3726
3790
  void refresh(currentId);
@@ -3823,7 +3887,7 @@ function readTermWidth(term) {
3823
3887
  return term.width ?? 80;
3824
3888
  }
3825
3889
  function formatNewSessionLabel(cwd, maxWidth) {
3826
- const prefix = "+ New session in ";
3890
+ const prefix = "New session in ";
3827
3891
  const budget = Math.max(1, maxWidth - prefix.length);
3828
3892
  return prefix + truncateMiddle(shortenHomePath(cwd), budget);
3829
3893
  }
@@ -3859,6 +3923,115 @@ var init_picker = __esm({
3859
3923
  }
3860
3924
  });
3861
3925
 
3926
+ // src/tui/attachments.ts
3927
+ import path9 from "path";
3928
+ function mimeFromExtension(p) {
3929
+ return EXTENSION_TO_MIME[path9.extname(p).toLowerCase()] ?? null;
3930
+ }
3931
+ function isSupportedImagePath(p) {
3932
+ return mimeFromExtension(p) !== null;
3933
+ }
3934
+ function parseDataUriImage(uri) {
3935
+ const match = uri.match(/^data:(image\/[a-z0-9.+\-]+);base64,([A-Za-z0-9+/=]+)$/);
3936
+ if (!match) {
3937
+ return null;
3938
+ }
3939
+ const mimeType = match[1].toLowerCase();
3940
+ if (!SUPPORTED_MIMES.has(mimeType)) {
3941
+ return null;
3942
+ }
3943
+ const data = match[2];
3944
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
3945
+ const sizeBytes = Math.floor(data.length * 3 / 4) - padding;
3946
+ return { mimeType, data, sizeBytes };
3947
+ }
3948
+ function isSupportedDataUriImage(uri) {
3949
+ return parseDataUriImage(uri) !== null;
3950
+ }
3951
+ function formatSize(bytes) {
3952
+ if (bytes >= 1024 * 1024) {
3953
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
3954
+ }
3955
+ if (bytes >= 1024) {
3956
+ return `${(bytes / 1024).toFixed(0)}KB`;
3957
+ }
3958
+ return `${bytes}B`;
3959
+ }
3960
+ function parseImageDropPaste(raw) {
3961
+ const text = raw.trim();
3962
+ if (text.length === 0) {
3963
+ return null;
3964
+ }
3965
+ const tokens = [];
3966
+ let i = 0;
3967
+ while (i < text.length) {
3968
+ while (i < text.length && /\s/.test(text[i] ?? "")) {
3969
+ i++;
3970
+ }
3971
+ if (i >= text.length) {
3972
+ break;
3973
+ }
3974
+ const ch = text[i];
3975
+ let token = "";
3976
+ if (ch === "'" || ch === '"') {
3977
+ const quote = ch;
3978
+ i++;
3979
+ while (i < text.length && text[i] !== quote) {
3980
+ token += text[i];
3981
+ i++;
3982
+ }
3983
+ if (i >= text.length) {
3984
+ return null;
3985
+ }
3986
+ i++;
3987
+ } else {
3988
+ while (i < text.length && !/\s/.test(text[i] ?? "")) {
3989
+ if (text[i] === "\\" && i + 1 < text.length) {
3990
+ token += text[i + 1];
3991
+ i += 2;
3992
+ } else {
3993
+ token += text[i];
3994
+ i++;
3995
+ }
3996
+ }
3997
+ }
3998
+ if (token.startsWith("data:")) {
3999
+ if (!isSupportedDataUriImage(token)) {
4000
+ return null;
4001
+ }
4002
+ tokens.push(token);
4003
+ continue;
4004
+ }
4005
+ let normalized = token;
4006
+ if (normalized.startsWith("file://")) {
4007
+ normalized = decodeURI(normalized.slice("file://".length));
4008
+ }
4009
+ if (!normalized.startsWith("/")) {
4010
+ return null;
4011
+ }
4012
+ if (!isSupportedImagePath(normalized)) {
4013
+ return null;
4014
+ }
4015
+ tokens.push(normalized);
4016
+ }
4017
+ return tokens.length > 0 ? tokens : null;
4018
+ }
4019
+ var MAX_ATTACHMENT_BYTES, EXTENSION_TO_MIME, SUPPORTED_MIMES;
4020
+ var init_attachments = __esm({
4021
+ "src/tui/attachments.ts"() {
4022
+ "use strict";
4023
+ MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
4024
+ EXTENSION_TO_MIME = {
4025
+ ".png": "image/png",
4026
+ ".jpg": "image/jpeg",
4027
+ ".jpeg": "image/jpeg",
4028
+ ".gif": "image/gif",
4029
+ ".webp": "image/webp"
4030
+ };
4031
+ SUPPORTED_MIMES = new Set(Object.values(EXTENSION_TO_MIME));
4032
+ }
4033
+ });
4034
+
3862
4035
  // src/tui/screen.ts
3863
4036
  import stringWidth from "string-width";
3864
4037
  import wrapAnsi from "wrap-ansi";
@@ -3867,7 +4040,8 @@ function formattedLineSig(zone, width, line, highlight2 = null, activeCol = null
3867
4040
  if (!line) {
3868
4041
  return `${zone}|${width}|empty|${highlight2 ?? ""}|${active}`;
3869
4042
  }
3870
- return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}|${highlight2 ?? ""}|${active}`;
4043
+ const img = line.iterm2Image ? `i${line.iterm2Image.heightCells}:${line.iterm2Image.data.length}` : "";
4044
+ return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}|${highlight2 ?? ""}|${active}|${img}`;
3871
4045
  }
3872
4046
  function computePromptVisualRows(buffer, room) {
3873
4047
  const rows = [];
@@ -4115,6 +4289,14 @@ function* segmentForWidth(text) {
4115
4289
  i = runEnd;
4116
4290
  }
4117
4291
  }
4292
+ function buildIterm2ImageEscape(base64, heightCells, insideTmux) {
4293
+ const inner = `\x1B]1337;File=inline=1;height=${heightCells};preserveAspectRatio=1:${base64}\x07`;
4294
+ if (!insideTmux) {
4295
+ return inner;
4296
+ }
4297
+ const doubled = inner.replace(/\x1b/g, "\x1B\x1B");
4298
+ return `\x1BPtmux;${doubled}\x1B\\`;
4299
+ }
4118
4300
  function wrap(text, width, opts = {}) {
4119
4301
  if (width <= 0) {
4120
4302
  return [text];
@@ -4364,6 +4546,8 @@ function mapKeyName(name) {
4364
4546
  return "ctrl-s";
4365
4547
  case "CTRL_U":
4366
4548
  return "ctrl-u";
4549
+ case "CTRL_V":
4550
+ return "ctrl-v";
4367
4551
  case "CTRL_W":
4368
4552
  return "ctrl-w";
4369
4553
  case "CTRL_Y":
@@ -4374,13 +4558,14 @@ function mapKeyName(name) {
4374
4558
  return null;
4375
4559
  }
4376
4560
  }
4377
- var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, TK_MARKUP_STYLE_CHAR, shortId;
4561
+ var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, MAX_CHIP_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, TK_MARKUP_STYLE_CHAR, shortId;
4378
4562
  var init_screen = __esm({
4379
4563
  "src/tui/screen.ts"() {
4380
4564
  "use strict";
4381
4565
  init_agent_display();
4382
4566
  init_paths();
4383
4567
  init_session();
4568
+ init_attachments();
4384
4569
  HEADER_ROWS = 2;
4385
4570
  BANNER_ROWS = 1;
4386
4571
  SEPARATOR_ROWS = 1;
@@ -4388,6 +4573,7 @@ var init_screen = __esm({
4388
4573
  MAX_QUEUED_ROWS = 5;
4389
4574
  MAX_PERMISSION_ROWS = 12;
4390
4575
  MAX_COMPLETION_ROWS = 6;
4576
+ MAX_CHIP_ROWS = 4;
4391
4577
  CONFIRM_PROMPT_ROWS = 2;
4392
4578
  DEFAULT_CONTENT_REPAINT_THROTTLE_MS = 1e3;
4393
4579
  DEFAULT_MAX_SCROLLBACK_LINES = 1e4;
@@ -4407,6 +4593,12 @@ var init_screen = __esm({
4407
4593
  lastPromptRows = 0;
4408
4594
  queuedTexts = [];
4409
4595
  lastQueueEditingIndex = -1;
4596
+ // Attachments on the current draft, pushed by the app whenever the
4597
+ // dispatcher mutates. The chip zone (drawAttachmentChipZone) renders
4598
+ // one row per attachment plus, in iTerm2-capable terminals, an inline
4599
+ // thumbnail. Capped at MAX_CHIP_ROWS in the visible zone — additional
4600
+ // chips collapse into an overflow row.
4601
+ attachments = [];
4410
4602
  repaintPaused = 0;
4411
4603
  repaintPending = false;
4412
4604
  lastRepaintAt = 0;
@@ -4469,7 +4661,7 @@ var init_screen = __esm({
4469
4661
  banner = {
4470
4662
  status: "ready",
4471
4663
  planMode: false,
4472
- hint: "\u21E7\u21E5 plan \xB7 \u2325\u23CE newline \xB7 \u2303P pick \xB7 \u2303C cancel \xB7 \u2303D detach",
4664
+ hint: "\u21E7\u21E5 plan \xB7 \u2325\u23CE newline \xB7 \u2303V paste \xB7 \u2303P pick \xB7 \u2303C cancel \xB7 \u2303D detach",
4473
4665
  queued: 0
4474
4666
  };
4475
4667
  header = { agent: "?", cwd: "?", sessionId: "?" };
@@ -4600,7 +4792,12 @@ var init_screen = __esm({
4600
4792
  this.pasteActive = false;
4601
4793
  const pasted = Buffer.from(this.pasteBuffer, "binary").toString("utf-8").replace(/\r\n?/g, "\n");
4602
4794
  this.pasteBuffer = "";
4603
- this.onKey([{ type: "paste", text: pasted }]);
4795
+ const paths2 = parseImageDropPaste(pasted);
4796
+ if (paths2 !== null) {
4797
+ this.onKey([{ type: "attachment-paths", paths: paths2 }]);
4798
+ } else {
4799
+ this.onKey([{ type: "paste", text: pasted }]);
4800
+ }
4604
4801
  continue;
4605
4802
  }
4606
4803
  const startIdx = text.indexOf(startMarker);
@@ -5351,7 +5548,7 @@ var init_screen = __esm({
5351
5548
  }
5352
5549
  scrollbackVisibleRows() {
5353
5550
  const top = HEADER_ROWS + SEPARATOR_ROWS;
5354
- const bottom = this.term.height - this.promptRows() - BANNER_ROWS - SEPARATOR_ROWS - this.queuedRows() - this.completionRows();
5551
+ const bottom = this.term.height - this.promptRows() - BANNER_ROWS - SEPARATOR_ROWS - this.chipRows() - this.queuedRows() - this.completionRows();
5355
5552
  return Math.max(0, bottom - top + 1);
5356
5553
  }
5357
5554
  maxScrollOffset() {
@@ -5433,6 +5630,7 @@ var init_screen = __esm({
5433
5630
  this.drawScrollback();
5434
5631
  this.drawCompletionZone();
5435
5632
  this.drawQueuedZone();
5633
+ this.drawAttachmentChipZone();
5436
5634
  const promptRows = this.promptRows();
5437
5635
  const separatorRow = h - promptRows - BANNER_ROWS;
5438
5636
  this.drawSeparator(separatorRow);
@@ -5524,6 +5722,16 @@ var init_screen = __esm({
5524
5722
  queuedRows() {
5525
5723
  return Math.min(MAX_QUEUED_ROWS, this.queuedTexts.length);
5526
5724
  }
5725
+ chipRows() {
5726
+ return Math.min(MAX_CHIP_ROWS, this.attachments.length);
5727
+ }
5728
+ setAttachments(attachments) {
5729
+ if (this.attachments.length === attachments.length && this.attachments.every((a, i) => a === attachments[i])) {
5730
+ return;
5731
+ }
5732
+ this.attachments = [...attachments];
5733
+ this.repaint();
5734
+ }
5527
5735
  completionRows() {
5528
5736
  if (this.permissionPrompt) {
5529
5737
  return 0;
@@ -5539,7 +5747,8 @@ var init_screen = __esm({
5539
5747
  const promptRows = this.promptRows();
5540
5748
  const separatorRow = this.term.height - promptRows - BANNER_ROWS;
5541
5749
  const queuedRows = this.queuedRows();
5542
- const completionBottom = separatorRow - 1 - queuedRows;
5750
+ const chipRows = this.chipRows();
5751
+ const completionBottom = separatorRow - 1 - queuedRows - chipRows;
5543
5752
  const completionTop = completionBottom - rows + 1;
5544
5753
  let nameWidth = 0;
5545
5754
  for (const item of this.completions.slice(0, rows)) {
@@ -5572,6 +5781,58 @@ var init_screen = __esm({
5572
5781
  });
5573
5782
  }
5574
5783
  }
5784
+ // Chip zone: one row per attached image, sitting between the queued
5785
+ // zone and the separator (closest to the user's draft). Each row
5786
+ // shows "📎 <name> · <size>" plus, in iTerm2-capable terminals, a
5787
+ // tiny inline thumbnail at the end. Overflow collapses into a
5788
+ // single "+ N more attached" row.
5789
+ drawAttachmentChipZone() {
5790
+ const rows = this.chipRows();
5791
+ if (rows === 0) {
5792
+ return;
5793
+ }
5794
+ const w = this.term.width;
5795
+ const promptRows = this.promptRows();
5796
+ const separatorRow = this.term.height - promptRows - BANNER_ROWS;
5797
+ const chipBottom = separatorRow - 1;
5798
+ const chipTop = chipBottom - rows + 1;
5799
+ const iterm = this.isIterm2();
5800
+ for (let i = 0; i < rows; i++) {
5801
+ const row = chipTop + i;
5802
+ const isLast = i === rows - 1 && this.attachments.length > MAX_CHIP_ROWS;
5803
+ const overflow = this.attachments.length - MAX_CHIP_ROWS;
5804
+ const att = this.attachments[i];
5805
+ const label = att ? `${att.name ?? "image"} \xB7 ${formatSize(att.sizeBytes)}` : "";
5806
+ const sig = isLast ? `chip|${w}|overflow|${overflow}` : att ? `chip|${w}|${iterm ? "i" : "t"}|${label}|${att.sizeBytes}` : `chip|${w}|empty`;
5807
+ this.paintRow(row, sig, () => {
5808
+ if (isLast) {
5809
+ this.term.dim(` \u{1F4CE} + ${overflow + 1} more attached`);
5810
+ return;
5811
+ }
5812
+ if (!att) {
5813
+ return;
5814
+ }
5815
+ this.term(" ").yellow(`\u{1F4CE} ${label}`);
5816
+ if (iterm) {
5817
+ this.term(" ");
5818
+ this.writeIterm2Image(att.data, 1);
5819
+ }
5820
+ });
5821
+ }
5822
+ }
5823
+ isIterm2() {
5824
+ const env = process.env;
5825
+ return env.LC_TERMINAL === "iTerm2" || env.TERM_PROGRAM === "iTerm.app";
5826
+ }
5827
+ // Emits the iTerm2 OSC 1337 inline image escape at the current
5828
+ // cursor position. Wraps in DCS-passthrough when tmux is detected
5829
+ // (requires `set -g allow-passthrough on` in the user's tmux conf).
5830
+ // Caller is responsible for knowing iTerm2 is the active terminal.
5831
+ writeIterm2Image(base64, heightCells) {
5832
+ process.stdout.write(
5833
+ buildIterm2ImageEscape(base64, heightCells, Boolean(process.env.TMUX))
5834
+ );
5835
+ }
5575
5836
  drawQueuedZone() {
5576
5837
  const rows = this.queuedRows();
5577
5838
  if (rows === 0) {
@@ -5580,7 +5841,8 @@ var init_screen = __esm({
5580
5841
  const w = this.term.width;
5581
5842
  const promptRows = this.promptRows();
5582
5843
  const separatorRow = this.term.height - promptRows - BANNER_ROWS;
5583
- const queuedBottom = separatorRow - 1;
5844
+ const chipRows = this.chipRows();
5845
+ const queuedBottom = separatorRow - 1 - chipRows;
5584
5846
  const queuedTop = queuedBottom - rows + 1;
5585
5847
  const editingIndex = this.dispatcher.state().queueIndex;
5586
5848
  for (let i = 0; i < rows; i++) {
@@ -5729,6 +5991,8 @@ var init_screen = __esm({
5729
5991
  }
5730
5992
  } else if (this.banner.status === "disconnected") {
5731
5993
  this.term.brightRed(`${dot} ${this.banner.status}`);
5994
+ } else if (this.banner.status === "cold") {
5995
+ this.term.brightMagenta(`${dot} ${this.banner.status}`);
5732
5996
  } else {
5733
5997
  this.term.brightGreen(`${dot} ${this.banner.status}`);
5734
5998
  }
@@ -5883,6 +6147,9 @@ var init_screen = __esm({
5883
6147
  if (line.ansi) {
5884
6148
  wrappedLine.ansi = true;
5885
6149
  }
6150
+ if (i === 0 && line.iterm2Image) {
6151
+ wrappedLine.iterm2Image = line.iterm2Image;
6152
+ }
5886
6153
  if (id !== void 0 && chunk.length > 0) {
5887
6154
  const found = line.body.indexOf(chunk, scanPos);
5888
6155
  const colOffset = found === -1 ? scanPos : found;
@@ -5928,6 +6195,12 @@ var init_screen = __esm({
5928
6195
  if (line.ansi || line.body.includes("^")) {
5929
6196
  this.term.styleReset();
5930
6197
  }
6198
+ if (line.iterm2Image && this.isIterm2()) {
6199
+ this.writeIterm2Image(
6200
+ line.iterm2Image.data,
6201
+ line.iterm2Image.heightCells
6202
+ );
6203
+ }
5931
6204
  }
5932
6205
  };
5933
6206
  NON_ASCII = /[^\x20-\x7e]/;
@@ -5972,6 +6245,17 @@ var init_input = __esm({
5972
6245
  // here so ^Y can yank it back. Standard readline keeps a stack; we
5973
6246
  // only keep one slot because that's what 99% of yank uses look like.
5974
6247
  killBuffer = "";
6248
+ // Images attached to the current draft. Cleared in the same paths
6249
+ // that clear the text buffer (clearBuffer, after send). Queue
6250
+ // navigation snapshots/restores them alongside savedDraft so up/down
6251
+ // through queued items doesn't drop chips.
6252
+ attachments = [];
6253
+ // Snapshot of `attachments` taken when the user starts walking
6254
+ // history/queue with chips already attached. Restored alongside the
6255
+ // text draft when the walk ends. Distinct from savedDraft because
6256
+ // queue slots (which may carry their own attachments — though we
6257
+ // don't surface that yet) shouldn't blend with the current draft's.
6258
+ savedAttachments = null;
5975
6259
  constructor(opts = {}) {
5976
6260
  this.history = [...opts.history ?? []];
5977
6261
  this.planMode = opts.planMode ?? false;
@@ -5984,9 +6268,22 @@ var init_input = __esm({
5984
6268
  planMode: this.planMode,
5985
6269
  historyIndex: this.historyIndex,
5986
6270
  queueIndex: this.queueIndex,
6271
+ attachments: [...this.attachments],
5987
6272
  historySearchQuery: this.historySearch?.query ?? null
5988
6273
  };
5989
6274
  }
6275
+ // App calls this after asynchronously acquiring an image (drag-drop
6276
+ // file read, clipboard shellout). The dispatcher just records it;
6277
+ // chip rendering and capability gating live in the app/screen layer.
6278
+ addAttachment(attachment) {
6279
+ this.attachments.push(attachment);
6280
+ }
6281
+ removeAttachment(index) {
6282
+ if (index < 0 || index >= this.attachments.length) {
6283
+ return;
6284
+ }
6285
+ this.attachments.splice(index, 1);
6286
+ }
5990
6287
  setTurnRunning(running) {
5991
6288
  this.turnRunning = running;
5992
6289
  }
@@ -6017,13 +6314,17 @@ var init_input = __esm({
6017
6314
  }
6018
6315
  // Public seed for the buffer (used for Escape pre-fill). Treated like a
6019
6316
  // fresh draft: nav state and any saved draft are cleared, cursor lands
6020
- // at the end so the user can edit immediately.
6021
- setBuffer(text) {
6317
+ // at the end so the user can edit immediately. Attachments restore
6318
+ // alongside the text so a cancelled turn's chips land back in the
6319
+ // draft together with the typed prompt.
6320
+ setBuffer(text, attachments = []) {
6022
6321
  this.loadEntry(text);
6023
6322
  this.historyIndex = -1;
6024
6323
  this.queueIndex = -1;
6025
6324
  this.savedDraft = null;
6325
+ this.savedAttachments = null;
6026
6326
  this.historySearch = null;
6327
+ this.attachments = [...attachments];
6027
6328
  }
6028
6329
  feed(event) {
6029
6330
  if (this.historySearch !== null) {
@@ -6069,6 +6370,9 @@ var init_input = __esm({
6069
6370
  this.insertText(event.text);
6070
6371
  return [];
6071
6372
  }
6373
+ if (event.type === "attachment-paths") {
6374
+ return [];
6375
+ }
6072
6376
  return this.handleKey(event.name);
6073
6377
  }
6074
6378
  handleKey(name) {
@@ -6145,6 +6449,8 @@ var init_input = __esm({
6145
6449
  case "ctrl-u":
6146
6450
  this.killLine();
6147
6451
  return [];
6452
+ case "ctrl-v":
6453
+ return [{ type: "attachment-request", source: "clipboard" }];
6148
6454
  case "ctrl-w":
6149
6455
  this.killWord();
6150
6456
  return [];
@@ -6177,7 +6483,9 @@ var init_input = __esm({
6177
6483
  this.historyIndex = -1;
6178
6484
  this.queueIndex = -1;
6179
6485
  this.savedDraft = null;
6486
+ this.savedAttachments = null;
6180
6487
  this.historySearch = null;
6488
+ this.attachments = [];
6181
6489
  }
6182
6490
  insertChar(ch) {
6183
6491
  if (ch.length === 0) {
@@ -6329,6 +6637,8 @@ var init_input = __esm({
6329
6637
  row: this.row,
6330
6638
  col: this.col
6331
6639
  };
6640
+ this.savedAttachments = [...this.attachments];
6641
+ this.attachments = [];
6332
6642
  if (this.queue.length > 0) {
6333
6643
  this.queueIndex = this.queue.length - 1;
6334
6644
  this.loadEntry(this.queue[this.queueIndex] ?? "");
@@ -6401,6 +6711,8 @@ var init_input = __esm({
6401
6711
  this.row = this.savedDraft.row;
6402
6712
  this.col = this.savedDraft.col;
6403
6713
  this.savedDraft = null;
6714
+ this.attachments = this.savedAttachments ?? [];
6715
+ this.savedAttachments = null;
6404
6716
  } else {
6405
6717
  this.clearBuffer();
6406
6718
  }
@@ -6554,18 +6866,20 @@ var init_input = __esm({
6554
6866
  const text = this.bufferText();
6555
6867
  if (this.queueIndex >= 0 && this.queueIndex < this.queue.length) {
6556
6868
  const index = this.queueIndex;
6869
+ const attachments2 = [...this.attachments];
6557
6870
  this.clearBuffer();
6558
6871
  if (text.trim().length === 0) {
6559
6872
  return [{ type: "queue-remove", index }];
6560
6873
  }
6561
- return [{ type: "queue-edit", index, text }];
6874
+ return [{ type: "queue-edit", index, text, attachments: attachments2 }];
6562
6875
  }
6563
- if (text.trim().length === 0) {
6876
+ if (text.trim().length === 0 && this.attachments.length === 0) {
6564
6877
  return [];
6565
6878
  }
6566
6879
  const planMode = this.planMode;
6880
+ const attachments = [...this.attachments];
6567
6881
  this.clearBuffer();
6568
- return [{ type: "send", text, planMode }];
6882
+ return [{ type: "send", text, planMode, attachments }];
6569
6883
  }
6570
6884
  // Home: jump to the very start of the prompt buffer. If we're already
6571
6885
  // there, fall through to scrolling the scrollback to its top.
@@ -6590,19 +6904,20 @@ var init_input = __esm({
6590
6904
  return [{ type: "scroll-to-bottom" }];
6591
6905
  }
6592
6906
  handleCtrlC() {
6593
- if (!this.bufferIsEmpty()) {
6594
- this.buffer = [""];
6595
- this.row = 0;
6596
- this.col = 0;
6597
- if (this.queueIndex === -1) {
6598
- this.historyIndex = -1;
6599
- this.savedDraft = null;
6600
- }
6601
- return [];
6602
- }
6603
6907
  if (this.queueIndex >= 0) {
6908
+ const index = this.queueIndex;
6604
6909
  this.queueIndex = -1;
6605
6910
  this.restoreDraft();
6911
+ return [{ type: "queue-remove", index }];
6912
+ }
6913
+ if (!this.bufferIsEmpty() || this.attachments.length > 0) {
6914
+ this.buffer = [""];
6915
+ this.row = 0;
6916
+ this.col = 0;
6917
+ this.attachments = [];
6918
+ this.historyIndex = -1;
6919
+ this.savedDraft = null;
6920
+ this.savedAttachments = null;
6606
6921
  return [];
6607
6922
  }
6608
6923
  if (this.turnRunning) {
@@ -6614,6 +6929,232 @@ var init_input = __esm({
6614
6929
  }
6615
6930
  });
6616
6931
 
6932
+ // src/tui/clipboard.ts
6933
+ import { spawn as nodeSpawn } from "child_process";
6934
+ import fs14 from "fs/promises";
6935
+ import os4 from "os";
6936
+ import path10 from "path";
6937
+ async function readClipboard(envIn = {}) {
6938
+ const env = { ...defaultEnv, ...envIn };
6939
+ if (env.platform === "darwin") {
6940
+ return readMacOS(env);
6941
+ }
6942
+ if (env.platform === "linux") {
6943
+ return readLinux(env);
6944
+ }
6945
+ return {
6946
+ ok: false,
6947
+ reason: `clipboard paste is not supported on ${env.platform}`
6948
+ };
6949
+ }
6950
+ async function readMacOS(env) {
6951
+ const tmpPath = path10.join(
6952
+ env.tmpdir(),
6953
+ `hydra-clipboard-${Date.now()}-${process.pid}.png`
6954
+ );
6955
+ const script = [
6956
+ "set png_data to the clipboard as \xABclass PNGf\xBB",
6957
+ `set out_file to (open for access (POSIX file "${tmpPath}") with write permission)`,
6958
+ "write png_data to out_file",
6959
+ "close access out_file"
6960
+ ];
6961
+ const args = [];
6962
+ for (const line of script) {
6963
+ args.push("-e", line);
6964
+ }
6965
+ try {
6966
+ await run2(env.spawn, "osascript", args);
6967
+ const img = await readFileAsAttachment(tmpPath, true);
6968
+ if (img.ok) {
6969
+ return img;
6970
+ }
6971
+ if (img.reason.startsWith("clipboard image is")) {
6972
+ return img;
6973
+ }
6974
+ } catch {
6975
+ await fs14.unlink(tmpPath).catch(() => void 0);
6976
+ }
6977
+ try {
6978
+ const buf = await runCapture(env.spawn, "pbpaste", []);
6979
+ if (buf.length === 0) {
6980
+ return { ok: false, reason: "clipboard is empty" };
6981
+ }
6982
+ return { ok: true, kind: "text", text: normalizeText(buf.toString("utf-8")) };
6983
+ } catch {
6984
+ return { ok: false, reason: "clipboard read failed" };
6985
+ }
6986
+ }
6987
+ async function readLinux(env) {
6988
+ const tool = await detectLinuxTool(env);
6989
+ if (!tool) {
6990
+ return {
6991
+ ok: false,
6992
+ reason: "install wl-clipboard (Wayland) or xclip (X11) to paste from the clipboard"
6993
+ };
6994
+ }
6995
+ try {
6996
+ const buf = await runCapture(env.spawn, tool.cmd, tool.imageArgs);
6997
+ if (buf.length > 0) {
6998
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
6999
+ return {
7000
+ ok: false,
7001
+ reason: `clipboard image is ${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)}`
7002
+ };
7003
+ }
7004
+ return {
7005
+ ok: true,
7006
+ kind: "image",
7007
+ attachment: {
7008
+ mimeType: "image/png",
7009
+ data: buf.toString("base64"),
7010
+ sizeBytes: buf.length
7011
+ }
7012
+ };
7013
+ }
7014
+ } catch {
7015
+ }
7016
+ try {
7017
+ const buf = await runCapture(env.spawn, tool.cmd, tool.textArgs);
7018
+ if (buf.length === 0) {
7019
+ return { ok: false, reason: "clipboard is empty" };
7020
+ }
7021
+ return {
7022
+ ok: true,
7023
+ kind: "text",
7024
+ text: normalizeText(buf.toString("utf-8"))
7025
+ };
7026
+ } catch {
7027
+ return { ok: false, reason: "clipboard read failed" };
7028
+ }
7029
+ }
7030
+ async function detectLinuxTool(env) {
7031
+ if (env.env.WAYLAND_DISPLAY && await which(env, "wl-paste")) {
7032
+ return {
7033
+ cmd: "wl-paste",
7034
+ imageArgs: ["-t", "image/png"],
7035
+ // -n: drop trailing newline wl-paste adds by default. We further
7036
+ // normalize line endings below, but this avoids a spurious
7037
+ // empty trailing row from a single-line clipboard text.
7038
+ textArgs: ["-n"]
7039
+ };
7040
+ }
7041
+ if (env.env.DISPLAY && await which(env, "xclip")) {
7042
+ return {
7043
+ cmd: "xclip",
7044
+ imageArgs: ["-selection", "clipboard", "-t", "image/png", "-o"],
7045
+ textArgs: ["-selection", "clipboard", "-o"]
7046
+ };
7047
+ }
7048
+ return null;
7049
+ }
7050
+ function normalizeText(text) {
7051
+ return text.replace(/\r\n?/g, "\n");
7052
+ }
7053
+ async function which(env, cmd) {
7054
+ try {
7055
+ await run2(env.spawn, "which", [cmd]);
7056
+ return true;
7057
+ } catch {
7058
+ return false;
7059
+ }
7060
+ }
7061
+ async function readFileAsAttachment(p, unlinkAfter) {
7062
+ try {
7063
+ const buf = await fs14.readFile(p);
7064
+ if (unlinkAfter) {
7065
+ await fs14.unlink(p).catch(() => void 0);
7066
+ }
7067
+ if (buf.length === 0) {
7068
+ return { ok: false, reason: "no image on clipboard" };
7069
+ }
7070
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
7071
+ return {
7072
+ ok: false,
7073
+ reason: `clipboard image is ${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)}`
7074
+ };
7075
+ }
7076
+ const mimeType = mimeFromExtension(p) ?? "image/png";
7077
+ return {
7078
+ ok: true,
7079
+ kind: "image",
7080
+ attachment: {
7081
+ mimeType,
7082
+ data: buf.toString("base64"),
7083
+ sizeBytes: buf.length
7084
+ }
7085
+ };
7086
+ } catch {
7087
+ return { ok: false, reason: "failed to read clipboard image" };
7088
+ }
7089
+ }
7090
+ function run2(spawn6, cmd, args) {
7091
+ return new Promise((resolve5, reject) => {
7092
+ const proc = spawn6(cmd, args);
7093
+ proc.stdout?.on("data", () => void 0);
7094
+ proc.stderr?.on("data", () => void 0);
7095
+ proc.on("error", reject);
7096
+ proc.on("close", (code) => {
7097
+ if (code === 0) {
7098
+ resolve5();
7099
+ } else {
7100
+ reject(new Error(`${cmd} exited ${code}`));
7101
+ }
7102
+ });
7103
+ });
7104
+ }
7105
+ function runCapture(spawn6, cmd, args) {
7106
+ return new Promise((resolve5, reject) => {
7107
+ const proc = spawn6(cmd, args);
7108
+ const chunks = [];
7109
+ let stdoutEnded = proc.stdout === null;
7110
+ let closedCode = null;
7111
+ let settled = false;
7112
+ const settle = () => {
7113
+ if (settled || !stdoutEnded || closedCode === null) {
7114
+ return;
7115
+ }
7116
+ settled = true;
7117
+ if (closedCode === 0) {
7118
+ resolve5(Buffer.concat(chunks));
7119
+ } else {
7120
+ reject(new Error(`${cmd} exited ${closedCode}`));
7121
+ }
7122
+ };
7123
+ proc.stdout?.on("data", (chunk) => {
7124
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
7125
+ });
7126
+ proc.stdout?.on("end", () => {
7127
+ stdoutEnded = true;
7128
+ settle();
7129
+ });
7130
+ proc.stderr?.on("data", () => void 0);
7131
+ proc.on("error", (err) => {
7132
+ if (settled) {
7133
+ return;
7134
+ }
7135
+ settled = true;
7136
+ reject(err);
7137
+ });
7138
+ proc.on("close", (code) => {
7139
+ closedCode = code ?? 0;
7140
+ settle();
7141
+ });
7142
+ });
7143
+ }
7144
+ var defaultEnv;
7145
+ var init_clipboard = __esm({
7146
+ "src/tui/clipboard.ts"() {
7147
+ "use strict";
7148
+ init_attachments();
7149
+ defaultEnv = {
7150
+ platform: process.platform,
7151
+ env: process.env,
7152
+ spawn: nodeSpawn,
7153
+ tmpdir: os4.tmpdir
7154
+ };
7155
+ }
7156
+ });
7157
+
6617
7158
  // src/tui/completion.ts
6618
7159
  function longestCommonPrefix(names) {
6619
7160
  if (names.length === 0) {
@@ -6948,8 +7489,29 @@ import chalk from "chalk";
6948
7489
  import { highlight, supportsLanguage } from "cli-highlight";
6949
7490
  function formatEvent(event) {
6950
7491
  switch (event.kind) {
6951
- case "user-text":
6952
- return formatBlock(event.text, "\u258E ", "user", void 0, event.sentBy, true);
7492
+ case "user-text": {
7493
+ const lines = formatBlock(
7494
+ event.text,
7495
+ "\u258E ",
7496
+ "user",
7497
+ void 0,
7498
+ event.sentBy,
7499
+ true
7500
+ );
7501
+ if (event.attachments && event.attachments.length > 0) {
7502
+ for (const a of event.attachments) {
7503
+ lines.push({
7504
+ prefix: "\u258E ",
7505
+ prefixStyle: "user",
7506
+ body: `\u{1F4CE} ${a.name ?? "image"}`,
7507
+ bodyStyle: "user",
7508
+ fillRow: true,
7509
+ iterm2Image: { data: a.data, heightCells: 5 }
7510
+ });
7511
+ }
7512
+ }
7513
+ return lines;
7514
+ }
6953
7515
  case "agent-text":
6954
7516
  return formatBlock(event.text, " ", "agent");
6955
7517
  case "agent-thought":
@@ -7279,6 +7841,8 @@ var init_format = __esm({
7279
7841
  import { appendFileSync, statSync, renameSync } from "fs";
7280
7842
  import { nanoid as nanoid3 } from "nanoid";
7281
7843
  import termkit from "terminal-kit";
7844
+ import fs15 from "fs/promises";
7845
+ import path11 from "path";
7282
7846
  async function runTuiApp(opts) {
7283
7847
  const config = await ensureConfig();
7284
7848
  logMaxBytes = config.tui.logMaxBytes;
@@ -7396,6 +7960,15 @@ async function runSession(term, config, opts, exitHint) {
7396
7960
  appendRender(event);
7397
7961
  maybeDismissPermissionByToolUpdate(update);
7398
7962
  });
7963
+ conn.onNotification("hydra-acp/session_closed", () => {
7964
+ if (pendingTurns > 0) {
7965
+ adjustPendingTurns(-pendingTurns);
7966
+ }
7967
+ const screenReady = typeof screenRef !== "undefined" && screenRef !== null;
7968
+ if (screenReady) {
7969
+ screenRef.setBanner({ status: "cold", elapsedMs: void 0 });
7970
+ }
7971
+ });
7399
7972
  const handlePermissionResolved = (update) => {
7400
7973
  const u = update ?? {};
7401
7974
  const toolCallId = typeof u.toolCallId === "string" ? u.toolCallId : void 0;
@@ -7499,6 +8072,7 @@ async function runSession(term, config, opts, exitHint) {
7499
8072
  });
7500
8073
  let upstreamSessionId;
7501
8074
  let agentInfoName;
8075
+ let agentAcceptsImages = true;
7502
8076
  try {
7503
8077
  const initResult = await conn.request("initialize", {
7504
8078
  protocolVersion: ACP_PROTOCOL_VERSION,
@@ -7509,6 +8083,10 @@ async function runSession(term, config, opts, exitHint) {
7509
8083
  clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
7510
8084
  });
7511
8085
  agentInfoName = initResult?.agentInfo?.name;
8086
+ const imageCap = initResult?.agentCapabilities?.promptCapabilities?.image;
8087
+ if (imageCap === false) {
8088
+ agentAcceptsImages = false;
8089
+ }
7512
8090
  } catch {
7513
8091
  }
7514
8092
  let resolvedSessionId = ctx.sessionId;
@@ -7607,6 +8185,10 @@ async function runSession(term, config, opts, exitHint) {
7607
8185
  if (tryHandleCompletionKey(ev)) {
7608
8186
  continue;
7609
8187
  }
8188
+ if (ev.type === "attachment-paths") {
8189
+ void handleAttachmentPaths(ev.paths);
8190
+ continue;
8191
+ }
7610
8192
  const effects = dispatcher.feed(ev);
7611
8193
  for (const effect of effects) {
7612
8194
  handleEffect(effect);
@@ -7616,6 +8198,7 @@ async function runSession(term, config, opts, exitHint) {
7616
8198
  screen.setBannerSearchIndicator(
7617
8199
  dispatcher.state().historySearchQuery
7618
8200
  );
8201
+ screen.setAttachments(dispatcher.state().attachments);
7619
8202
  screen.refreshPrompt();
7620
8203
  }
7621
8204
  });
@@ -7906,7 +8489,8 @@ async function runSession(term, config, opts, exitHint) {
7906
8489
  const choice = await pickSession(term, {
7907
8490
  cwd: resolvedCwd,
7908
8491
  sessions,
7909
- config
8492
+ config,
8493
+ currentSessionId: resolvedSessionId
7910
8494
  });
7911
8495
  if (choice.kind === "abort") {
7912
8496
  screen.start();
@@ -7937,13 +8521,17 @@ async function runSession(term, config, opts, exitHint) {
7937
8521
  const handleEffect = (effect) => {
7938
8522
  switch (effect.type) {
7939
8523
  case "send":
7940
- enqueuePrompt(effect.text, effect.planMode);
8524
+ enqueuePrompt(effect.text, effect.planMode, effect.attachments);
7941
8525
  return;
7942
8526
  case "queue-edit": {
7943
8527
  const realIdx = effect.index + queueHeadOffset();
7944
8528
  const existing = promptQueue[realIdx];
7945
8529
  if (existing) {
7946
- promptQueue[realIdx] = { text: effect.text, planMode: existing.planMode };
8530
+ promptQueue[realIdx] = {
8531
+ text: effect.text,
8532
+ planMode: existing.planMode,
8533
+ attachments: effect.attachments
8534
+ };
7947
8535
  refreshQueueDisplay();
7948
8536
  }
7949
8537
  return;
@@ -7962,7 +8550,10 @@ async function runSession(term, config, opts, exitHint) {
7962
8550
  const waitingEmpty = promptQueue.length <= headOffset;
7963
8551
  const bufferEmpty = dispatcher.state().buffer.every((line) => line === "");
7964
8552
  if (waitingEmpty && bufferEmpty) {
7965
- pendingPrefill = turnInFlight.text;
8553
+ pendingPrefill = {
8554
+ text: turnInFlight.text,
8555
+ attachments: turnInFlight.attachments
8556
+ };
7966
8557
  }
7967
8558
  }
7968
8559
  if (turnInFlight) {
@@ -8001,17 +8592,104 @@ async function runSession(term, config, opts, exitHint) {
8001
8592
  screen.enterScrollbackSearch();
8002
8593
  screen.updateScrollbackSearchTerm(effect.query);
8003
8594
  return;
8595
+ case "attachment-request":
8596
+ void handleClipboardAttachment();
8597
+ return;
8004
8598
  }
8005
8599
  };
8600
+ const handleAttachmentPaths = async (tokens) => {
8601
+ if (!agentAcceptsImages) {
8602
+ screen.notify("agent does not accept image attachments");
8603
+ return;
8604
+ }
8605
+ let added = 0;
8606
+ for (const token of tokens) {
8607
+ if (token.startsWith("data:")) {
8608
+ const parsed = parseDataUriImage(token);
8609
+ if (!parsed) {
8610
+ screen.notify("unsupported data: URI");
8611
+ continue;
8612
+ }
8613
+ if (parsed.sizeBytes > MAX_ATTACHMENT_BYTES) {
8614
+ screen.notify(
8615
+ `image too large (${formatSize(parsed.sizeBytes)}, max ${formatSize(MAX_ATTACHMENT_BYTES)})`
8616
+ );
8617
+ continue;
8618
+ }
8619
+ dispatcher.addAttachment({
8620
+ mimeType: parsed.mimeType,
8621
+ data: parsed.data,
8622
+ name: "pasted image",
8623
+ sizeBytes: parsed.sizeBytes
8624
+ });
8625
+ added++;
8626
+ continue;
8627
+ }
8628
+ const mimeType = mimeFromExtension(token);
8629
+ if (!mimeType) {
8630
+ screen.notify(`unsupported image type: ${path11.basename(token)}`);
8631
+ continue;
8632
+ }
8633
+ try {
8634
+ const buf = await fs15.readFile(token);
8635
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
8636
+ screen.notify(
8637
+ `image too large (${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)})`
8638
+ );
8639
+ continue;
8640
+ }
8641
+ dispatcher.addAttachment({
8642
+ mimeType,
8643
+ data: buf.toString("base64"),
8644
+ name: path11.basename(token),
8645
+ sizeBytes: buf.length
8646
+ });
8647
+ added++;
8648
+ } catch (err) {
8649
+ screen.notify(
8650
+ `cannot read ${path11.basename(token)}: ${err.message}`
8651
+ );
8652
+ }
8653
+ }
8654
+ if (added > 0) {
8655
+ screen.setAttachments(dispatcher.state().attachments);
8656
+ screen.refreshPrompt();
8657
+ }
8658
+ };
8659
+ const handleClipboardAttachment = async () => {
8660
+ const result = await readClipboard();
8661
+ if (!result.ok) {
8662
+ screen.notify(result.reason);
8663
+ return;
8664
+ }
8665
+ if (result.kind === "image") {
8666
+ if (!agentAcceptsImages) {
8667
+ screen.notify("agent does not accept image attachments");
8668
+ return;
8669
+ }
8670
+ dispatcher.addAttachment(result.attachment);
8671
+ screen.setAttachments(dispatcher.state().attachments);
8672
+ screen.refreshPrompt();
8673
+ return;
8674
+ }
8675
+ const effects = dispatcher.feed({ type: "paste", text: result.text });
8676
+ for (const effect of effects) {
8677
+ handleEffect(effect);
8678
+ }
8679
+ screen.refreshPrompt();
8680
+ };
8006
8681
  const promptQueue = [];
8007
8682
  let workerActive = false;
8008
8683
  const refreshQueueDisplay = () => {
8009
8684
  const waiting = promptQueue.slice(workerActive ? 1 : 0);
8010
- screen.setQueuedPrompts(waiting.map((p) => p.text));
8685
+ const displayTexts = waiting.map(
8686
+ (p) => p.attachments.length > 0 ? `${p.text} \xB7 \u{1F4CE}\xD7${p.attachments.length}` : p.text
8687
+ );
8688
+ screen.setQueuedPrompts(displayTexts);
8011
8689
  screen.setBanner({ queued: waiting.length });
8012
8690
  dispatcher.setQueue(waiting.map((p) => p.text));
8013
8691
  };
8014
- const enqueuePrompt = (text, planMode) => {
8692
+ const enqueuePrompt = (text, planMode, attachments) => {
8015
8693
  screen.scrollToBottom();
8016
8694
  if (handleBuiltinCommand(text)) {
8017
8695
  return;
@@ -8019,7 +8697,7 @@ async function runSession(term, config, opts, exitHint) {
8019
8697
  history = appendEntry(history, text);
8020
8698
  dispatcher.setHistory(history);
8021
8699
  saveHistory(historyFile, history).catch(() => void 0);
8022
- promptQueue.push({ text, planMode });
8700
+ promptQueue.push({ text, planMode, attachments });
8023
8701
  refreshQueueDisplay();
8024
8702
  tickWorker();
8025
8703
  };
@@ -8186,31 +8864,38 @@ async function runSession(term, config, opts, exitHint) {
8186
8864
  break;
8187
8865
  }
8188
8866
  refreshQueueDisplay();
8189
- await processPrompt(next.text, next.planMode);
8867
+ await processPrompt(next.text, next.planMode, next.attachments);
8190
8868
  promptQueue.shift();
8191
8869
  }
8192
8870
  } finally {
8193
8871
  workerActive = false;
8194
8872
  refreshQueueDisplay();
8195
8873
  if (pendingPrefill !== null) {
8196
- const text = pendingPrefill;
8874
+ const { text, attachments } = pendingPrefill;
8197
8875
  pendingPrefill = null;
8198
8876
  const bufferEmpty = dispatcher.state().buffer.every((line) => line === "");
8199
8877
  if (bufferEmpty) {
8200
- dispatcher.setBuffer(text);
8878
+ dispatcher.setBuffer(text, attachments);
8201
8879
  screen.refreshPrompt();
8202
8880
  }
8203
8881
  }
8204
8882
  }
8205
8883
  };
8206
- const processPrompt = async (text, planMode) => {
8207
- const userBlocks = [{ type: "text", text }];
8884
+ const processPrompt = async (text, planMode, attachments) => {
8885
+ const userBlocks = [];
8886
+ if (text.length > 0) {
8887
+ userBlocks.push({ type: "text", text });
8888
+ }
8889
+ for (const a of attachments) {
8890
+ userBlocks.push({ type: "image", data: a.data, mimeType: a.mimeType });
8891
+ }
8208
8892
  const promptArr = planMode ? [{ type: "text", text: PLAN_PREFIX_TEXT }, ...userBlocks] : userBlocks;
8209
8893
  adjustPendingTurns(1);
8210
- appendRender({ kind: "user-text", text });
8894
+ appendRender({ kind: "user-text", text, attachments });
8211
8895
  let cancelled = false;
8212
8896
  turnInFlight = {
8213
8897
  text,
8898
+ attachments,
8214
8899
  cancel: () => {
8215
8900
  if (cancelled) {
8216
8901
  return;
@@ -8707,6 +9392,8 @@ var init_app = __esm({
8707
9392
  init_picker();
8708
9393
  init_screen();
8709
9394
  init_input();
9395
+ init_attachments();
9396
+ init_clipboard();
8710
9397
  init_completion();
8711
9398
  init_render_update();
8712
9399
  init_format();
package/dist/index.d.ts CHANGED
@@ -1777,6 +1777,7 @@ declare class Session {
1777
1777
  private runTitleRegen;
1778
1778
  private runInternalPrompt;
1779
1779
  private runAgentCommand;
1780
+ private runKillCommand;
1780
1781
  private buildSwitchTranscript;
1781
1782
  seedFromImport(): Promise<void>;
1782
1783
  private broadcastAgentSwitch;
package/dist/index.js CHANGED
@@ -1448,6 +1448,11 @@ var HYDRA_COMMANDS = [
1448
1448
  name: "hydra agent",
1449
1449
  argsHint: "<agent>",
1450
1450
  description: "Swap the agent backing this session, preserving context"
1451
+ },
1452
+ {
1453
+ verb: "kill",
1454
+ name: "hydra kill",
1455
+ description: "Close this session (kills the agent; record is kept so it can be resumed later)"
1451
1456
  }
1452
1457
  ];
1453
1458
  var VERB_INDEX = new Map(HYDRA_COMMANDS.map((c) => [c.verb, c]));
@@ -2152,6 +2157,8 @@ var Session = class {
2152
2157
  return this.runTitleCommand(arg);
2153
2158
  case "agent":
2154
2159
  return this.runAgentCommand(arg);
2160
+ case "kill":
2161
+ return this.runKillCommand();
2155
2162
  default: {
2156
2163
  const err = new Error(
2157
2164
  `no dispatcher for /hydra verb ${verb}`
@@ -2263,6 +2270,17 @@ var Session = class {
2263
2270
  return { stopReason: "end_turn" };
2264
2271
  });
2265
2272
  }
2273
+ // Close this session in-place. Bypasses enqueuePrompt deliberately so a
2274
+ // mid-turn /hydra kill takes effect immediately — agent.kill() will tear
2275
+ // down any in-flight request as a side effect. The record is kept
2276
+ // (deleteRecord:false) so the session goes cold and can be resurrected.
2277
+ // Returns end_turn so the prompt() caller's response resolves normally,
2278
+ // but every attached client has already received hydra-acp/session_closed
2279
+ // by the time this returns.
2280
+ async runKillCommand() {
2281
+ await this.close({ deleteRecord: false });
2282
+ return { stopReason: "end_turn" };
2283
+ }
2266
2284
  // Walk the persisted history and produce a labeled transcript suitable
2267
2285
  // for handing to a fresh agent. Includes user prompts, agent replies,
2268
2286
  // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydra-acp/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Multi-client ACP session daemon: spawn agents, attach over WSS, multiplex sessions across editors.",
5
5
  "license": "MIT",
6
6
  "type": "module",