@higherdev/cli 0.25.0 → 0.26.0

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.
@@ -0,0 +1,133 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { isAbsolute, basename, extname } from "node:path";
3
+ import { loadConfig } from "./config.js";
4
+ const MIME = {
5
+ ".avif": "image/avif", ".bmp": "image/bmp", ".gif": "image/gif", ".heic": "image/heic",
6
+ ".heif": "image/heif", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".png": "image/png",
7
+ ".tif": "image/tiff", ".tiff": "image/tiff", ".webp": "image/webp", ".pdf": "application/pdf", ".txt": "text/plain",
8
+ ".text": "text/plain", ".md": "text/markdown", ".markdown": "text/markdown",
9
+ ".json": "application/json", ".csv": "text/csv",
10
+ };
11
+ export function formatAttachmentBytes(size) {
12
+ if (size < 1024)
13
+ return `${size} B`;
14
+ if (size < 1024 * 1024)
15
+ return `${Math.round(size / 1024)} KB`;
16
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
17
+ }
18
+ export function attachmentChip(file) {
19
+ return `[${file.mime.startsWith("image/") ? "image" : "file"}: ${file.name} ${formatAttachmentBytes(file.size)}]`;
20
+ }
21
+ function shellWords(text) {
22
+ const words = [];
23
+ let word = "";
24
+ let quote = null;
25
+ let started = false;
26
+ for (let at = 0; at < text.length; at += 1) {
27
+ const char = text[at];
28
+ if (!quote && /\s/.test(char)) {
29
+ if (started) {
30
+ words.push(word);
31
+ word = "";
32
+ started = false;
33
+ }
34
+ continue;
35
+ }
36
+ if (char === "\\" && quote !== "'") {
37
+ at += 1;
38
+ if (at >= text.length)
39
+ return null;
40
+ word += text[at];
41
+ started = true;
42
+ continue;
43
+ }
44
+ if (char === "'" || char === '"') {
45
+ if (!quote) {
46
+ quote = char;
47
+ started = true;
48
+ continue;
49
+ }
50
+ if (quote === char) {
51
+ quote = null;
52
+ continue;
53
+ }
54
+ }
55
+ word += char;
56
+ started = true;
57
+ }
58
+ if (quote)
59
+ return null;
60
+ if (started)
61
+ words.push(word);
62
+ return words;
63
+ }
64
+ export function parseAttachArgs(argv) {
65
+ const rest = [];
66
+ let ticketKey;
67
+ let to;
68
+ for (let at = 0; at < argv.length; at += 1) {
69
+ const arg = argv[at];
70
+ if (arg === "--ticket") {
71
+ ticketKey = argv[++at]?.toUpperCase();
72
+ continue;
73
+ }
74
+ if (arg === "--to") {
75
+ const role = argv[++at];
76
+ if (role === "architect" || role === "orchestrator")
77
+ to = role;
78
+ else
79
+ throw new Error("--to must be architect or orchestrator.");
80
+ continue;
81
+ }
82
+ rest.push(arg);
83
+ }
84
+ if (rest.length !== 1 || Boolean(ticketKey) === Boolean(to)) {
85
+ throw new Error("usage: hd attach PATH --ticket KEY | --to architect|orchestrator");
86
+ }
87
+ if (ticketKey && !/^HD-[1-9][0-9]*$/i.test(ticketKey))
88
+ throw new Error("--ticket must be an HD-N key.");
89
+ return { path: rest[0], ...(ticketKey ? { ticketKey } : {}), ...(to ? { to } : {}) };
90
+ }
91
+ export function parseTuiAttach(text) {
92
+ const words = shellWords(text.trim());
93
+ if (!words?.length)
94
+ return null;
95
+ const key = words.at(-1)?.match(/^HD-[1-9][0-9]*$/i)?.[0].toUpperCase();
96
+ const paths = key ? words.slice(0, -1) : words;
97
+ return paths.length === 1 ? { path: paths[0], ...(key ? { ticketKey: key } : {}) } : null;
98
+ }
99
+ export async function detectDroppedPaths(pasted, isFile = async (path) => (await stat(path)).isFile()) {
100
+ const text = pasted.trim();
101
+ if (!text)
102
+ return [];
103
+ if (isAbsolute(text) && await isFile(text).catch(() => false))
104
+ return [text];
105
+ const words = shellWords(text);
106
+ if (!words?.length || words.some((path) => !isAbsolute(path)))
107
+ return [];
108
+ const checks = await Promise.all(words.map((path) => isFile(path).catch(() => false)));
109
+ return checks.every(Boolean) ? words : [];
110
+ }
111
+ export async function uploadAttachment(path, target = {}, config = loadConfig()) {
112
+ const info = await stat(path);
113
+ if (!info.isFile())
114
+ throw new Error(`${path} is not a file.`);
115
+ if (info.size > 20 * 1024 * 1024)
116
+ throw new Error("Attachments must be 20 MB or smaller.");
117
+ const mime = MIME[extname(path).toLowerCase()];
118
+ if (!mime)
119
+ throw new Error("Use an image, PDF, text, Markdown, JSON, or CSV file.");
120
+ const form = new FormData();
121
+ form.set("file", new Blob([await readFile(path)], { type: mime }), basename(path));
122
+ if (target.ticketKey)
123
+ form.set("ticket_key", target.ticketKey.toUpperCase());
124
+ if (target.messageId)
125
+ form.set("message_id", target.messageId);
126
+ const response = await fetch(`${config.url}/api/w/${config.slug}/attachments`, {
127
+ method: "POST", headers: { authorization: `Bearer ${config.api_key}` }, body: form,
128
+ });
129
+ const parsed = await response.json().catch(() => null);
130
+ if (!response.ok || !parsed?.attachment)
131
+ throw new Error(parsed?.error ?? `hd: ${response.status} Upload failed.`);
132
+ return parsed.attachment;
133
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { banner, c, statusChip, table, truncate, usage } from "./out.js";
12
12
  import { ticketNew } from "./ticket-commands.js";
13
13
  import { ticketViewLines } from "./tui/ticket-view.js";
14
14
  import { agentAdd, AGENT_ADD_USAGE } from "./agent-commands.js";
15
+ import { attachmentChip, parseAttachArgs, uploadAttachment } from "./attachments.js";
15
16
  import { WORKSPACE_USAGE, workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet, workspaceUse } from "./workspace-commands.js";
16
17
  import { defaultGh, hasWriteRepoPermission, HDX_RUNNER_GH_USER, requireAdminRepo, runnerRepoPermission } from "./workspace-preflight.js";
17
18
  function fail(message) {
@@ -101,7 +102,7 @@ async function cmdTicket(argv, deps = {}) {
101
102
  console.log(JSON.stringify(data));
102
103
  return;
103
104
  }
104
- const { ticket, pr, events, runs, messages, decisions } = data;
105
+ const { ticket, pr, events, runs, messages, decisions, attachments } = data;
105
106
  const width = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
106
107
  for (const line of ticketViewLines({
107
108
  ...ticket,
@@ -114,6 +115,7 @@ async function cmdTicket(argv, deps = {}) {
114
115
  summary: run.summary, elapsed_ms: run.elapsed_ms,
115
116
  })),
116
117
  decisions,
118
+ attachments,
117
119
  }, width)) {
118
120
  console.log(line.text);
119
121
  }
@@ -276,6 +278,15 @@ async function cmdMsg(argv) {
276
278
  });
277
279
  console.log(`sent ${message.id}`);
278
280
  }
281
+ export async function cmdAttach(argv) {
282
+ const parsed = parseAttachArgs(argv);
283
+ const attachment = await uploadAttachment(parsed.path, parsed.ticketKey ? { ticketKey: parsed.ticketKey } : {});
284
+ if (parsed.to) {
285
+ await postMessage({ body_md: `Attached ${attachment.name}.`, to_role: parsed.to, delivery: "queue",
286
+ attachment_ids: [attachment.id] });
287
+ }
288
+ console.log(`${attachmentChip(attachment)}${parsed.ticketKey ? ` attached to ${parsed.ticketKey}` : ` sent to ${parsed.to}`}`);
289
+ }
279
290
  async function cmdInbox(argv) {
280
291
  const { rest, opts, bools } = flags(argv);
281
292
  if (rest.length || (bools.has("limit") && !opts.limit))
@@ -627,6 +638,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
627
638
  await cmdMsg(rest);
628
639
  return;
629
640
  }
641
+ if (cmd === "attach") {
642
+ await cmdAttach(rest);
643
+ return;
644
+ }
630
645
  if (cmd === "inbox") {
631
646
  await cmdInbox(rest);
632
647
  return;
package/dist/out.js CHANGED
@@ -75,6 +75,7 @@ export function usage() {
75
75
  ` ${c.blue("hd host roll | env ls | set | rm")} roll a host or edit host env`,
76
76
  ` ${c.blue("hd logs KEY [-f]")} run events`,
77
77
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,
78
+ ` ${c.blue("hd attach PATH --ticket KEY | --to ROLE")} attach a file`,
78
79
  ` ${c.blue("hd inbox [--all] [--limit N] [--json]")} inbox messages`,
79
80
  ` ${c.blue("hd decide [ID --answer TEXT]")} decisions`,
80
81
  ` ${c.blue("hd on | hd off")} workspace switch`,
@@ -139,6 +139,8 @@ export async function ticketNew(argv, deps = {}) {
139
139
  else {
140
140
  throw new Error(TICKET_NEW_USAGE);
141
141
  }
142
+ if (deps.attachmentIds?.length)
143
+ fields.attachment_ids = deps.attachmentIds;
142
144
  const created = await (deps.createTicket ?? createTicket)(fields, deps.config);
143
145
  let queued = false;
144
146
  if (offerQueue && queueNow(await (deps.prompt ?? promptOnStdin)("Queue it now? [y/N] "))) {
package/dist/tui/App.js CHANGED
@@ -33,6 +33,7 @@ import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./setti
33
33
  import { appendLines, runLabels, toStreamLines } from "./stream.js";
34
34
  import { UI } from "./theme.js";
35
35
  import { WorkspaceLoads } from "./workspace-load.js";
36
+ import { attachmentChip, detectDroppedPaths, uploadAttachment } from "../attachments.js";
36
37
  let messageSeq = 0;
37
38
  const nextId = () => `m${messageSeq++}`;
38
39
  const tuiPrompt = (question) => promptOnStdin(question, true);
@@ -54,6 +55,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
54
55
  const [chatOffset, setChatOffset] = useState(0);
55
56
  const [now, setNow] = useState(Date.now());
56
57
  const [draft, setDraft] = useState("");
58
+ const [pendingAttachments, setPendingAttachments] = useState([]);
57
59
  const [busy, setBusy] = useState(false);
58
60
  const [notice, setNotice] = useState(null);
59
61
  const [availableUpdate, setAvailableUpdate] = useState(initialUpdate);
@@ -394,6 +396,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
394
396
  const key = threadKey(workspace.id, role);
395
397
  const since = new Date().toISOString();
396
398
  const youTurn = { id: nextId(), speaker: "you", body: text, at: since };
399
+ const attachmentIds = pendingAttachments.map((attachment) => attachment.id);
397
400
  setMode(role);
398
401
  setView("chat");
399
402
  setChatOffset(10_000);
@@ -406,7 +409,9 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
406
409
  }));
407
410
  void (async () => {
408
411
  try {
409
- const posted = await postAgentMessage(role, text, config);
412
+ const posted = await postAgentMessage(role, text, config, attachmentIds);
413
+ if (attachmentIds.length)
414
+ setPendingAttachments([]);
410
415
  const deadline = Date.now() + REPLY_WAIT_MS;
411
416
  while (Date.now() <= deadline) {
412
417
  const follow = await followChat(config, role, posted.id, since);
@@ -450,7 +455,32 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
450
455
  }));
451
456
  }
452
457
  })();
453
- }, [agentName, config, setThread, workspace.id]);
458
+ }, [agentName, config, pendingAttachments, setThread, workspace.id]);
459
+ const receiveDrop = useCallback((pasted) => {
460
+ if (!/^(?:\/|'\/|"\/)/.test(pasted.trim()))
461
+ return false;
462
+ void (async () => {
463
+ const paths = await detectDroppedPaths(pasted);
464
+ if (!paths.length) {
465
+ setDraft((current) => current + pasted.replace(/[\r\n]+/g, " "));
466
+ return;
467
+ }
468
+ setBusy(true);
469
+ try {
470
+ const uploaded = [];
471
+ for (const path of paths)
472
+ uploaded.push(await uploadAttachment(path, {}, config));
473
+ setPendingAttachments((current) => [...current, ...uploaded]);
474
+ }
475
+ catch (error) {
476
+ setNotice(error instanceof Error ? error.message : String(error));
477
+ }
478
+ finally {
479
+ setBusy(false);
480
+ }
481
+ })();
482
+ return true;
483
+ }, [config]);
454
484
  const openTicket = useCallback(async (key) => {
455
485
  setTicketKey(key);
456
486
  setView("ticket");
@@ -632,10 +662,12 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
632
662
  try {
633
663
  let result;
634
664
  await suspendTerminal(async () => {
635
- result = await ticketNew(action.args, { config, isTTY: true, prompt: tuiPrompt });
665
+ result = await ticketNew(action.args, { config, isTTY: true, prompt: tuiPrompt,
666
+ attachmentIds: pendingAttachments.map((attachment) => attachment.id) });
636
667
  });
637
668
  if (!result)
638
669
  throw new Error("Ticket creation did not finish.");
670
+ setPendingAttachments([]);
639
671
  say("system", `Created ${result.ticket.key}: ${result.ticket.title}${result.queued ? " and queued it" : ""}.`);
640
672
  await refresh();
641
673
  }
@@ -820,6 +852,23 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
820
852
  setBusy(false);
821
853
  }
822
854
  return;
855
+ case "attach":
856
+ setBusy(true);
857
+ try {
858
+ const key = action.key ?? ticketKey;
859
+ if (!key)
860
+ throw new Error("Open a ticket or include its HD-N key.");
861
+ const attachment = await uploadAttachment(action.path, { ticketKey: key }, config);
862
+ say("system", `${attachmentChip(attachment)} attached to ${key}.`);
863
+ await refresh();
864
+ }
865
+ catch (error) {
866
+ setNotice(error instanceof Error ? error.message : String(error));
867
+ }
868
+ finally {
869
+ setBusy(false);
870
+ }
871
+ return;
823
872
  case "logs":
824
873
  setLogsFilter(action.key);
825
874
  setRawLogs(action.raw);
@@ -876,7 +925,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
876
925
  }
877
926
  }, [view, settings, applyEdit, board, browsing, mode, say, askAgent, openAgentChat, order, settingsOrder,
878
927
  changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
879
- selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
928
+ selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width, pendingAttachments]);
880
929
  useInput((input, key) => {
881
930
  if (key.ctrl && input === "c") {
882
931
  exit();
@@ -934,7 +983,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
934
983
  return _jsx(Bubble, { message: item.message, width: width }, item.key);
935
984
  } }), splash ? (_jsx(Splash, { columns: columns, rows: rows, width: width, ready: ready, helpFull: plan.helpFull, animate: plan.fits, onDone: () => setReady(true) })) : null, _jsxs(Box, { flexDirection: "column", width: width, children: [view === "board" && plan.panels > 0 ? _jsx(BoardPanel, { board: board, width: width, rows: plan.panels, cursor: cursor }) : null, view === "roadmap" && plan.panels > 0 ? (_jsx(RoadmapPanel, { board: board, width: width, rows: plan.panels, offset: roadmapOffset })) : null, view === "agents" && plan.panels > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentsPanel, { board: board, width: width, rows: agentsView.top }), agentsView.bottom > 0 ? _jsxs(_Fragment, { children: [_jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: agentsView.bottom, live: running > 0 })] }) : null] })) : null, view === "feed" && plan.panels > 0 ? _jsx(FeedPanel, { entries: feed, width: width, rows: plan.panels }) : null, view === "settings" && plan.panels > 0 ? _jsx(SettingsPanel, { entries: settings, width: width, rows: plan.panels, title: "Settings", cursor: field, editing: editing }) : null, view === "inbox" && plan.panels > 0 ? _jsx(InboxPanel, { board: board, width: width, rows: plan.panels, focus: inboxFocus, selectedId: selectedDecisionId, answeringId: answering }) : null, view === "ticket" && plan.panels > 0 ? ticket
936
985
  ? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels, offset: ticketOffset, collapsed: ticketCollapsed })
937
- : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), availableUpdate || updateProgress ? (_jsx(Box, { children: _jsx(Text, { color: UI.warn, children: updateProgress ?? tuiUpdatePrompt(availableUpdate) }) })) : null, _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
986
+ : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), availableUpdate || updateProgress ? (_jsx(Box, { children: _jsx(Text, { color: UI.warn, children: updateProgress ?? tuiUpdatePrompt(availableUpdate) }) })) : null, pendingAttachments.map((attachment) => (_jsx(Text, { color: UI.accent, children: attachmentChip(attachment) }, attachment.id))), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
938
987
  setDraft(next);
939
988
  if (editingRef.current)
940
989
  setEditing({ key: editingRef.current.key, draft: next });
@@ -968,7 +1017,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
968
1017
  }
969
1018
  setCursor(null);
970
1019
  selectedRef.current = null;
971
- }, onUp: () => {
1020
+ }, onPasteText: receiveDrop, onUp: () => {
972
1021
  if (chatting && !draft) {
973
1022
  const overflow = chatLines.length > plan.panels;
974
1023
  const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
package/dist/tui/Help.js CHANGED
@@ -13,6 +13,7 @@ export const COMMANDS = [
13
13
  { name: "/cancel", args: "HD-12", help: "cancel a ticket" },
14
14
  { name: "/merge", args: "HD-12", help: "approve a reviewed PR over the reviewer's objections" },
15
15
  { name: "/msg", args: "HD-12 TEXT", help: "message a ticket's builder" },
16
+ { name: "/attach", args: "PATH [HD-12]", help: "attach a file to the open or named ticket" },
16
17
  { name: "/logs", args: "[raw] [HD-12]", help: "filter activity; raw reveals event JSON" },
17
18
  { name: "/epic", args: "new PATH | approve ID | rm ID", help: "create, approve, or remove a draft epic" },
18
19
  { name: "/epics", help: "list epics and ticket progress" },
@@ -19,7 +19,7 @@ function printableOf(text) {
19
19
  // eslint-disable-next-line no-control-regex
20
20
  return text.replace(/[\x00-\x1f\x7f]/g, "");
21
21
  }
22
- export default function TextInput({ value, onChange, onSubmit, onCancel, onUp, onDown, onPageUp, onPageDown, isActive = true, placeholder = "", prompt, color, }) {
22
+ export default function TextInput({ value, onChange, onSubmit, onCancel, onUp, onDown, onPageUp, onPageDown, isActive = true, placeholder = "", prompt, color, onPasteText, }) {
23
23
  const [cursor, setCursor] = useState(value.length);
24
24
  // The last value this component produced. Anything else arriving in `value`
25
25
  // was swapped in by the caller.
@@ -47,6 +47,8 @@ export default function TextInput({ value, onChange, onSubmit, onCancel, onUp, o
47
47
  setCursor(cursor + text.length);
48
48
  };
49
49
  usePaste((text) => {
50
+ if (onPasteText?.(text))
51
+ return;
50
52
  // Newlines would break a single-line field, so flatten them to spaces.
51
53
  insert(text.replace(/[\r\n]+/g, " "));
52
54
  }, { isActive });
package/dist/tui/data.js CHANGED
@@ -123,8 +123,9 @@ export async function switchWorkspace(slug, config = loadConfig()) {
123
123
  selectWorkspace(slug);
124
124
  return snapshot;
125
125
  }
126
- export async function postAgentMessage(role, body, config) {
127
- const { message } = await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
126
+ export async function postAgentMessage(role, body, config, attachmentIds = []) {
127
+ const { message } = await sendMessage({ body_md: body, to_role: role, delivery: "queue",
128
+ attachment_ids: attachmentIds }, config);
128
129
  return message;
129
130
  }
130
131
  export async function loadChatMessages(config, role) {
@@ -157,7 +158,7 @@ export async function followChat(config, role, messageId, since) {
157
158
  return { run, events, reply: reply?.body_md ?? null };
158
159
  }
159
160
  export async function loadTicketDetail(config, key) {
160
- const { ticket, pr, events, runs, messages, decisions } = await showTicket(key, config);
161
+ const { ticket, pr, events, runs, messages, decisions, attachments } = await showTicket(key, config);
161
162
  return {
162
163
  ticket: {
163
164
  ...ticket,
@@ -167,6 +168,7 @@ export async function loadTicketDetail(config, key) {
167
168
  messages,
168
169
  runs,
169
170
  decisions,
171
+ attachments,
170
172
  },
171
173
  };
172
174
  }
package/dist/tui/parse.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseTuiAttach } from "../attachments.js";
1
2
  /**
2
3
  * What a typed line means. Kept separate from the component so the behaviour
3
4
  * can be tested without a terminal, a database, or a render.
@@ -97,6 +98,11 @@ export function parseLine(raw) {
97
98
  case "msg":
98
99
  return rest.length >= 2 ? { kind: "message", key: rest[0].toUpperCase(), text: rest.slice(1).join(" ") }
99
100
  : { kind: "unknown", command: "msg needs KEY TEXT" };
101
+ case "attach": {
102
+ const parsed = parseTuiAttach(argument);
103
+ return parsed ? { kind: "attach", path: parsed.path, key: parsed.ticketKey ?? null }
104
+ : { kind: "unknown", command: "attach needs PATH and an optional HD-N key" };
105
+ }
100
106
  case "logs":
101
107
  if (rest[0]?.toLowerCase() === "raw" && rest.length <= 2) {
102
108
  return { kind: "logs", key: rest[1]?.toUpperCase() ?? null, raw: true };
@@ -1,4 +1,4 @@
1
- export const TICKET_SECTIONS = ["body", "acceptance", "timeline", "messages", "pr", "runs", "decisions"];
1
+ export const TICKET_SECTIONS = ["body", "acceptance", "attachments", "timeline", "messages", "pr", "runs", "decisions"];
2
2
  export function ticketPr(ticket) {
3
3
  if (!ticket.pr_url && ticket.pr_number == null)
4
4
  return null;
@@ -121,6 +121,17 @@ export function ticketViewLines(ticket, width, collapsed = []) {
121
121
  }
122
122
  pushWrapped(lines, "acceptance", " ", ticket.acceptance_md.trim(), width);
123
123
  });
124
+ const attachments = ticket.attachments ?? [];
125
+ if (attachments.length)
126
+ addSection("attachments", "Attachments", () => {
127
+ for (const attachment of attachments) {
128
+ const size = attachment.size < 1024 ? `${attachment.size} B`
129
+ : attachment.size < 1024 * 1024 ? `${Math.round(attachment.size / 1024)} KB`
130
+ : `${(attachment.size / 1024 / 1024).toFixed(1)} MB`;
131
+ lines.push({ key: attachment.id, kind: "line", section: "attachments",
132
+ text: ` [${attachment.mime.startsWith("image/") ? "image" : "file"}: ${attachment.name} ${size}]` });
133
+ }
134
+ }, attachments.length);
124
135
  addSection("timeline", "Timeline", () => {
125
136
  if (!ticket.timeline?.length) {
126
137
  lines.push({ key: "timeline:empty", kind: "line", section: "timeline", text: " No completed runs yet." });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",