@higherdev/cli 0.24.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.
- package/dist/attachments.js +133 -0
- package/dist/index.js +18 -3
- package/dist/out.js +1 -0
- package/dist/ticket-commands.js +2 -0
- package/dist/tui/App.js +114 -14
- package/dist/tui/Help.js +1 -0
- package/dist/tui/TextInput.js +3 -1
- package/dist/tui/data.js +5 -3
- package/dist/tui/launch.js +8 -3
- package/dist/tui/parse.js +6 -0
- package/dist/tui/ticket-view.js +12 -1
- package/dist/update-check.js +43 -2
- 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) {
|
|
@@ -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))
|
|
@@ -585,7 +596,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
585
596
|
if (canLaunchApp()) {
|
|
586
597
|
launchedTui = true;
|
|
587
598
|
const { launchApp } = await import("./tui/launch.js");
|
|
588
|
-
await launchApp();
|
|
599
|
+
await launchApp(undefined, deps);
|
|
589
600
|
return;
|
|
590
601
|
}
|
|
591
602
|
console.log(banner());
|
|
@@ -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;
|
|
@@ -670,7 +685,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
670
685
|
console.log(`logged in to ${config.slug}`);
|
|
671
686
|
launchedTui = true;
|
|
672
687
|
const { launchApp } = await import("./tui/launch.js");
|
|
673
|
-
await launchApp(config.slug);
|
|
688
|
+
await launchApp(config.slug, deps);
|
|
674
689
|
return;
|
|
675
690
|
}
|
|
676
691
|
if (cmd === "init" || cmd === "upgrade") {
|
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
|
@@ -2,7 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
|
-
import {
|
|
5
|
+
import { pendingTuiUpdate, relaunchHd, runPromptedUpdate, skipUpdate, subscribeUpdateNotice, tuiUpdatePrompt, updatePromptAction, } from "../update-check.js";
|
|
6
6
|
import { epicProgressRows } from "../epics.js";
|
|
7
7
|
import { promptOnStdin } from "../prompt.js";
|
|
8
8
|
import { ticketNew } from "../ticket-commands.js";
|
|
@@ -33,10 +33,11 @@ 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);
|
|
39
|
-
export function App({ initial,
|
|
40
|
+
export function App({ initial, availableUpdate: initialUpdate = null, updateDeps = {}, onRelaunch, }) {
|
|
40
41
|
const { exit, suspendTerminal } = useApp();
|
|
41
42
|
const { stdout } = useStdout();
|
|
42
43
|
const columns = stdout?.columns && stdout.columns > 0 ? stdout.columns : 80;
|
|
@@ -54,9 +55,12 @@ export function App({ initial, updateNotice = null }) {
|
|
|
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
|
-
const [
|
|
61
|
+
const [availableUpdate, setAvailableUpdate] = useState(initialUpdate);
|
|
62
|
+
const [updateProgress, setUpdateProgress] = useState(null);
|
|
63
|
+
const [updating, setUpdating] = useState(false);
|
|
60
64
|
const [ticketKey, setTicketKey] = useState(null);
|
|
61
65
|
const [ticketOffset, setTicketOffset] = useState(0);
|
|
62
66
|
const [roadmapOffset, setRoadmapOffset] = useState(0);
|
|
@@ -86,9 +90,48 @@ export function App({ initial, updateNotice = null }) {
|
|
|
86
90
|
const loads = useRef(new WorkspaceLoads(initial.workspace.id));
|
|
87
91
|
editingRef.current = editing;
|
|
88
92
|
useEffect(() => {
|
|
89
|
-
|
|
90
|
-
return subscribeUpdateNotice(setUpdateLine);
|
|
93
|
+
return subscribeUpdateNotice(() => setAvailableUpdate(pendingTuiUpdate(updateDeps)));
|
|
91
94
|
}, []);
|
|
95
|
+
const dismissUpdate = useCallback(() => {
|
|
96
|
+
if (!availableUpdate)
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
skipUpdate(availableUpdate, updateDeps);
|
|
100
|
+
setAvailableUpdate(pendingTuiUpdate(updateDeps));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
setNotice(`Could not remember skipped update: ${error instanceof Error ? error.message : String(error)}`);
|
|
104
|
+
}
|
|
105
|
+
}, [availableUpdate, updateDeps]);
|
|
106
|
+
const acceptUpdate = useCallback(async () => {
|
|
107
|
+
if (!availableUpdate || updating)
|
|
108
|
+
return;
|
|
109
|
+
setUpdating(true);
|
|
110
|
+
setUpdateProgress(`Updating hd ${availableUpdate.current}...`);
|
|
111
|
+
try {
|
|
112
|
+
await runPromptedUpdate(availableUpdate, {
|
|
113
|
+
...updateDeps,
|
|
114
|
+
log: (line) => {
|
|
115
|
+
updateDeps.log?.(line);
|
|
116
|
+
if (line !== `hd ${availableUpdate.current}`) {
|
|
117
|
+
setUpdateProgress(`Installed ${line}. Relaunching...`);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
relaunch: () => {
|
|
121
|
+
exit();
|
|
122
|
+
if (onRelaunch)
|
|
123
|
+
onRelaunch();
|
|
124
|
+
else
|
|
125
|
+
setTimeout(updateDeps.relaunch ?? relaunchHd, 0);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
setNotice(`Update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
setUpdateProgress(null);
|
|
132
|
+
setUpdating(false);
|
|
133
|
+
}
|
|
134
|
+
}, [availableUpdate, exit, onRelaunch, updateDeps, updating]);
|
|
92
135
|
useEffect(() => {
|
|
93
136
|
if (messages.length > 0 || view !== "home")
|
|
94
137
|
setStarted(true);
|
|
@@ -353,6 +396,7 @@ export function App({ initial, updateNotice = null }) {
|
|
|
353
396
|
const key = threadKey(workspace.id, role);
|
|
354
397
|
const since = new Date().toISOString();
|
|
355
398
|
const youTurn = { id: nextId(), speaker: "you", body: text, at: since };
|
|
399
|
+
const attachmentIds = pendingAttachments.map((attachment) => attachment.id);
|
|
356
400
|
setMode(role);
|
|
357
401
|
setView("chat");
|
|
358
402
|
setChatOffset(10_000);
|
|
@@ -365,7 +409,9 @@ export function App({ initial, updateNotice = null }) {
|
|
|
365
409
|
}));
|
|
366
410
|
void (async () => {
|
|
367
411
|
try {
|
|
368
|
-
const posted = await postAgentMessage(role, text, config);
|
|
412
|
+
const posted = await postAgentMessage(role, text, config, attachmentIds);
|
|
413
|
+
if (attachmentIds.length)
|
|
414
|
+
setPendingAttachments([]);
|
|
369
415
|
const deadline = Date.now() + REPLY_WAIT_MS;
|
|
370
416
|
while (Date.now() <= deadline) {
|
|
371
417
|
const follow = await followChat(config, role, posted.id, since);
|
|
@@ -409,7 +455,32 @@ export function App({ initial, updateNotice = null }) {
|
|
|
409
455
|
}));
|
|
410
456
|
}
|
|
411
457
|
})();
|
|
412
|
-
}, [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]);
|
|
413
484
|
const openTicket = useCallback(async (key) => {
|
|
414
485
|
setTicketKey(key);
|
|
415
486
|
setView("ticket");
|
|
@@ -591,10 +662,12 @@ export function App({ initial, updateNotice = null }) {
|
|
|
591
662
|
try {
|
|
592
663
|
let result;
|
|
593
664
|
await suspendTerminal(async () => {
|
|
594
|
-
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) });
|
|
595
667
|
});
|
|
596
668
|
if (!result)
|
|
597
669
|
throw new Error("Ticket creation did not finish.");
|
|
670
|
+
setPendingAttachments([]);
|
|
598
671
|
say("system", `Created ${result.ticket.key}: ${result.ticket.title}${result.queued ? " and queued it" : ""}.`);
|
|
599
672
|
await refresh();
|
|
600
673
|
}
|
|
@@ -779,6 +852,23 @@ export function App({ initial, updateNotice = null }) {
|
|
|
779
852
|
setBusy(false);
|
|
780
853
|
}
|
|
781
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;
|
|
782
872
|
case "logs":
|
|
783
873
|
setLogsFilter(action.key);
|
|
784
874
|
setRawLogs(action.raw);
|
|
@@ -835,10 +925,20 @@ export function App({ initial, updateNotice = null }) {
|
|
|
835
925
|
}
|
|
836
926
|
}, [view, settings, applyEdit, board, browsing, mode, say, askAgent, openAgentChat, order, settingsOrder,
|
|
837
927
|
changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
|
|
838
|
-
selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
|
|
928
|
+
selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width, pendingAttachments]);
|
|
839
929
|
useInput((input, key) => {
|
|
840
|
-
if (key.ctrl && input === "c")
|
|
930
|
+
if (key.ctrl && input === "c") {
|
|
841
931
|
exit();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (availableUpdate && !updating) {
|
|
935
|
+
const action = updatePromptAction(input, key.escape);
|
|
936
|
+
if (action === "skip")
|
|
937
|
+
dismissUpdate();
|
|
938
|
+
else if (action === "accept")
|
|
939
|
+
void acceptUpdate();
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
842
942
|
});
|
|
843
943
|
const decisions = board.decisions;
|
|
844
944
|
const answeringDecision = answering ? decisions.find((decision) => decision.id === answering) : null;
|
|
@@ -870,7 +970,7 @@ export function App({ initial, updateNotice = null }) {
|
|
|
870
970
|
const plan = planLayout({
|
|
871
971
|
rows, columns, width, splash, ready, decision: chatting ? 0 : decisionRows(decisions),
|
|
872
972
|
inFlight: chatting ? 0 : inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
|
|
873
|
-
notice: Boolean(notice), home: view === "home",
|
|
973
|
+
notice: Boolean(notice || availableUpdate || updateProgress), home: view === "home",
|
|
874
974
|
});
|
|
875
975
|
const agentsView = splitPanels(plan.panels);
|
|
876
976
|
const running = board.runs.filter((run) => run.status === "running").length;
|
|
@@ -883,11 +983,11 @@ export function App({ initial, updateNotice = null }) {
|
|
|
883
983
|
return _jsx(Bubble, { message: item.message, width: width }, item.key);
|
|
884
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
|
|
885
985
|
? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels, offset: ticketOffset, collapsed: ticketCollapsed })
|
|
886
|
-
: _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,
|
|
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) => {
|
|
887
987
|
setDraft(next);
|
|
888
988
|
if (editingRef.current)
|
|
889
989
|
setEditing({ key: editingRef.current.key, draft: next });
|
|
890
|
-
}, onSubmit: (value) => void run(value), isActive: inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
|
|
990
|
+
}, onSubmit: (value) => void run(value), isActive: !availableUpdate && !updating && inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
|
|
891
991
|
? (answeringOptions.length ? `1-${answeringOptions.length} or your answer` : "your answer")
|
|
892
992
|
: promptPlaceholder({ busy, pendingChats: inFlight.length }), prompt: _jsx(Text, { color: answering || chatting || mode !== "browse" ? UI.cream : UI.dim, children: answering ? `answer ${answeringNumber}> ` : chatting ? `${chatLabel}> ` : mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
|
|
893
993
|
if (answering) {
|
|
@@ -917,7 +1017,7 @@ export function App({ initial, updateNotice = null }) {
|
|
|
917
1017
|
}
|
|
918
1018
|
setCursor(null);
|
|
919
1019
|
selectedRef.current = null;
|
|
920
|
-
}, onUp: () => {
|
|
1020
|
+
}, onPasteText: receiveDrop, onUp: () => {
|
|
921
1021
|
if (chatting && !draft) {
|
|
922
1022
|
const overflow = chatLines.length > plan.panels;
|
|
923
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" },
|
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/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"
|
|
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/launch.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
|
-
import {
|
|
2
|
+
import { pendingTuiUpdate, relaunchHd } from "../update-check.js";
|
|
3
3
|
import { canLaunchApp } from "./capability.js";
|
|
4
4
|
import { loadSnapshot } from "./data.js";
|
|
5
5
|
/** Opens HigherDEV. Shared by `hd` and by the end of `hd login`. */
|
|
6
|
-
export async function launchApp(slug) {
|
|
6
|
+
export async function launchApp(slug, updateDeps = {}) {
|
|
7
7
|
// Keep this guard here as well as at command call sites. Ink cannot enter raw
|
|
8
8
|
// mode unless both streams are terminals.
|
|
9
9
|
if (!canLaunchApp())
|
|
@@ -14,9 +14,14 @@ export async function launchApp(slug) {
|
|
|
14
14
|
import("react"),
|
|
15
15
|
import("./App.js"),
|
|
16
16
|
]);
|
|
17
|
+
let shouldRelaunch = false;
|
|
17
18
|
const instance = render(React.createElement(App, {
|
|
18
19
|
initial,
|
|
19
|
-
|
|
20
|
+
availableUpdate: pendingTuiUpdate(updateDeps),
|
|
21
|
+
updateDeps,
|
|
22
|
+
onRelaunch: () => { shouldRelaunch = true; },
|
|
20
23
|
}), { exitOnCtrlC: false });
|
|
21
24
|
await instance.waitUntilExit();
|
|
25
|
+
if (shouldRelaunch)
|
|
26
|
+
(updateDeps.relaunch ?? relaunchHd)();
|
|
22
27
|
}
|
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 };
|
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." });
|
package/dist/update-check.js
CHANGED
|
@@ -56,6 +56,9 @@ export function readUpdateCheck(path = updateCheckPath()) {
|
|
|
56
56
|
return {
|
|
57
57
|
latest: parsed.latest,
|
|
58
58
|
checked_at: typeof parsed.checked_at === "number" ? parsed.checked_at : 0,
|
|
59
|
+
...(typeof parsed.skipped_version === "string" && parsed.skipped_version
|
|
60
|
+
? { skipped_version: parsed.skipped_version }
|
|
61
|
+
: {}),
|
|
59
62
|
};
|
|
60
63
|
}
|
|
61
64
|
catch {
|
|
@@ -73,6 +76,34 @@ export function pendingUpdateNotice(deps = {}) {
|
|
|
73
76
|
return null;
|
|
74
77
|
return updateNoticeLine(stored.latest, current);
|
|
75
78
|
}
|
|
79
|
+
export function pendingTuiUpdate(deps = {}) {
|
|
80
|
+
const stored = readUpdateCheck(deps.path ?? updateCheckPath());
|
|
81
|
+
const current = deps.version ?? runningVersion();
|
|
82
|
+
if (!stored || !isNewer(stored.latest, current))
|
|
83
|
+
return null;
|
|
84
|
+
if (stored.skipped_version && !isNewer(stored.latest, stored.skipped_version))
|
|
85
|
+
return null;
|
|
86
|
+
return { latest: stored.latest, current };
|
|
87
|
+
}
|
|
88
|
+
export function tuiUpdatePrompt(update) {
|
|
89
|
+
return `hd ${update.latest} is available, you have ${update.current}. Update now? (y/n)`;
|
|
90
|
+
}
|
|
91
|
+
export function updatePromptAction(input, escape = false) {
|
|
92
|
+
if (escape || input.toLowerCase() === "n")
|
|
93
|
+
return "skip";
|
|
94
|
+
if (input.toLowerCase() === "y")
|
|
95
|
+
return "accept";
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
export function skipUpdate(update, deps = {}) {
|
|
99
|
+
const path = deps.path ?? updateCheckPath();
|
|
100
|
+
const stored = readUpdateCheck(path);
|
|
101
|
+
writeUpdateCheck({
|
|
102
|
+
latest: stored?.latest ?? update.latest,
|
|
103
|
+
checked_at: stored?.checked_at ?? (deps.now ?? Date.now)(),
|
|
104
|
+
skipped_version: update.latest,
|
|
105
|
+
}, path);
|
|
106
|
+
}
|
|
76
107
|
export function subscribeUpdateNotice(listener) {
|
|
77
108
|
listeners.add(listener);
|
|
78
109
|
return () => {
|
|
@@ -111,8 +142,15 @@ export function beginUpdateCheck(deps = {}) {
|
|
|
111
142
|
inFlight = (async () => {
|
|
112
143
|
try {
|
|
113
144
|
const latest = await fetchLatest(deps);
|
|
114
|
-
if (latest)
|
|
115
|
-
|
|
145
|
+
if (latest) {
|
|
146
|
+
const path = deps.path ?? updateCheckPath();
|
|
147
|
+
const stored = readUpdateCheck(path);
|
|
148
|
+
writeUpdateCheck({
|
|
149
|
+
latest,
|
|
150
|
+
checked_at: (deps.now ?? Date.now)(),
|
|
151
|
+
...(stored?.skipped_version ? { skipped_version: stored.skipped_version } : {}),
|
|
152
|
+
}, path);
|
|
153
|
+
}
|
|
116
154
|
}
|
|
117
155
|
catch {
|
|
118
156
|
// Offline, timed out, or unwritable cache: the command still succeeds.
|
|
@@ -163,3 +201,6 @@ export async function runUpdate(argv, deps = {}) {
|
|
|
163
201
|
(deps.relaunch ?? relaunchHd)();
|
|
164
202
|
return lines;
|
|
165
203
|
}
|
|
204
|
+
export function runPromptedUpdate(update, deps = {}) {
|
|
205
|
+
return runUpdate([], { ...deps, version: update.current, tuiRunning: true });
|
|
206
|
+
}
|