@higherdev/cli 0.25.0 → 0.27.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.
- package/dist/attachments.js +133 -0
- package/dist/index.js +20 -1
- package/dist/out.js +1 -0
- package/dist/ticket-commands.js +2 -0
- package/dist/tui/App.js +62 -18
- package/dist/tui/Help.js +1 -0
- package/dist/tui/TextInput.js +3 -1
- package/dist/tui/chat-view.js +8 -1
- package/dist/tui/chat-wait.js +16 -5
- package/dist/tui/data.js +6 -3
- package/dist/tui/parse.js +6 -0
- package/dist/tui/settings-model.js +1 -1
- package/dist/tui/ticket-view.js +12 -1
- package/package.json +1 -1
|
@@ -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) {
|
|
@@ -83,6 +84,10 @@ async function cmdStatus() {
|
|
|
83
84
|
if (data.decisions.length > 0) {
|
|
84
85
|
console.log(`\n${c.yellow(String(data.decisions.length))} open decision${data.decisions.length === 1 ? "" : "s"}`);
|
|
85
86
|
}
|
|
87
|
+
if (data.chat_reply_median_ms != null) {
|
|
88
|
+
const seconds = Math.max(0, Math.round(data.chat_reply_median_ms / 1_000));
|
|
89
|
+
console.log(`\n${c.bold("Chat replies")} median ${seconds}s (last day)`);
|
|
90
|
+
}
|
|
86
91
|
}
|
|
87
92
|
async function cmdTicket(argv, deps = {}) {
|
|
88
93
|
const [action, ...rest] = argv;
|
|
@@ -101,7 +106,7 @@ async function cmdTicket(argv, deps = {}) {
|
|
|
101
106
|
console.log(JSON.stringify(data));
|
|
102
107
|
return;
|
|
103
108
|
}
|
|
104
|
-
const { ticket, pr, events, runs, messages, decisions } = data;
|
|
109
|
+
const { ticket, pr, events, runs, messages, decisions, attachments } = data;
|
|
105
110
|
const width = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
|
|
106
111
|
for (const line of ticketViewLines({
|
|
107
112
|
...ticket,
|
|
@@ -114,6 +119,7 @@ async function cmdTicket(argv, deps = {}) {
|
|
|
114
119
|
summary: run.summary, elapsed_ms: run.elapsed_ms,
|
|
115
120
|
})),
|
|
116
121
|
decisions,
|
|
122
|
+
attachments,
|
|
117
123
|
}, width)) {
|
|
118
124
|
console.log(line.text);
|
|
119
125
|
}
|
|
@@ -276,6 +282,15 @@ async function cmdMsg(argv) {
|
|
|
276
282
|
});
|
|
277
283
|
console.log(`sent ${message.id}`);
|
|
278
284
|
}
|
|
285
|
+
export async function cmdAttach(argv) {
|
|
286
|
+
const parsed = parseAttachArgs(argv);
|
|
287
|
+
const attachment = await uploadAttachment(parsed.path, parsed.ticketKey ? { ticketKey: parsed.ticketKey } : {});
|
|
288
|
+
if (parsed.to) {
|
|
289
|
+
await postMessage({ body_md: `Attached ${attachment.name}.`, to_role: parsed.to, delivery: "queue",
|
|
290
|
+
attachment_ids: [attachment.id] });
|
|
291
|
+
}
|
|
292
|
+
console.log(`${attachmentChip(attachment)}${parsed.ticketKey ? ` attached to ${parsed.ticketKey}` : ` sent to ${parsed.to}`}`);
|
|
293
|
+
}
|
|
279
294
|
async function cmdInbox(argv) {
|
|
280
295
|
const { rest, opts, bools } = flags(argv);
|
|
281
296
|
if (rest.length || (bools.has("limit") && !opts.limit))
|
|
@@ -627,6 +642,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
627
642
|
await cmdMsg(rest);
|
|
628
643
|
return;
|
|
629
644
|
}
|
|
645
|
+
if (cmd === "attach") {
|
|
646
|
+
await cmdAttach(rest);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
630
649
|
if (cmd === "inbox") {
|
|
631
650
|
await cmdInbox(rest);
|
|
632
651
|
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`,
|
package/dist/ticket-commands.js
CHANGED
|
@@ -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
|
@@ -26,13 +26,14 @@ import { bubbleRows } from "./height.js";
|
|
|
26
26
|
import { planLayout, splitPanels } from "./layout.js";
|
|
27
27
|
import { parseLine } from "./parse.js";
|
|
28
28
|
import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, mergeTicket, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, followChat, loadChatMessages, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, POLL_MS, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, } from "./data.js";
|
|
29
|
-
import { inputActive,
|
|
29
|
+
import { inputActive, promptPlaceholder, settleChatReply } from "./chat-wait.js";
|
|
30
30
|
import { answeredLine, decisionHeaderIndex, decisionIdAt, moveDecisionFocus, nextUnanswered, resolveDecisionAnswer, } from "./decide-nav.js";
|
|
31
31
|
import { EARLIER_PAGE } from "./inbox.js";
|
|
32
32
|
import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
|
|
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,9 +409,10 @@ 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);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
+
const posted = await postAgentMessage(role, text, config, attachmentIds);
|
|
413
|
+
if (attachmentIds.length)
|
|
414
|
+
setPendingAttachments([]);
|
|
415
|
+
while (true) {
|
|
412
416
|
const follow = await followChat(config, role, posted.id, since);
|
|
413
417
|
const activity = pendingActivity(follow.events, follow.run, agentName(role));
|
|
414
418
|
setThread(key, (current) => ({
|
|
@@ -422,10 +426,13 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
|
|
|
422
426
|
},
|
|
423
427
|
}));
|
|
424
428
|
if (followShouldStop(follow.run, follow.reply)) {
|
|
425
|
-
const
|
|
429
|
+
const settled = settleChatReply(follow.reply, follow.run);
|
|
426
430
|
setThread(key, (current) => ({
|
|
427
431
|
...current,
|
|
428
|
-
turns: [...current.turns, {
|
|
432
|
+
turns: [...current.turns, {
|
|
433
|
+
id: `reply-${posted.id}`, speaker: role, body: settled.body, at: new Date().toISOString(),
|
|
434
|
+
replyMs: settled.replyMs,
|
|
435
|
+
}],
|
|
429
436
|
pending: null,
|
|
430
437
|
}));
|
|
431
438
|
setChatOffset(10_000);
|
|
@@ -433,13 +440,6 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
|
|
|
433
440
|
}
|
|
434
441
|
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
435
442
|
}
|
|
436
|
-
setThread(key, (current) => ({
|
|
437
|
-
...current,
|
|
438
|
-
turns: [...current.turns, {
|
|
439
|
-
id: `timeout-${posted.id}`, speaker: role, body: NO_REPLY_NOTE, at: new Date().toISOString(),
|
|
440
|
-
}],
|
|
441
|
-
pending: null,
|
|
442
|
-
}));
|
|
443
443
|
}
|
|
444
444
|
catch (error) {
|
|
445
445
|
const body = error instanceof Error ? error.message : String(error);
|
|
@@ -450,7 +450,32 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
|
|
|
450
450
|
}));
|
|
451
451
|
}
|
|
452
452
|
})();
|
|
453
|
-
}, [agentName, config, setThread, workspace.id]);
|
|
453
|
+
}, [agentName, config, pendingAttachments, setThread, workspace.id]);
|
|
454
|
+
const receiveDrop = useCallback((pasted) => {
|
|
455
|
+
if (!/^(?:\/|'\/|"\/)/.test(pasted.trim()))
|
|
456
|
+
return false;
|
|
457
|
+
void (async () => {
|
|
458
|
+
const paths = await detectDroppedPaths(pasted);
|
|
459
|
+
if (!paths.length) {
|
|
460
|
+
setDraft((current) => current + pasted.replace(/[\r\n]+/g, " "));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
setBusy(true);
|
|
464
|
+
try {
|
|
465
|
+
const uploaded = [];
|
|
466
|
+
for (const path of paths)
|
|
467
|
+
uploaded.push(await uploadAttachment(path, {}, config));
|
|
468
|
+
setPendingAttachments((current) => [...current, ...uploaded]);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
setBusy(false);
|
|
475
|
+
}
|
|
476
|
+
})();
|
|
477
|
+
return true;
|
|
478
|
+
}, [config]);
|
|
454
479
|
const openTicket = useCallback(async (key) => {
|
|
455
480
|
setTicketKey(key);
|
|
456
481
|
setView("ticket");
|
|
@@ -632,10 +657,12 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
|
|
|
632
657
|
try {
|
|
633
658
|
let result;
|
|
634
659
|
await suspendTerminal(async () => {
|
|
635
|
-
result = await ticketNew(action.args, { config, isTTY: true, prompt: tuiPrompt
|
|
660
|
+
result = await ticketNew(action.args, { config, isTTY: true, prompt: tuiPrompt,
|
|
661
|
+
attachmentIds: pendingAttachments.map((attachment) => attachment.id) });
|
|
636
662
|
});
|
|
637
663
|
if (!result)
|
|
638
664
|
throw new Error("Ticket creation did not finish.");
|
|
665
|
+
setPendingAttachments([]);
|
|
639
666
|
say("system", `Created ${result.ticket.key}: ${result.ticket.title}${result.queued ? " and queued it" : ""}.`);
|
|
640
667
|
await refresh();
|
|
641
668
|
}
|
|
@@ -820,6 +847,23 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
|
|
|
820
847
|
setBusy(false);
|
|
821
848
|
}
|
|
822
849
|
return;
|
|
850
|
+
case "attach":
|
|
851
|
+
setBusy(true);
|
|
852
|
+
try {
|
|
853
|
+
const key = action.key ?? ticketKey;
|
|
854
|
+
if (!key)
|
|
855
|
+
throw new Error("Open a ticket or include its HD-N key.");
|
|
856
|
+
const attachment = await uploadAttachment(action.path, { ticketKey: key }, config);
|
|
857
|
+
say("system", `${attachmentChip(attachment)} attached to ${key}.`);
|
|
858
|
+
await refresh();
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
862
|
+
}
|
|
863
|
+
finally {
|
|
864
|
+
setBusy(false);
|
|
865
|
+
}
|
|
866
|
+
return;
|
|
823
867
|
case "logs":
|
|
824
868
|
setLogsFilter(action.key);
|
|
825
869
|
setRawLogs(action.raw);
|
|
@@ -876,7 +920,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
|
|
|
876
920
|
}
|
|
877
921
|
}, [view, settings, applyEdit, board, browsing, mode, say, askAgent, openAgentChat, order, settingsOrder,
|
|
878
922
|
changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
|
|
879
|
-
selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
|
|
923
|
+
selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width, pendingAttachments]);
|
|
880
924
|
useInput((input, key) => {
|
|
881
925
|
if (key.ctrl && input === "c") {
|
|
882
926
|
exit();
|
|
@@ -934,7 +978,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
|
|
|
934
978
|
return _jsx(Bubble, { message: item.message, width: width }, item.key);
|
|
935
979
|
} }), 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
980
|
? _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) => {
|
|
981
|
+
: _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
982
|
setDraft(next);
|
|
939
983
|
if (editingRef.current)
|
|
940
984
|
setEditing({ key: editingRef.current.key, draft: next });
|
|
@@ -968,7 +1012,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
|
|
|
968
1012
|
}
|
|
969
1013
|
setCursor(null);
|
|
970
1014
|
selectedRef.current = null;
|
|
971
|
-
}, onUp: () => {
|
|
1015
|
+
}, onPasteText: receiveDrop, onUp: () => {
|
|
972
1016
|
if (chatting && !draft) {
|
|
973
1017
|
const overflow = chatLines.length > plan.panels;
|
|
974
1018
|
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" },
|
package/dist/tui/TextInput.js
CHANGED
|
@@ -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/chat-view.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { answeredIn, QUEUED_STEP } from "./chat-wait.js";
|
|
1
2
|
import { narrateEvents } from "./narrate.js";
|
|
2
3
|
import { wrapLines } from "./ticket-view.js";
|
|
3
4
|
export function chatInstructionEffects(body) {
|
|
@@ -93,7 +94,7 @@ export function pendingActivity(events, run, label, now = Date.now()) {
|
|
|
93
94
|
export function followShouldStop(run, reply) {
|
|
94
95
|
if (reply)
|
|
95
96
|
return true;
|
|
96
|
-
if (run &&
|
|
97
|
+
if (run && ["failed", "killed"].includes(run.status))
|
|
97
98
|
return true;
|
|
98
99
|
return false;
|
|
99
100
|
}
|
|
@@ -113,6 +114,9 @@ export function chatViewLines(thread, width, label, now = Date.now()) {
|
|
|
113
114
|
const effects = chatInstructionEffects(turn.body);
|
|
114
115
|
if (effects)
|
|
115
116
|
lines.push({ key: `${turn.id}:effects`, kind: "status", text: effects });
|
|
117
|
+
if (turn.replyMs != null) {
|
|
118
|
+
lines.push({ key: `${turn.id}:reply-ms`, kind: "status", text: answeredIn(turn.replyMs) });
|
|
119
|
+
}
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
122
|
if (thread.pending) {
|
|
@@ -122,6 +126,9 @@ export function chatViewLines(thread, width, label, now = Date.now()) {
|
|
|
122
126
|
: live.status;
|
|
123
127
|
if (status)
|
|
124
128
|
lines.push({ key: "pending:status", kind: "status", text: status });
|
|
129
|
+
if (thread.pending.status === "queued") {
|
|
130
|
+
lines.push({ key: "pending:queued", kind: "activity", text: QUEUED_STEP });
|
|
131
|
+
}
|
|
125
132
|
for (const [index, title] of thread.pending.activity.entries()) {
|
|
126
133
|
wrapLines(title, bodyWidth).forEach((text, line) => {
|
|
127
134
|
lines.push({ key: `pending:activity:${index}:${line}`, kind: "activity", text });
|
package/dist/tui/chat-wait.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export const REPLY_WAIT_MS = 180_000;
|
|
3
|
-
/** Shown in the pending bubble when the wait expires. The reply can still land in /inbox. */
|
|
1
|
+
/** Shown on the pending bubble only when the chat run failed. The reply can still land in /inbox. */
|
|
4
2
|
export const NO_REPLY_NOTE = "No reply yet. It will land in /inbox.";
|
|
5
3
|
export const QUEUED_STEP = "· queued, it answers on the next tick";
|
|
6
4
|
/**
|
|
@@ -13,6 +11,19 @@ export function inputActive(state) {
|
|
|
13
11
|
export function promptPlaceholder(state) {
|
|
14
12
|
return state.busy ? "working…" : "message, or /help";
|
|
15
13
|
}
|
|
16
|
-
export function
|
|
17
|
-
|
|
14
|
+
export function failedChatNote(reason) {
|
|
15
|
+
const detail = reason?.trim();
|
|
16
|
+
return detail ? `${NO_REPLY_NOTE} ${detail}` : NO_REPLY_NOTE;
|
|
17
|
+
}
|
|
18
|
+
export function answeredIn(ms) {
|
|
19
|
+
return `answered in ${Math.max(0, Math.round(ms / 1000))}s`;
|
|
20
|
+
}
|
|
21
|
+
export function settleChatReply(reply, run) {
|
|
22
|
+
if (reply) {
|
|
23
|
+
return { body: reply, pending: false, steps: [], done: true, replyMs: run?.reply_ms ?? null };
|
|
24
|
+
}
|
|
25
|
+
if (run && ["failed", "killed"].includes(run.status ?? "")) {
|
|
26
|
+
return { body: failedChatNote(run.summary), pending: false, steps: [], done: true, replyMs: run.reply_ms ?? null };
|
|
27
|
+
}
|
|
28
|
+
return { body: "", pending: true, steps: [], done: false, replyMs: null };
|
|
18
29
|
}
|
package/dist/tui/data.js
CHANGED
|
@@ -67,6 +67,7 @@ export async function loadSnapshot(config = loadConfig(), options = {}) {
|
|
|
67
67
|
followup: Number(workspaceData.workspace.settings.max_turns?.followup ?? 15),
|
|
68
68
|
review: Number(workspaceData.workspace.settings.max_turns?.review ?? 50),
|
|
69
69
|
orchestrate: Number(workspaceData.workspace.settings.max_turns?.orchestrate ?? 30),
|
|
70
|
+
chat: Number(workspaceData.workspace.settings.max_turns?.chat ?? 8),
|
|
70
71
|
},
|
|
71
72
|
max_attempts: Number(workspaceData.workspace.settings.max_attempts ?? 3),
|
|
72
73
|
},
|
|
@@ -123,8 +124,9 @@ export async function switchWorkspace(slug, config = loadConfig()) {
|
|
|
123
124
|
selectWorkspace(slug);
|
|
124
125
|
return snapshot;
|
|
125
126
|
}
|
|
126
|
-
export async function postAgentMessage(role, body, config) {
|
|
127
|
-
const { message } = await sendMessage({ body_md: body, to_role: role, delivery: "queue"
|
|
127
|
+
export async function postAgentMessage(role, body, config, attachmentIds = []) {
|
|
128
|
+
const { message } = await sendMessage({ body_md: body, to_role: role, delivery: "queue",
|
|
129
|
+
attachment_ids: attachmentIds }, config);
|
|
128
130
|
return message;
|
|
129
131
|
}
|
|
130
132
|
export async function loadChatMessages(config, role) {
|
|
@@ -157,7 +159,7 @@ export async function followChat(config, role, messageId, since) {
|
|
|
157
159
|
return { run, events, reply: reply?.body_md ?? null };
|
|
158
160
|
}
|
|
159
161
|
export async function loadTicketDetail(config, key) {
|
|
160
|
-
const { ticket, pr, events, runs, messages, decisions } = await showTicket(key, config);
|
|
162
|
+
const { ticket, pr, events, runs, messages, decisions, attachments } = await showTicket(key, config);
|
|
161
163
|
return {
|
|
162
164
|
ticket: {
|
|
163
165
|
...ticket,
|
|
@@ -167,6 +169,7 @@ export async function loadTicketDetail(config, key) {
|
|
|
167
169
|
messages,
|
|
168
170
|
runs,
|
|
169
171
|
decisions,
|
|
172
|
+
attachments,
|
|
170
173
|
},
|
|
171
174
|
};
|
|
172
175
|
}
|
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 };
|
|
@@ -7,7 +7,7 @@ export function settingsRows(workspace, agents) {
|
|
|
7
7
|
{ key: "w:default_branch", kind: "text", label: "branch", value: workspace.default_branch },
|
|
8
8
|
{ key: "w:default_host", kind: "text", label: "host", value: workspace.default_host },
|
|
9
9
|
{ key: "w:auto_merge", kind: "toggle", label: "auto merge", value: workspace.auto_merge ? "yes" : "no" },
|
|
10
|
-
...["build", "followup", "review", "orchestrate"].map((kind) => ({
|
|
10
|
+
...["build", "followup", "review", "orchestrate", "chat"].map((kind) => ({
|
|
11
11
|
key: `w:max_turns:${kind}`, kind: "number", label: `turns ${kind}`,
|
|
12
12
|
value: String(workspace.max_turns[kind]), hint: "integer >= 1",
|
|
13
13
|
})),
|
package/dist/tui/ticket-view.js
CHANGED
|
@@ -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." });
|