@higherdev/cli 0.18.0 → 0.22.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/api.js +19 -0
- package/dist/host.js +1 -0
- package/dist/index.js +54 -3
- package/dist/out.js +2 -1
- package/dist/roadmap.js +23 -0
- package/dist/tui/App.js +178 -29
- package/dist/tui/Chat.js +28 -0
- package/dist/tui/Dashboard.js +7 -2
- package/dist/tui/Help.js +4 -2
- package/dist/tui/Roadmap.js +38 -0
- package/dist/tui/chat-view.js +143 -0
- package/dist/tui/data.js +39 -5
- package/dist/tui/parse.js +4 -0
- package/dist/tui/ticket-view.js +1 -1
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -58,6 +58,9 @@ export async function queueTicket(key, config = loadConfig()) {
|
|
|
58
58
|
export async function cancelTicket(key, config = loadConfig()) {
|
|
59
59
|
return request(config, "POST", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/cancel`);
|
|
60
60
|
}
|
|
61
|
+
export async function mergeTicket(key, reason, config = loadConfig()) {
|
|
62
|
+
return request(config, "POST", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/merge`, { reason: reason ?? "", by: "owner" }, true);
|
|
63
|
+
}
|
|
61
64
|
export async function listTicketRunEvents(key, afterAt, afterId, config = loadConfig()) {
|
|
62
65
|
const query = new URLSearchParams();
|
|
63
66
|
if (afterAt)
|
|
@@ -75,6 +78,9 @@ export async function listTicketStory(key, config = loadConfig()) {
|
|
|
75
78
|
export async function postMessage(fields, config = loadConfig()) {
|
|
76
79
|
return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
|
|
77
80
|
}
|
|
81
|
+
export async function getRun(id, config = loadConfig()) {
|
|
82
|
+
return request(config, "GET", `/api/w/${config.slug}/runs/${encodeURIComponent(id)}`);
|
|
83
|
+
}
|
|
78
84
|
export async function answerDecision(id, answer_md, config = loadConfig()) {
|
|
79
85
|
return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
|
|
80
86
|
}
|
|
@@ -128,6 +134,12 @@ export async function setHostEnv(host, name, value, config = loadConfig()) {
|
|
|
128
134
|
export async function removeHostEnv(host, name, config = loadConfig()) {
|
|
129
135
|
return request(config, "DELETE", `/api/hosts/${encodeURIComponent(host)}/env`, { name });
|
|
130
136
|
}
|
|
137
|
+
export async function getRoadmap(config = loadConfig()) {
|
|
138
|
+
return request(config, "GET", `/api/w/${config.slug}/roadmap`);
|
|
139
|
+
}
|
|
140
|
+
export async function updateRoadmap(vision_md, config = loadConfig()) {
|
|
141
|
+
return request(config, "PATCH", `/api/w/${config.slug}/roadmap`, { vision_md });
|
|
142
|
+
}
|
|
131
143
|
export async function listEpics(config = loadConfig()) {
|
|
132
144
|
return request(config, "GET", `/api/w/${config.slug}/epics`);
|
|
133
145
|
}
|
|
@@ -137,6 +149,9 @@ export async function createEpic(fields, config = loadConfig()) {
|
|
|
137
149
|
export async function approveEpic(id, config = loadConfig()) {
|
|
138
150
|
return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id });
|
|
139
151
|
}
|
|
152
|
+
export async function updateEpic(id, fields, config = loadConfig()) {
|
|
153
|
+
return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id, ...fields });
|
|
154
|
+
}
|
|
140
155
|
export async function deleteEpic(id, config = loadConfig()) {
|
|
141
156
|
return request(config, "DELETE", `/api/w/${config.slug}/epics`, { id });
|
|
142
157
|
}
|
|
@@ -152,6 +167,10 @@ export async function listMessages(options = {}, config = loadConfig()) {
|
|
|
152
167
|
query.set("offset", String(options.offset));
|
|
153
168
|
if (options.toRoles?.length)
|
|
154
169
|
query.set("to_role", options.toRoles.join(","));
|
|
170
|
+
if (options.fromRoles?.length)
|
|
171
|
+
query.set("from_role", options.fromRoles.join(","));
|
|
172
|
+
if (options.unticketed)
|
|
173
|
+
query.set("unticketed", "true");
|
|
155
174
|
if (options.undelivered)
|
|
156
175
|
query.set("undelivered", "true");
|
|
157
176
|
if (options.delivered)
|
package/dist/host.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import { approveEpic, answerDecision, cancelTicket, createEpic, deleteAgent, deleteEpic, getStatus, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
|
|
4
|
+
import { approveEpic, answerDecision, cancelTicket, mergeTicket, createEpic, deleteAgent, deleteEpic, getStatus, getRoadmap, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateEpic, updateCaps, } from "./api.js";
|
|
5
5
|
import { formatDrainStatus, hostRoll, initHost, parseHostFlags, parseHostRollFlags } from "./host.js";
|
|
6
6
|
import { login, parseLoginFlags } from "./login.js";
|
|
7
7
|
import { loadConfig } from "./config.js";
|
|
8
8
|
import { epicProgressRows, readEpicSpec } from "./epics.js";
|
|
9
|
+
import { roadmapText } from "./roadmap.js";
|
|
9
10
|
import { banner, c, statusChip, table, truncate, usage } from "./out.js";
|
|
10
11
|
import { ticketNew } from "./ticket-commands.js";
|
|
11
12
|
import { ticketViewLines } from "./tui/ticket-view.js";
|
|
@@ -138,7 +139,16 @@ async function cmdTicket(argv, deps = {}) {
|
|
|
138
139
|
console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)}`);
|
|
139
140
|
return;
|
|
140
141
|
}
|
|
141
|
-
|
|
142
|
+
if (action === "merge") {
|
|
143
|
+
const parsed = flags(rest);
|
|
144
|
+
const key = parsed.rest[0];
|
|
145
|
+
if (!key || parsed.rest.length !== 1)
|
|
146
|
+
fail("usage: hd ticket merge KEY [--reason TEXT]");
|
|
147
|
+
const { ticket } = await mergeTicket(key.toUpperCase(), parsed.opts.reason);
|
|
148
|
+
console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
fail("usage: hd ticket list | show KEY | new --title TITLE | queue KEY | cancel KEY | merge KEY");
|
|
142
152
|
}
|
|
143
153
|
async function cmdEpic(argv) {
|
|
144
154
|
const [action, ...rest] = argv;
|
|
@@ -178,7 +188,44 @@ async function cmdEpic(argv) {
|
|
|
178
188
|
}
|
|
179
189
|
return;
|
|
180
190
|
}
|
|
181
|
-
|
|
191
|
+
if (action === "set") {
|
|
192
|
+
const parsed = flags(rest);
|
|
193
|
+
const id = parsed.rest[0];
|
|
194
|
+
const choices = ["after", "position", "depends-on"].filter((name) => parsed.opts[name] !== undefined);
|
|
195
|
+
if (!id || parsed.rest.length !== 1 || choices.length !== 1) {
|
|
196
|
+
fail("usage: hd epic set ID --after ID | --position N | --depends-on ID");
|
|
197
|
+
}
|
|
198
|
+
const position = parsed.opts.position ? Number(parsed.opts.position) : undefined;
|
|
199
|
+
if (position !== undefined && (!Number.isInteger(position) || position < 1)) {
|
|
200
|
+
fail("position must be a positive integer.");
|
|
201
|
+
}
|
|
202
|
+
const { epic } = await updateEpic(id, {
|
|
203
|
+
...(parsed.opts.after ? { after: parsed.opts.after } : {}),
|
|
204
|
+
...(position !== undefined ? { position } : {}),
|
|
205
|
+
...(parsed.opts["depends-on"] ? { depends_on: [parsed.opts["depends-on"]] } : {}),
|
|
206
|
+
});
|
|
207
|
+
console.log(`${c.bold(epic.id)} position ${epic.position}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
fail("usage: hd epic new PATH [--title TITLE] | list | approve ID | rm ID | set ID --position N");
|
|
211
|
+
}
|
|
212
|
+
async function cmdRoadmap(argv) {
|
|
213
|
+
const parsed = flags(argv);
|
|
214
|
+
if (parsed.rest.length || [...parsed.bools].some((name) => name !== "json") || Object.keys(parsed.opts).length) {
|
|
215
|
+
fail("usage: hd roadmap [--json]");
|
|
216
|
+
}
|
|
217
|
+
const data = await getRoadmap();
|
|
218
|
+
if (parsed.bools.has("json"))
|
|
219
|
+
return console.log(JSON.stringify(data));
|
|
220
|
+
if (!data.roadmap)
|
|
221
|
+
return console.log(c.dim("No roadmap."));
|
|
222
|
+
console.log(c.bold("Vision"));
|
|
223
|
+
console.log(data.roadmap.vision_md);
|
|
224
|
+
if (!data.epics.length)
|
|
225
|
+
return console.log(`\n${c.dim("No epics.")}`);
|
|
226
|
+
console.log("");
|
|
227
|
+
for (const line of roadmapText(data.epics))
|
|
228
|
+
console.log(line);
|
|
182
229
|
}
|
|
183
230
|
async function cmdPlan(argv) {
|
|
184
231
|
if (argv.length)
|
|
@@ -554,6 +601,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
554
601
|
await cmdEpic(rest);
|
|
555
602
|
return;
|
|
556
603
|
}
|
|
604
|
+
if (cmd === "roadmap") {
|
|
605
|
+
await cmdRoadmap(rest);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
557
608
|
if (cmd === "plan") {
|
|
558
609
|
await cmdPlan(rest);
|
|
559
610
|
return;
|
package/dist/out.js
CHANGED
|
@@ -64,8 +64,9 @@ export function usage() {
|
|
|
64
64
|
return [
|
|
65
65
|
c.bold("Usage"),
|
|
66
66
|
` ${c.blue("hd status")} workspace overview`,
|
|
67
|
-
` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel")} ticket operations`,
|
|
67
|
+
` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel | merge")} ticket operations`,
|
|
68
68
|
` ${c.blue("hd epic new PATH | list | approve | rm")} epic operations`,
|
|
69
|
+
` ${c.blue("hd roadmap [--json]")} ordered workspace roadmap`,
|
|
69
70
|
` ${c.blue("hd plan")} use /architect in the TUI`,
|
|
70
71
|
` ${c.blue("hd workspace ls | use | new | set | rotate-key | grant-runner-access")} workspace operations`,
|
|
71
72
|
` ${c.blue("hd agents [add | rm | set]")} manage agents`,
|
package/dist/roadmap.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function currentRoadmapEpic(epics) {
|
|
2
|
+
const statuses = new Map(epics.map((epic) => [epic.id, epic.status]));
|
|
3
|
+
return [...epics].sort((a, b) => a.position - b.position).find((epic) => !["draft", "done"].includes(epic.status)
|
|
4
|
+
&& epic.depends_on.every((id) => statuses.get(id) === "done")) ?? null;
|
|
5
|
+
}
|
|
6
|
+
export function progressBar(merged, total, width = 10) {
|
|
7
|
+
const complete = total > 0 ? Math.round((Math.max(0, Math.min(merged, total)) / total) * width) : 0;
|
|
8
|
+
return `${"█".repeat(complete)}${"░".repeat(Math.max(0, width - complete))}`;
|
|
9
|
+
}
|
|
10
|
+
export function roadmapText(epics) {
|
|
11
|
+
const names = new Map(epics.map((epic) => [epic.id, epic.title]));
|
|
12
|
+
const current = currentRoadmapEpic(epics);
|
|
13
|
+
return [...epics].sort((a, b) => a.position - b.position).flatMap((epic) => {
|
|
14
|
+
const dependencies = epic.depends_on.map((id) => names.get(id) ?? id);
|
|
15
|
+
const status = epic.status === "draft" ? "draft, awaiting approval" : epic.status;
|
|
16
|
+
return [
|
|
17
|
+
`${epic.id === current?.id ? "▶" : " "} ${epic.position}. ${epic.title} [${status}]`,
|
|
18
|
+
` Outcome: ${epic.outcome_md || "Not described"}`,
|
|
19
|
+
` Depends on: ${dependencies.length ? dependencies.join(", ") : "none"}`,
|
|
20
|
+
` ${progressBar(epic.merged, epic.total)} ${epic.merged}/${epic.total} merged`,
|
|
21
|
+
];
|
|
22
|
+
});
|
|
23
|
+
}
|
package/dist/tui/App.js
CHANGED
|
@@ -9,20 +9,23 @@ import { agentAdd } from "../agent-commands.js";
|
|
|
9
9
|
import { workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
|
|
10
10
|
import { Banner } from "./Banner.js";
|
|
11
11
|
import { Bubble } from "./Bubble.js";
|
|
12
|
+
import { ChatPanel } from "./Chat.js";
|
|
13
|
+
import { chatAgentLabel, chatScrollOffset, chatViewLines, emptyThread, followShouldStop, leaveChat, mergeHydratedThread, pendingActivity, rehydrateThread, threadKey, } from "./chat-view.js";
|
|
12
14
|
import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js";
|
|
13
15
|
import { DecisionPanel, decisionRows } from "./Decision.js";
|
|
14
16
|
import { COMMANDS, Help } from "./Help.js";
|
|
15
17
|
import { AgentsPanel, BoardPanel, FeedPanel, InboxPanel, TicketPanel, inboxEntries } from "./Panels.js";
|
|
16
18
|
import { sectionAt, ticketScrollOffset, ticketViewLines, toggleSection } from "./ticket-view.js";
|
|
17
19
|
import { SettingsPanel } from "./Settings.js";
|
|
20
|
+
import { RoadmapPanel, roadmapLines } from "./Roadmap.js";
|
|
18
21
|
import { Splash } from "./Splash.js";
|
|
19
22
|
import TextInput from "./TextInput.js";
|
|
20
23
|
import { alertOnce } from "./alert.js";
|
|
21
24
|
import { bubbleRows } from "./height.js";
|
|
22
25
|
import { planLayout, splitPanels } from "./layout.js";
|
|
23
26
|
import { parseLine } from "./parse.js";
|
|
24
|
-
import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace,
|
|
25
|
-
import { inputActive,
|
|
27
|
+
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";
|
|
28
|
+
import { inputActive, NO_REPLY_NOTE, promptPlaceholder, REPLY_WAIT_MS } from "./chat-wait.js";
|
|
26
29
|
import { answeredLine, decisionHeaderIndex, decisionIdAt, moveDecisionFocus, nextUnanswered, resolveDecisionAnswer, } from "./decide-nav.js";
|
|
27
30
|
import { EARLIER_PAGE } from "./inbox.js";
|
|
28
31
|
import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
|
|
@@ -46,11 +49,15 @@ export function App({ initial }) {
|
|
|
46
49
|
const [view, setView] = useState("home");
|
|
47
50
|
const [live, setLive] = useState("connecting");
|
|
48
51
|
const [messages, setMessages] = useState([]);
|
|
52
|
+
const [threads, setThreads] = useState({});
|
|
53
|
+
const [chatOffset, setChatOffset] = useState(0);
|
|
54
|
+
const [now, setNow] = useState(Date.now());
|
|
49
55
|
const [draft, setDraft] = useState("");
|
|
50
56
|
const [busy, setBusy] = useState(false);
|
|
51
57
|
const [notice, setNotice] = useState(null);
|
|
52
58
|
const [ticketKey, setTicketKey] = useState(null);
|
|
53
59
|
const [ticketOffset, setTicketOffset] = useState(0);
|
|
60
|
+
const [roadmapOffset, setRoadmapOffset] = useState(0);
|
|
54
61
|
const [ticketCollapsed, setTicketCollapsed] = useState([]);
|
|
55
62
|
const [ready, setReady] = useState(false);
|
|
56
63
|
const [stream, setStream] = useState([]);
|
|
@@ -97,6 +104,12 @@ export function App({ initial }) {
|
|
|
97
104
|
if (live.cockpit > 0)
|
|
98
105
|
setStarted(true);
|
|
99
106
|
}, [ready, rows, columns, width, board.decisions]);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (view !== "chat")
|
|
109
|
+
return;
|
|
110
|
+
const timer = setInterval(() => setNow(Date.now()), 1_000);
|
|
111
|
+
return () => clearInterval(timer);
|
|
112
|
+
}, [view]);
|
|
100
113
|
const applySnapshot = useCallback((snapshot) => {
|
|
101
114
|
const token = loads.current.start(snapshot.workspace.id);
|
|
102
115
|
if (!loads.current.isCurrent(token))
|
|
@@ -302,27 +315,95 @@ export function App({ initial }) {
|
|
|
302
315
|
setBusy(false);
|
|
303
316
|
}
|
|
304
317
|
}, [config, say]);
|
|
318
|
+
const agentName = useCallback((role) => chatAgentLabel(role, board.agents.find((agent) => agent.role === role)?.display_name), [board.agents]);
|
|
319
|
+
const setThread = useCallback((key, update) => {
|
|
320
|
+
setThreads((prior) => {
|
|
321
|
+
const current = prior[key] ?? emptyThread(key.split(":")[1], key.split(":")[0] ?? workspace.id);
|
|
322
|
+
return { ...prior, [key]: update(current) };
|
|
323
|
+
});
|
|
324
|
+
}, [workspace.id]);
|
|
325
|
+
const hydrateChat = useCallback(async (role) => {
|
|
326
|
+
const key = threadKey(workspace.id, role);
|
|
327
|
+
try {
|
|
328
|
+
const remote = rehydrateThread(await loadChatMessages(config, role), role, workspace.id);
|
|
329
|
+
setThreads((prior) => ({
|
|
330
|
+
...prior,
|
|
331
|
+
[key]: mergeHydratedThread(prior[key] ?? null, remote),
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
336
|
+
}
|
|
337
|
+
}, [config, workspace.id]);
|
|
338
|
+
const openAgentChat = useCallback((role) => {
|
|
339
|
+
setMode(role);
|
|
340
|
+
setView("chat");
|
|
341
|
+
setCursor(null);
|
|
342
|
+
selectedRef.current = null;
|
|
343
|
+
setChatOffset(10_000);
|
|
344
|
+
void hydrateChat(role);
|
|
345
|
+
}, [hydrateChat]);
|
|
305
346
|
const askAgent = useCallback((role, text) => {
|
|
306
|
-
const
|
|
307
|
-
setMessages((prior) => [...prior, { id, speaker: role, body: "", pending: true }]);
|
|
347
|
+
const key = threadKey(workspace.id, role);
|
|
308
348
|
const since = new Date().toISOString();
|
|
349
|
+
const youTurn = { id: nextId(), speaker: "you", body: text, at: since };
|
|
350
|
+
setMode(role);
|
|
351
|
+
setView("chat");
|
|
352
|
+
setChatOffset(10_000);
|
|
353
|
+
setThread(key, (current) => ({
|
|
354
|
+
...current,
|
|
355
|
+
role,
|
|
356
|
+
workspaceId: workspace.id,
|
|
357
|
+
turns: [...current.turns, youTurn],
|
|
358
|
+
pending: { messageId: null, runId: null, startedAt: since, status: "queued", activity: [] },
|
|
359
|
+
}));
|
|
309
360
|
void (async () => {
|
|
310
361
|
try {
|
|
311
|
-
await postAgentMessage(role, text, config);
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
362
|
+
const posted = await postAgentMessage(role, text, config);
|
|
363
|
+
const deadline = Date.now() + REPLY_WAIT_MS;
|
|
364
|
+
while (Date.now() <= deadline) {
|
|
365
|
+
const follow = await followChat(config, role, posted.id, since);
|
|
366
|
+
const activity = pendingActivity(follow.events, follow.run, agentName(role));
|
|
367
|
+
setThread(key, (current) => ({
|
|
368
|
+
...current,
|
|
369
|
+
pending: {
|
|
370
|
+
messageId: posted.id,
|
|
371
|
+
runId: follow.run?.id ?? null,
|
|
372
|
+
startedAt: follow.run?.started_at ?? current.pending?.startedAt ?? since,
|
|
373
|
+
status: follow.run?.status ?? "queued",
|
|
374
|
+
activity: activity.lines,
|
|
375
|
+
},
|
|
376
|
+
}));
|
|
377
|
+
if (followShouldStop(follow.run, follow.reply)) {
|
|
378
|
+
const body = follow.reply ?? follow.run?.summary ?? NO_REPLY_NOTE;
|
|
379
|
+
setThread(key, (current) => ({
|
|
380
|
+
...current,
|
|
381
|
+
turns: [...current.turns, { id: `reply-${posted.id}`, speaker: role, body, at: new Date().toISOString() }],
|
|
382
|
+
pending: null,
|
|
383
|
+
}));
|
|
384
|
+
setChatOffset(10_000);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
388
|
+
}
|
|
389
|
+
setThread(key, (current) => ({
|
|
390
|
+
...current,
|
|
391
|
+
turns: [...current.turns, {
|
|
392
|
+
id: `timeout-${posted.id}`, speaker: role, body: NO_REPLY_NOTE, at: new Date().toISOString(),
|
|
393
|
+
}],
|
|
394
|
+
pending: null,
|
|
395
|
+
}));
|
|
319
396
|
}
|
|
320
397
|
catch (error) {
|
|
321
398
|
const body = error instanceof Error ? error.message : String(error);
|
|
322
|
-
|
|
399
|
+
setThread(key, (current) => ({
|
|
400
|
+
...current,
|
|
401
|
+
turns: [...current.turns, { id: nextId(), speaker: role, body, at: new Date().toISOString() }],
|
|
402
|
+
pending: null,
|
|
403
|
+
}));
|
|
323
404
|
}
|
|
324
405
|
})();
|
|
325
|
-
}, [config]);
|
|
406
|
+
}, [agentName, config, setThread, workspace.id]);
|
|
326
407
|
const openTicket = useCallback(async (key) => {
|
|
327
408
|
setTicketKey(key);
|
|
328
409
|
setView("ticket");
|
|
@@ -415,7 +496,6 @@ export function App({ initial }) {
|
|
|
415
496
|
setNotice(null);
|
|
416
497
|
const action = parseLine(text);
|
|
417
498
|
if (action.kind === "say") {
|
|
418
|
-
say("you", text);
|
|
419
499
|
if (mode !== "browse")
|
|
420
500
|
askAgent(mode, text);
|
|
421
501
|
else
|
|
@@ -424,15 +504,12 @@ export function App({ initial }) {
|
|
|
424
504
|
}
|
|
425
505
|
switch (action.kind) {
|
|
426
506
|
case "mode":
|
|
427
|
-
|
|
428
|
-
setCursor(null);
|
|
429
|
-
selectedRef.current = null;
|
|
430
|
-
say("system", action.mode === "architect"
|
|
431
|
-
? "Talking to the architect. Ask questions, refine the epic, then explicitly request a draft."
|
|
432
|
-
: "Talking to the orchestrator. It moves work already in flight.");
|
|
507
|
+
openAgentChat(action.mode);
|
|
433
508
|
return;
|
|
434
509
|
case "view":
|
|
435
510
|
setView(action.view);
|
|
511
|
+
if (action.view === "roadmap")
|
|
512
|
+
setRoadmapOffset(0);
|
|
436
513
|
if (action.view === "inbox") {
|
|
437
514
|
const header = selectedDecisionId ? decisionHeaderIndex(inbox, selectedDecisionId) : 0;
|
|
438
515
|
setInboxFocus(header >= 0 ? header : 0);
|
|
@@ -616,6 +693,20 @@ export function App({ initial }) {
|
|
|
616
693
|
setBusy(false);
|
|
617
694
|
}
|
|
618
695
|
return;
|
|
696
|
+
case "merge":
|
|
697
|
+
setBusy(true);
|
|
698
|
+
try {
|
|
699
|
+
const { ticket: merged } = await mergeTicket(config, action.key);
|
|
700
|
+
say("system", `${merged.key} approved over review.`);
|
|
701
|
+
await refresh();
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
705
|
+
}
|
|
706
|
+
finally {
|
|
707
|
+
setBusy(false);
|
|
708
|
+
}
|
|
709
|
+
return;
|
|
619
710
|
case "agent-add":
|
|
620
711
|
setBusy(true);
|
|
621
712
|
try {
|
|
@@ -736,9 +827,9 @@ export function App({ initial }) {
|
|
|
736
827
|
default:
|
|
737
828
|
return;
|
|
738
829
|
}
|
|
739
|
-
}, [view, settings, applyEdit, board, browsing, mode, say, askAgent, order, settingsOrder,
|
|
740
|
-
config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
|
|
741
|
-
submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
|
|
830
|
+
}, [view, settings, applyEdit, board, browsing, mode, say, askAgent, openAgentChat, order, settingsOrder,
|
|
831
|
+
changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
|
|
832
|
+
selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
|
|
742
833
|
useInput((input, key) => {
|
|
743
834
|
if (key.ctrl && input === "c")
|
|
744
835
|
exit();
|
|
@@ -758,14 +849,21 @@ export function App({ initial }) {
|
|
|
758
849
|
const settled = messages.filter((message) => message.done);
|
|
759
850
|
const inFlight = messages.filter((message) => !message.done);
|
|
760
851
|
const splash = !started;
|
|
852
|
+
const chatRole = view === "chat" && mode !== "browse" ? mode : null;
|
|
853
|
+
const activeThread = chatRole
|
|
854
|
+
? threads[threadKey(workspace.id, chatRole)] ?? emptyThread(chatRole, workspace.id)
|
|
855
|
+
: null;
|
|
856
|
+
const chatLabel = chatRole ? agentName(chatRole) : "";
|
|
857
|
+
const chatLines = activeThread ? chatViewLines(activeThread, Math.max(20, width), chatLabel, now) : [];
|
|
761
858
|
const scrollback = splash ? [] : [
|
|
762
859
|
{ key: "banner" },
|
|
763
860
|
{ key: "help", message: { id: "help", speaker: "system", body: "", panel: "help" } },
|
|
764
861
|
...settled.map((message) => ({ key: message.id, message })),
|
|
765
862
|
];
|
|
863
|
+
const chatting = view === "chat";
|
|
766
864
|
const plan = planLayout({
|
|
767
|
-
rows, columns, width, splash, ready, decision: decisionRows(decisions),
|
|
768
|
-
inFlight: inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
|
|
865
|
+
rows, columns, width, splash, ready, decision: chatting ? 0 : decisionRows(decisions),
|
|
866
|
+
inFlight: chatting ? 0 : inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
|
|
769
867
|
notice: Boolean(notice), home: view === "home",
|
|
770
868
|
});
|
|
771
869
|
const agentsView = splitPanels(plan.panels);
|
|
@@ -777,15 +875,15 @@ export function App({ initial }) {
|
|
|
777
875
|
if (item.message.panel === "help")
|
|
778
876
|
return _jsx(Help, { width: width }, item.key);
|
|
779
877
|
return _jsx(Bubble, { message: item.message, width: width }, item.key);
|
|
780
|
-
} }), 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 === "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
|
|
878
|
+
} }), 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
|
|
781
879
|
? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels, offset: ticketOffset, collapsed: ticketCollapsed })
|
|
782
|
-
: _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,
|
|
880
|
+
: _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` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
|
|
783
881
|
setDraft(next);
|
|
784
882
|
if (editingRef.current)
|
|
785
883
|
setEditing({ key: editingRef.current.key, draft: next });
|
|
786
884
|
}, onSubmit: (value) => void run(value), isActive: inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
|
|
787
885
|
? (answeringOptions.length ? `1-${answeringOptions.length} or your answer` : "your answer")
|
|
788
|
-
: promptPlaceholder({ busy, pendingChats: inFlight.length }), prompt: _jsx(Text, { color: answering || mode !== "browse" ? UI.cream : UI.dim, children: answering ? `answer ${answeringNumber}> ` : mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
|
|
886
|
+
: 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: () => {
|
|
789
887
|
if (answering) {
|
|
790
888
|
setAnswering(null);
|
|
791
889
|
setDraft("");
|
|
@@ -798,6 +896,14 @@ export function App({ initial }) {
|
|
|
798
896
|
setNotice(null);
|
|
799
897
|
return;
|
|
800
898
|
}
|
|
899
|
+
if (chatting || mode !== "browse") {
|
|
900
|
+
const left = leaveChat();
|
|
901
|
+
setMode(left.mode);
|
|
902
|
+
setView(left.view);
|
|
903
|
+
setDraft("");
|
|
904
|
+
setNotice(left.notice);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
801
907
|
if (view !== "home") {
|
|
802
908
|
setView("home");
|
|
803
909
|
setTicketKey(null);
|
|
@@ -806,6 +912,16 @@ export function App({ initial }) {
|
|
|
806
912
|
setCursor(null);
|
|
807
913
|
selectedRef.current = null;
|
|
808
914
|
}, onUp: () => {
|
|
915
|
+
if (chatting && !draft) {
|
|
916
|
+
const overflow = chatLines.length > plan.panels;
|
|
917
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
918
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - 1));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (view === "roadmap" && !draft) {
|
|
922
|
+
setRoadmapOffset((current) => Math.max(0, current - 1));
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
809
925
|
if (view === "ticket" && !draft) {
|
|
810
926
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
811
927
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - 1));
|
|
@@ -824,6 +940,17 @@ export function App({ initial }) {
|
|
|
824
940
|
historyAt.current = historyAt.current < 0 ? history.current.length - 1 : Math.max(0, historyAt.current - 1);
|
|
825
941
|
setDraft(history.current[historyAt.current] ?? "");
|
|
826
942
|
}, onDown: () => {
|
|
943
|
+
if (chatting && !draft) {
|
|
944
|
+
const overflow = chatLines.length > plan.panels;
|
|
945
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
946
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + 1));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (view === "roadmap" && !draft) {
|
|
950
|
+
const max = Math.max(0, roadmapLines(board, width).length - Math.max(1, plan.panels - 1));
|
|
951
|
+
setRoadmapOffset((current) => Math.min(max, current + 1));
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
827
954
|
if (view === "ticket" && !draft) {
|
|
828
955
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
829
956
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + 1));
|
|
@@ -847,11 +974,33 @@ export function App({ initial }) {
|
|
|
847
974
|
}
|
|
848
975
|
setDraft(history.current[historyAt.current] ?? "");
|
|
849
976
|
}, onPageUp: () => {
|
|
977
|
+
if (chatting && !draft) {
|
|
978
|
+
const overflow = chatLines.length > plan.panels;
|
|
979
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
980
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - Math.max(1, inner)));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (view === "roadmap" && !draft) {
|
|
984
|
+
setRoadmapOffset((current) => Math.max(0, current - Math.max(1, plan.panels - 1)));
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
850
987
|
if (view === "ticket" && !draft) {
|
|
851
988
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
852
989
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - Math.max(1, plan.panels)));
|
|
853
990
|
}
|
|
854
991
|
}, onPageDown: () => {
|
|
992
|
+
if (chatting && !draft) {
|
|
993
|
+
const overflow = chatLines.length > plan.panels;
|
|
994
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
995
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + Math.max(1, inner)));
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (view === "roadmap" && !draft) {
|
|
999
|
+
const page = Math.max(1, plan.panels - 1);
|
|
1000
|
+
const max = Math.max(0, roadmapLines(board, width).length - page);
|
|
1001
|
+
setRoadmapOffset((current) => Math.min(max, current + page));
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
855
1004
|
if (view === "ticket" && !draft) {
|
|
856
1005
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
857
1006
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + Math.max(1, plan.panels)));
|
package/dist/tui/Chat.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { BoundedPanel as Panel } from "./bounded.js";
|
|
4
|
+
import { chatScrollOffset, chatViewLines, } from "./chat-view.js";
|
|
5
|
+
import { UI } from "./theme.js";
|
|
6
|
+
export function ChatPanel({ thread, width = 80, rows = 24, offset = 0, label, now = Date.now(), }) {
|
|
7
|
+
const entries = chatViewLines(thread, Math.max(20, width), label, now);
|
|
8
|
+
const drawable = Math.max(0, rows);
|
|
9
|
+
const overflow = entries.length > drawable;
|
|
10
|
+
const inner = overflow ? Math.max(0, drawable - 1) : drawable;
|
|
11
|
+
const start = chatScrollOffset(entries.length, inner, offset);
|
|
12
|
+
const shown = entries.slice(start, start + inner);
|
|
13
|
+
const hiddenAbove = start;
|
|
14
|
+
const hiddenBelow = Math.max(0, entries.length - start - shown.length);
|
|
15
|
+
return (_jsx(Panel, { width: width, rows: drawable, children: [
|
|
16
|
+
...(shown.length === 0
|
|
17
|
+
? [
|
|
18
|
+
_jsx(Text, { color: UI.dim, children: `No messages yet. Say something to ${label}.` }, "empty"),
|
|
19
|
+
]
|
|
20
|
+
: shown.map((entry) => (_jsx(Text, { color: entry.kind === "status" ? UI.accent : entry.kind === "activity" ? UI.dim
|
|
21
|
+
: entry.kind === "speaker" ? UI.warn : UI.text, wrap: "truncate", children: entry.text }, entry.key)))),
|
|
22
|
+
overflow ? (_jsxs(Text, { color: UI.dim, wrap: "truncate", children: [hiddenAbove ? `${hiddenAbove}↑ ` : "", hiddenBelow ? `${hiddenBelow}↓` : ""] }, "more")) : null,
|
|
23
|
+
] }));
|
|
24
|
+
}
|
|
25
|
+
/** Thread stacked above a prompt, for layout tests. */
|
|
26
|
+
export function ChatFrame({ thread, width = 80, rows = 12, offset = 0, label, prompt, now = Date.now(), }) {
|
|
27
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(ChatPanel, { thread: thread, width: width, rows: rows, offset: offset, label: label, now: now }), _jsx(Text, { children: prompt })] }));
|
|
28
|
+
}
|
package/dist/tui/Dashboard.js
CHANGED
|
@@ -6,6 +6,7 @@ import { inkColor } from "../out/theme.js";
|
|
|
6
6
|
import { UI } from "./theme.js";
|
|
7
7
|
import { BoundedPanel as Panel, Heading, More, contentRows } from "./bounded.js";
|
|
8
8
|
import { agentDisplayRows } from "./agent-rows.js";
|
|
9
|
+
import { currentRoadmapEpic } from "../roadmap.js";
|
|
9
10
|
const DOT = "●";
|
|
10
11
|
/**
|
|
11
12
|
* The width at which the board and the agents stop competing for the same
|
|
@@ -157,8 +158,12 @@ export function epicsRows(board, cap = 3) {
|
|
|
157
158
|
return open.length === 0 ? 0 : Math.min(open.length, cap) + 1;
|
|
158
159
|
}
|
|
159
160
|
export function nowEntries(board) {
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
const current = currentRoadmapEpic(board.epics ?? []);
|
|
162
|
+
return [
|
|
163
|
+
...(current ? [{ key: `epic:${current.id}`, headline: `Current epic: ${current.title}` }] : []),
|
|
164
|
+
...board.tickets.filter((ticket) => !["backlog", "merged", "cancelled"].includes(ticket.status))
|
|
165
|
+
.map((ticket) => ({ key: ticket.key, headline: ticket.latest_headline ?? `${ticket.key}: ${ticket.title}` })),
|
|
166
|
+
];
|
|
162
167
|
}
|
|
163
168
|
function NowStrip({ board, width, rows }) {
|
|
164
169
|
const entries = nowEntries(board);
|
package/dist/tui/Help.js
CHANGED
|
@@ -6,15 +6,17 @@ export const HELP_FOOTER = "Anything not starting with / goes to whoever you are
|
|
|
6
6
|
/** Everything you can type. The app is driven from here, not from flags. */
|
|
7
7
|
export const COMMANDS = [
|
|
8
8
|
{ name: "/board", help: "the kanban board" },
|
|
9
|
+
{ name: "/roadmap", help: "the vision and ordered epic progress" },
|
|
9
10
|
{ name: "/inbox", args: "[more]", help: "unread, earlier, and numbered decisions; Enter answers" },
|
|
10
11
|
{ name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open a full ticket view or create one" },
|
|
11
12
|
{ name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
|
|
12
13
|
{ name: "/cancel", args: "HD-12", help: "cancel a ticket" },
|
|
14
|
+
{ name: "/merge", args: "HD-12", help: "approve a reviewed PR over the reviewer's objections" },
|
|
13
15
|
{ name: "/msg", args: "HD-12 TEXT", help: "message a ticket's builder" },
|
|
14
16
|
{ name: "/logs", args: "[raw] [HD-12]", help: "filter activity; raw reveals event JSON" },
|
|
15
17
|
{ name: "/epic", args: "new PATH | approve ID | rm ID", help: "create, approve, or remove a draft epic" },
|
|
16
18
|
{ name: "/epics", help: "list epics and ticket progress" },
|
|
17
|
-
{ name: "/architect", help: "
|
|
19
|
+
{ name: "/architect", help: "open a chat with the architect" },
|
|
18
20
|
{ name: "/plan", help: "alias for /architect" },
|
|
19
21
|
{ name: "/decide", args: "[N|ID] [answer]", help: "answer the selected or numbered decision" },
|
|
20
22
|
{ name: "/agents", args: "[add [ROLE] [flags] | rm ROLE|ID]", help: "view or manage named agents" },
|
|
@@ -24,7 +26,7 @@ export const COMMANDS = [
|
|
|
24
26
|
{ name: "/on", help: "turn on the current workspace" },
|
|
25
27
|
{ name: "/off", help: "turn off the current workspace" },
|
|
26
28
|
{ name: "/feed", help: "what just happened" },
|
|
27
|
-
{ name: "/orchestrator", help: "
|
|
29
|
+
{ name: "/orchestrator", help: "open a chat with the orchestrator" },
|
|
28
30
|
{ name: "/refresh", help: "reload the board now" },
|
|
29
31
|
{ name: "/help", help: "this list" },
|
|
30
32
|
{ name: "/exit", help: "leave" },
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { roadmapText } from "../roadmap.js";
|
|
4
|
+
import { inkColor } from "../out/theme.js";
|
|
5
|
+
import { epicProgress } from "./data.js";
|
|
6
|
+
import { BoundedPanel as Panel, Heading } from "./bounded.js";
|
|
7
|
+
import { wrapLines } from "./ticket-view.js";
|
|
8
|
+
import { UI } from "./theme.js";
|
|
9
|
+
export function roadmapLines(board, width) {
|
|
10
|
+
if (!board.roadmap)
|
|
11
|
+
return ["No roadmap yet. Talk to /architect to create one."];
|
|
12
|
+
const lines = ["Vision"];
|
|
13
|
+
lines.push(...wrapLines(board.roadmap.vision_md, Math.max(12, width - 2)));
|
|
14
|
+
lines.push("");
|
|
15
|
+
const progress = new Map(epicProgress(board).map((row) => [row.epic.id, row]));
|
|
16
|
+
lines.push(...roadmapText(board.epics.map((epic) => ({ ...epic,
|
|
17
|
+
merged: progress.get(epic.id)?.merged ?? 0,
|
|
18
|
+
total: progress.get(epic.id)?.total ?? 0,
|
|
19
|
+
}))));
|
|
20
|
+
return lines;
|
|
21
|
+
}
|
|
22
|
+
export function RoadmapPanel({ board, width = 80, rows = 12, offset = 0 }) {
|
|
23
|
+
const lines = roadmapLines(board, width);
|
|
24
|
+
const budget = Math.max(0, rows - 1);
|
|
25
|
+
const start = Math.max(0, Math.min(offset, Math.max(0, lines.length - budget)));
|
|
26
|
+
const shown = lines.slice(start, start + budget);
|
|
27
|
+
const note = `${board.epics.length} epics${start ? ` · ${start}↑` : ""}`
|
|
28
|
+
+ `${start + budget < lines.length ? ` · ${lines.length - start - budget}↓` : ""}`;
|
|
29
|
+
return _jsx(Panel, { width: width, rows: rows, children: [
|
|
30
|
+
_jsx(Heading, { text: "Roadmap", note: note }, "h"),
|
|
31
|
+
...shown.map((line, index) => {
|
|
32
|
+
const current = line.startsWith("▶");
|
|
33
|
+
const draft = line.includes("draft, awaiting approval");
|
|
34
|
+
return _jsx(Text, { color: current ? UI.accent
|
|
35
|
+
: draft ? inkColor("warning") : line === "Vision" ? UI.text : UI.dim, bold: current || line === "Vision", inverse: current, wrap: "truncate", children: line || " " }, `${start + index}:${line}`);
|
|
36
|
+
}),
|
|
37
|
+
] });
|
|
38
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { narrateEvents } from "./narrate.js";
|
|
2
|
+
import { wrapLines } from "./ticket-view.js";
|
|
3
|
+
export function chatInstructionEffects(body) {
|
|
4
|
+
const parts = [];
|
|
5
|
+
const answered = body.match(/Answered decision[^\n.;]*/i);
|
|
6
|
+
const queued = body.match(/HD-\d+ is queued/i);
|
|
7
|
+
const cap = body.match(/attempt cap is \d+/i);
|
|
8
|
+
if (answered)
|
|
9
|
+
parts.push(answered[0].trim().replace(/[.;]+$/, ""));
|
|
10
|
+
if (queued)
|
|
11
|
+
parts.push(queued[0].trim().replace(/[.;]+$/, ""));
|
|
12
|
+
if (cap)
|
|
13
|
+
parts.push(cap[0].trim().replace(/[.;]+$/, ""));
|
|
14
|
+
return parts.length ? parts.join("; ") : null;
|
|
15
|
+
}
|
|
16
|
+
/** Last 50 exchanges (a human turn and an agent turn) when rehydrating. */
|
|
17
|
+
export const CHAT_EXCHANGE_LIMIT = 50;
|
|
18
|
+
export const CHAT_MESSAGE_LIMIT = CHAT_EXCHANGE_LIMIT * 2;
|
|
19
|
+
const HUMAN_ROLES = new Set(["human", "operator"]);
|
|
20
|
+
const HUMAN_TARGETS = new Set(["human", "operator", "all"]);
|
|
21
|
+
export function threadKey(workspaceId, role) {
|
|
22
|
+
return `${workspaceId}:${role}`;
|
|
23
|
+
}
|
|
24
|
+
export function emptyThread(role, workspaceId) {
|
|
25
|
+
return { role, workspaceId, turns: [], pending: null };
|
|
26
|
+
}
|
|
27
|
+
export function chatAgentLabel(role, displayName) {
|
|
28
|
+
const named = displayName?.trim();
|
|
29
|
+
if (named)
|
|
30
|
+
return named;
|
|
31
|
+
return role === "architect" ? "Architect" : "Orchestrator";
|
|
32
|
+
}
|
|
33
|
+
export function formatWorkingElapsed(ms) {
|
|
34
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
35
|
+
if (seconds < 60)
|
|
36
|
+
return `${seconds}s`;
|
|
37
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
38
|
+
}
|
|
39
|
+
export function workingStatus(label, startedAt, now = Date.now()) {
|
|
40
|
+
const start = startedAt ? Date.parse(startedAt) : Number.NaN;
|
|
41
|
+
const elapsed = Number.isFinite(start) ? Math.max(0, now - start) : 0;
|
|
42
|
+
return `${label} is working (${formatWorkingElapsed(elapsed)})`;
|
|
43
|
+
}
|
|
44
|
+
export function isChatConversation(message, role) {
|
|
45
|
+
if (message.ticket_id)
|
|
46
|
+
return false;
|
|
47
|
+
const fromAgent = message.from_role === role && HUMAN_TARGETS.has(message.to_role);
|
|
48
|
+
const toAgent = HUMAN_ROLES.has(message.from_role) && message.to_role === role;
|
|
49
|
+
return fromAgent || toAgent;
|
|
50
|
+
}
|
|
51
|
+
export function rehydrateThread(messages, role, workspaceId) {
|
|
52
|
+
const conversation = messages
|
|
53
|
+
.filter((message) => isChatConversation(message, role))
|
|
54
|
+
.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id))
|
|
55
|
+
.slice(-CHAT_MESSAGE_LIMIT);
|
|
56
|
+
return {
|
|
57
|
+
role,
|
|
58
|
+
workspaceId,
|
|
59
|
+
turns: conversation.map((message) => ({
|
|
60
|
+
id: message.id,
|
|
61
|
+
speaker: message.from_role === role ? role : "you",
|
|
62
|
+
body: message.body_md,
|
|
63
|
+
at: message.created_at,
|
|
64
|
+
})),
|
|
65
|
+
pending: null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function restoreThread(threads, workspaceId, role) {
|
|
69
|
+
return threads[threadKey(workspaceId, role)] ?? null;
|
|
70
|
+
}
|
|
71
|
+
export function leaveChat() {
|
|
72
|
+
return { view: "home", mode: "browse", notice: "Back to browse." };
|
|
73
|
+
}
|
|
74
|
+
export function openChat(role) {
|
|
75
|
+
return { view: "chat", mode: role };
|
|
76
|
+
}
|
|
77
|
+
export function chatScrollOffset(count, rows, offset) {
|
|
78
|
+
if (count <= rows)
|
|
79
|
+
return 0;
|
|
80
|
+
return Math.max(0, Math.min(offset, count - rows));
|
|
81
|
+
}
|
|
82
|
+
export function chatWindow(count, rows, offset) {
|
|
83
|
+
const start = chatScrollOffset(count, rows, offset);
|
|
84
|
+
return { start, end: Math.min(count, start + Math.max(0, rows)) };
|
|
85
|
+
}
|
|
86
|
+
export function pendingActivity(events, run, label, now = Date.now()) {
|
|
87
|
+
const live = !run || ["queued", "running", ""].includes(run.status ?? "running");
|
|
88
|
+
return {
|
|
89
|
+
status: live ? workingStatus(label, run?.started_at ?? null, now) : "",
|
|
90
|
+
lines: narrateEvents(events).map((line) => line.title),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function followShouldStop(run, reply) {
|
|
94
|
+
if (reply)
|
|
95
|
+
return true;
|
|
96
|
+
if (run && !["queued", "running"].includes(run.status))
|
|
97
|
+
return true;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
export function chatViewLines(thread, width, label, now = Date.now()) {
|
|
101
|
+
const lines = [];
|
|
102
|
+
const bodyWidth = Math.max(8, width);
|
|
103
|
+
for (const turn of thread.turns) {
|
|
104
|
+
const speaker = turn.speaker === "you" ? "you" : label;
|
|
105
|
+
lines.push({ key: `${turn.id}:who`, kind: "speaker", text: speaker });
|
|
106
|
+
const body = turn.body.trim() || (turn.pending ? "" : "");
|
|
107
|
+
if (body) {
|
|
108
|
+
wrapLines(body, bodyWidth).forEach((text, index) => {
|
|
109
|
+
lines.push({ key: `${turn.id}:body:${index}`, kind: "body", text });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (turn.speaker !== "you") {
|
|
113
|
+
const effects = chatInstructionEffects(turn.body);
|
|
114
|
+
if (effects)
|
|
115
|
+
lines.push({ key: `${turn.id}:effects`, kind: "status", text: effects });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (thread.pending) {
|
|
119
|
+
const live = pendingActivity([], { started_at: thread.pending.startedAt, status: thread.pending.status }, label, now);
|
|
120
|
+
const status = thread.pending.status && !["queued", "running", ""].includes(thread.pending.status)
|
|
121
|
+
? ""
|
|
122
|
+
: live.status;
|
|
123
|
+
if (status)
|
|
124
|
+
lines.push({ key: "pending:status", kind: "status", text: status });
|
|
125
|
+
for (const [index, title] of thread.pending.activity.entries()) {
|
|
126
|
+
wrapLines(title, bodyWidth).forEach((text, line) => {
|
|
127
|
+
lines.push({ key: `pending:activity:${index}:${line}`, kind: "activity", text });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return lines;
|
|
132
|
+
}
|
|
133
|
+
export function mergeHydratedThread(local, remote) {
|
|
134
|
+
if (!local)
|
|
135
|
+
return remote;
|
|
136
|
+
const known = new Set(remote.turns.map((turn) => turn.id));
|
|
137
|
+
const extras = local.turns.filter((turn) => !known.has(turn.id));
|
|
138
|
+
return {
|
|
139
|
+
...remote,
|
|
140
|
+
turns: [...remote.turns, ...extras],
|
|
141
|
+
pending: local.pending,
|
|
142
|
+
};
|
|
143
|
+
}
|
package/dist/tui/data.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getWorkspace, listAgents,
|
|
1
|
+
import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getRoadmap, getRun, getWorkspace, listAgents, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage as sendMessage, mergeTicket as mergeTicketNow, queueTicket as queueTicketNow, setPaused as setPausedNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
|
|
2
2
|
import { loadConfig, switchWorkspace as selectWorkspace, } from "../config.js";
|
|
3
3
|
import { readEpicSpec } from "../epics.js";
|
|
4
4
|
import { EARLIER_PAGE } from "./inbox.js";
|
|
@@ -31,12 +31,12 @@ function withTicketKeys(messages, tickets) {
|
|
|
31
31
|
}
|
|
32
32
|
export async function loadSnapshot(config = loadConfig(), options = {}) {
|
|
33
33
|
const earlierLimit = options.earlierLimit ?? EARLIER_PAGE;
|
|
34
|
-
const [status, workspaceData, ticketData, agentData,
|
|
34
|
+
const [status, workspaceData, ticketData, agentData, roadmapData, feedData, unreadData, earlierData] = await Promise.all([
|
|
35
35
|
getStatus(config),
|
|
36
36
|
getWorkspace(config),
|
|
37
37
|
listTickets(config),
|
|
38
38
|
listAgents(config),
|
|
39
|
-
|
|
39
|
+
getRoadmap(config),
|
|
40
40
|
listFeed(config),
|
|
41
41
|
listMessages({ toRoles: ["human", "all"], undelivered: true, limit: 500 }, config),
|
|
42
42
|
listMessages({
|
|
@@ -73,7 +73,8 @@ export async function loadSnapshot(config = loadConfig(), options = {}) {
|
|
|
73
73
|
board: {
|
|
74
74
|
tickets,
|
|
75
75
|
agents: agentData.agents,
|
|
76
|
-
epics:
|
|
76
|
+
epics: roadmapData.epics,
|
|
77
|
+
roadmap: roadmapData.roadmap,
|
|
77
78
|
decisions: status.decisions,
|
|
78
79
|
messages: withTicketKeys(unreadData.messages, tickets),
|
|
79
80
|
earlier: earlier.slice(0, earlierLimit),
|
|
@@ -123,7 +124,37 @@ export async function switchWorkspace(slug, config = loadConfig()) {
|
|
|
123
124
|
return snapshot;
|
|
124
125
|
}
|
|
125
126
|
export async function postAgentMessage(role, body, config) {
|
|
126
|
-
await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
|
|
127
|
+
const { message } = await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
|
|
128
|
+
return message;
|
|
129
|
+
}
|
|
130
|
+
export async function loadChatMessages(config, role) {
|
|
131
|
+
const { messages } = await listMessages({
|
|
132
|
+
unticketed: true,
|
|
133
|
+
fromRoles: ["human", "operator", role],
|
|
134
|
+
limit: 200,
|
|
135
|
+
order: "desc",
|
|
136
|
+
}, config);
|
|
137
|
+
return messages.slice().reverse();
|
|
138
|
+
}
|
|
139
|
+
export async function followChat(config, role, messageId, since) {
|
|
140
|
+
const kind = role === "architect" ? "architect" : "orchestrate";
|
|
141
|
+
const status = await getStatus(config);
|
|
142
|
+
const runs = [...(status.live_runs ?? []), ...(status.recent_runs ?? [])];
|
|
143
|
+
const tagged = runs.find((run) => run.message_id === messageId);
|
|
144
|
+
const live = runs.find((run) => run.kind === kind && ["queued", "running"].includes(run.status));
|
|
145
|
+
const match = tagged ?? live ?? null;
|
|
146
|
+
let run = null;
|
|
147
|
+
let events = [];
|
|
148
|
+
if (match) {
|
|
149
|
+
const detail = await getRun(match.id, config);
|
|
150
|
+
run = detail.run;
|
|
151
|
+
events = detail.events;
|
|
152
|
+
}
|
|
153
|
+
const { messages } = await listMessages({ since, limit: 50 }, config);
|
|
154
|
+
const reply = messages.find((message) => message.from_role === role
|
|
155
|
+
&& ["human", "operator", "all"].includes(message.to_role)
|
|
156
|
+
&& (!run || !message.run_id || message.run_id === run.id));
|
|
157
|
+
return { run, events, reply: reply?.body_md ?? null };
|
|
127
158
|
}
|
|
128
159
|
export async function loadTicketDetail(config, key) {
|
|
129
160
|
const { ticket, pr, events, runs, messages, decisions } = await showTicket(key, config);
|
|
@@ -157,6 +188,9 @@ export async function queueTicket(config, key) {
|
|
|
157
188
|
export async function cancelTicket(config, key) {
|
|
158
189
|
return cancelTicketNow(key, config);
|
|
159
190
|
}
|
|
191
|
+
export async function mergeTicket(config, key, reason) {
|
|
192
|
+
return mergeTicketNow(key, reason, config);
|
|
193
|
+
}
|
|
160
194
|
export async function setWorkspacePaused(config, paused) {
|
|
161
195
|
return setPausedNow(paused, config);
|
|
162
196
|
}
|
package/dist/tui/parse.js
CHANGED
|
@@ -18,6 +18,7 @@ export function parseLine(raw) {
|
|
|
18
18
|
return rest.length ? { kind: "unknown", command: `${word.toLowerCase()} takes no arguments` }
|
|
19
19
|
: { kind: "mode", mode: "architect" };
|
|
20
20
|
case "board":
|
|
21
|
+
case "roadmap":
|
|
21
22
|
case "feed":
|
|
22
23
|
case "settings":
|
|
23
24
|
return { kind: "view", view: word.toLowerCase() };
|
|
@@ -79,6 +80,9 @@ export function parseLine(raw) {
|
|
|
79
80
|
case "cancel":
|
|
80
81
|
return argument ? { kind: "cancel", key: argument.toUpperCase() }
|
|
81
82
|
: { kind: "unknown", command: "cancel needs a key" };
|
|
83
|
+
case "merge":
|
|
84
|
+
return argument ? { kind: "merge", key: argument.toUpperCase() }
|
|
85
|
+
: { kind: "unknown", command: "merge needs a key" };
|
|
82
86
|
case "decide": {
|
|
83
87
|
const dismiss = /(^|\s)--(skip|dismiss)(\s|$)/.test(argument);
|
|
84
88
|
const words = argument.replace(/(^|\s)--(skip|dismiss)(\s|$)/g, " ").trim().split(/\s+/).filter(Boolean);
|
package/dist/tui/ticket-view.js
CHANGED
|
@@ -130,7 +130,7 @@ export function ticketViewLines(ticket, width, collapsed = []) {
|
|
|
130
130
|
pushWrapped(lines, "timeline", " ", event.headline, width);
|
|
131
131
|
lines.push({
|
|
132
132
|
key: `${event.id}:meta`, kind: "line", section: "timeline",
|
|
133
|
-
text: ` ${event.outcome} · ${event.kind}/${event.provider} · ${elapsedLabel(event.elapsed_ms)}`,
|
|
133
|
+
text: ` ${event.outcome} · ${event.kind}/${event.provider ?? "owner"} · ${elapsedLabel(event.elapsed_ms)}`,
|
|
134
134
|
});
|
|
135
135
|
if (event.send_back_reason) {
|
|
136
136
|
pushWrapped(lines, "timeline", " sent back because: ", event.send_back_reason, width);
|