@higherdev/cli 0.18.0 → 0.21.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 +16 -0
- package/dist/host.js +1 -0
- package/dist/index.js +44 -2
- package/dist/out.js +1 -0
- package/dist/roadmap.js +23 -0
- package/dist/tui/App.js +164 -29
- package/dist/tui/Chat.js +28 -0
- package/dist/tui/Dashboard.js +7 -2
- package/dist/tui/Help.js +3 -2
- package/dist/tui/Roadmap.js +38 -0
- package/dist/tui/chat-view.js +125 -0
- package/dist/tui/data.js +36 -5
- package/dist/tui/parse.js +1 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -75,6 +75,9 @@ export async function listTicketStory(key, config = loadConfig()) {
|
|
|
75
75
|
export async function postMessage(fields, config = loadConfig()) {
|
|
76
76
|
return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
|
|
77
77
|
}
|
|
78
|
+
export async function getRun(id, config = loadConfig()) {
|
|
79
|
+
return request(config, "GET", `/api/w/${config.slug}/runs/${encodeURIComponent(id)}`);
|
|
80
|
+
}
|
|
78
81
|
export async function answerDecision(id, answer_md, config = loadConfig()) {
|
|
79
82
|
return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
|
|
80
83
|
}
|
|
@@ -128,6 +131,12 @@ export async function setHostEnv(host, name, value, config = loadConfig()) {
|
|
|
128
131
|
export async function removeHostEnv(host, name, config = loadConfig()) {
|
|
129
132
|
return request(config, "DELETE", `/api/hosts/${encodeURIComponent(host)}/env`, { name });
|
|
130
133
|
}
|
|
134
|
+
export async function getRoadmap(config = loadConfig()) {
|
|
135
|
+
return request(config, "GET", `/api/w/${config.slug}/roadmap`);
|
|
136
|
+
}
|
|
137
|
+
export async function updateRoadmap(vision_md, config = loadConfig()) {
|
|
138
|
+
return request(config, "PATCH", `/api/w/${config.slug}/roadmap`, { vision_md });
|
|
139
|
+
}
|
|
131
140
|
export async function listEpics(config = loadConfig()) {
|
|
132
141
|
return request(config, "GET", `/api/w/${config.slug}/epics`);
|
|
133
142
|
}
|
|
@@ -137,6 +146,9 @@ export async function createEpic(fields, config = loadConfig()) {
|
|
|
137
146
|
export async function approveEpic(id, config = loadConfig()) {
|
|
138
147
|
return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id });
|
|
139
148
|
}
|
|
149
|
+
export async function updateEpic(id, fields, config = loadConfig()) {
|
|
150
|
+
return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id, ...fields });
|
|
151
|
+
}
|
|
140
152
|
export async function deleteEpic(id, config = loadConfig()) {
|
|
141
153
|
return request(config, "DELETE", `/api/w/${config.slug}/epics`, { id });
|
|
142
154
|
}
|
|
@@ -152,6 +164,10 @@ export async function listMessages(options = {}, config = loadConfig()) {
|
|
|
152
164
|
query.set("offset", String(options.offset));
|
|
153
165
|
if (options.toRoles?.length)
|
|
154
166
|
query.set("to_role", options.toRoles.join(","));
|
|
167
|
+
if (options.fromRoles?.length)
|
|
168
|
+
query.set("from_role", options.fromRoles.join(","));
|
|
169
|
+
if (options.unticketed)
|
|
170
|
+
query.set("unticketed", "true");
|
|
155
171
|
if (options.undelivered)
|
|
156
172
|
query.set("undelivered", "true");
|
|
157
173
|
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, 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";
|
|
@@ -178,7 +179,44 @@ async function cmdEpic(argv) {
|
|
|
178
179
|
}
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
181
|
-
|
|
182
|
+
if (action === "set") {
|
|
183
|
+
const parsed = flags(rest);
|
|
184
|
+
const id = parsed.rest[0];
|
|
185
|
+
const choices = ["after", "position", "depends-on"].filter((name) => parsed.opts[name] !== undefined);
|
|
186
|
+
if (!id || parsed.rest.length !== 1 || choices.length !== 1) {
|
|
187
|
+
fail("usage: hd epic set ID --after ID | --position N | --depends-on ID");
|
|
188
|
+
}
|
|
189
|
+
const position = parsed.opts.position ? Number(parsed.opts.position) : undefined;
|
|
190
|
+
if (position !== undefined && (!Number.isInteger(position) || position < 1)) {
|
|
191
|
+
fail("position must be a positive integer.");
|
|
192
|
+
}
|
|
193
|
+
const { epic } = await updateEpic(id, {
|
|
194
|
+
...(parsed.opts.after ? { after: parsed.opts.after } : {}),
|
|
195
|
+
...(position !== undefined ? { position } : {}),
|
|
196
|
+
...(parsed.opts["depends-on"] ? { depends_on: [parsed.opts["depends-on"]] } : {}),
|
|
197
|
+
});
|
|
198
|
+
console.log(`${c.bold(epic.id)} position ${epic.position}`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
fail("usage: hd epic new PATH [--title TITLE] | list | approve ID | rm ID | set ID --position N");
|
|
202
|
+
}
|
|
203
|
+
async function cmdRoadmap(argv) {
|
|
204
|
+
const parsed = flags(argv);
|
|
205
|
+
if (parsed.rest.length || [...parsed.bools].some((name) => name !== "json") || Object.keys(parsed.opts).length) {
|
|
206
|
+
fail("usage: hd roadmap [--json]");
|
|
207
|
+
}
|
|
208
|
+
const data = await getRoadmap();
|
|
209
|
+
if (parsed.bools.has("json"))
|
|
210
|
+
return console.log(JSON.stringify(data));
|
|
211
|
+
if (!data.roadmap)
|
|
212
|
+
return console.log(c.dim("No roadmap."));
|
|
213
|
+
console.log(c.bold("Vision"));
|
|
214
|
+
console.log(data.roadmap.vision_md);
|
|
215
|
+
if (!data.epics.length)
|
|
216
|
+
return console.log(`\n${c.dim("No epics.")}`);
|
|
217
|
+
console.log("");
|
|
218
|
+
for (const line of roadmapText(data.epics))
|
|
219
|
+
console.log(line);
|
|
182
220
|
}
|
|
183
221
|
async function cmdPlan(argv) {
|
|
184
222
|
if (argv.length)
|
|
@@ -554,6 +592,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
554
592
|
await cmdEpic(rest);
|
|
555
593
|
return;
|
|
556
594
|
}
|
|
595
|
+
if (cmd === "roadmap") {
|
|
596
|
+
await cmdRoadmap(rest);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
557
599
|
if (cmd === "plan") {
|
|
558
600
|
await cmdPlan(rest);
|
|
559
601
|
return;
|
package/dist/out.js
CHANGED
|
@@ -66,6 +66,7 @@ export function usage() {
|
|
|
66
66
|
` ${c.blue("hd status")} workspace overview`,
|
|
67
67
|
` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel")} 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, 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);
|
|
@@ -736,9 +813,9 @@ export function App({ initial }) {
|
|
|
736
813
|
default:
|
|
737
814
|
return;
|
|
738
815
|
}
|
|
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]);
|
|
816
|
+
}, [view, settings, applyEdit, board, browsing, mode, say, askAgent, openAgentChat, order, settingsOrder,
|
|
817
|
+
changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
|
|
818
|
+
selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
|
|
742
819
|
useInput((input, key) => {
|
|
743
820
|
if (key.ctrl && input === "c")
|
|
744
821
|
exit();
|
|
@@ -758,14 +835,21 @@ export function App({ initial }) {
|
|
|
758
835
|
const settled = messages.filter((message) => message.done);
|
|
759
836
|
const inFlight = messages.filter((message) => !message.done);
|
|
760
837
|
const splash = !started;
|
|
838
|
+
const chatRole = view === "chat" && mode !== "browse" ? mode : null;
|
|
839
|
+
const activeThread = chatRole
|
|
840
|
+
? threads[threadKey(workspace.id, chatRole)] ?? emptyThread(chatRole, workspace.id)
|
|
841
|
+
: null;
|
|
842
|
+
const chatLabel = chatRole ? agentName(chatRole) : "";
|
|
843
|
+
const chatLines = activeThread ? chatViewLines(activeThread, Math.max(20, width), chatLabel, now) : [];
|
|
761
844
|
const scrollback = splash ? [] : [
|
|
762
845
|
{ key: "banner" },
|
|
763
846
|
{ key: "help", message: { id: "help", speaker: "system", body: "", panel: "help" } },
|
|
764
847
|
...settled.map((message) => ({ key: message.id, message })),
|
|
765
848
|
];
|
|
849
|
+
const chatting = view === "chat";
|
|
766
850
|
const plan = planLayout({
|
|
767
|
-
rows, columns, width, splash, ready, decision: decisionRows(decisions),
|
|
768
|
-
inFlight: inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
|
|
851
|
+
rows, columns, width, splash, ready, decision: chatting ? 0 : decisionRows(decisions),
|
|
852
|
+
inFlight: chatting ? 0 : inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
|
|
769
853
|
notice: Boolean(notice), home: view === "home",
|
|
770
854
|
});
|
|
771
855
|
const agentsView = splitPanels(plan.panels);
|
|
@@ -777,15 +861,15 @@ export function App({ initial }) {
|
|
|
777
861
|
if (item.message.panel === "help")
|
|
778
862
|
return _jsx(Help, { width: width }, item.key);
|
|
779
863
|
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
|
|
864
|
+
} }), 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
865
|
? _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,
|
|
866
|
+
: _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
867
|
setDraft(next);
|
|
784
868
|
if (editingRef.current)
|
|
785
869
|
setEditing({ key: editingRef.current.key, draft: next });
|
|
786
870
|
}, onSubmit: (value) => void run(value), isActive: inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
|
|
787
871
|
? (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: () => {
|
|
872
|
+
: 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
873
|
if (answering) {
|
|
790
874
|
setAnswering(null);
|
|
791
875
|
setDraft("");
|
|
@@ -798,6 +882,14 @@ export function App({ initial }) {
|
|
|
798
882
|
setNotice(null);
|
|
799
883
|
return;
|
|
800
884
|
}
|
|
885
|
+
if (chatting || mode !== "browse") {
|
|
886
|
+
const left = leaveChat();
|
|
887
|
+
setMode(left.mode);
|
|
888
|
+
setView(left.view);
|
|
889
|
+
setDraft("");
|
|
890
|
+
setNotice(left.notice);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
801
893
|
if (view !== "home") {
|
|
802
894
|
setView("home");
|
|
803
895
|
setTicketKey(null);
|
|
@@ -806,6 +898,16 @@ export function App({ initial }) {
|
|
|
806
898
|
setCursor(null);
|
|
807
899
|
selectedRef.current = null;
|
|
808
900
|
}, onUp: () => {
|
|
901
|
+
if (chatting && !draft) {
|
|
902
|
+
const overflow = chatLines.length > plan.panels;
|
|
903
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
904
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - 1));
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (view === "roadmap" && !draft) {
|
|
908
|
+
setRoadmapOffset((current) => Math.max(0, current - 1));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
809
911
|
if (view === "ticket" && !draft) {
|
|
810
912
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
811
913
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - 1));
|
|
@@ -824,6 +926,17 @@ export function App({ initial }) {
|
|
|
824
926
|
historyAt.current = historyAt.current < 0 ? history.current.length - 1 : Math.max(0, historyAt.current - 1);
|
|
825
927
|
setDraft(history.current[historyAt.current] ?? "");
|
|
826
928
|
}, onDown: () => {
|
|
929
|
+
if (chatting && !draft) {
|
|
930
|
+
const overflow = chatLines.length > plan.panels;
|
|
931
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
932
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + 1));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (view === "roadmap" && !draft) {
|
|
936
|
+
const max = Math.max(0, roadmapLines(board, width).length - Math.max(1, plan.panels - 1));
|
|
937
|
+
setRoadmapOffset((current) => Math.min(max, current + 1));
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
827
940
|
if (view === "ticket" && !draft) {
|
|
828
941
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
829
942
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + 1));
|
|
@@ -847,11 +960,33 @@ export function App({ initial }) {
|
|
|
847
960
|
}
|
|
848
961
|
setDraft(history.current[historyAt.current] ?? "");
|
|
849
962
|
}, onPageUp: () => {
|
|
963
|
+
if (chatting && !draft) {
|
|
964
|
+
const overflow = chatLines.length > plan.panels;
|
|
965
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
966
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - Math.max(1, inner)));
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (view === "roadmap" && !draft) {
|
|
970
|
+
setRoadmapOffset((current) => Math.max(0, current - Math.max(1, plan.panels - 1)));
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
850
973
|
if (view === "ticket" && !draft) {
|
|
851
974
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
852
975
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - Math.max(1, plan.panels)));
|
|
853
976
|
}
|
|
854
977
|
}, onPageDown: () => {
|
|
978
|
+
if (chatting && !draft) {
|
|
979
|
+
const overflow = chatLines.length > plan.panels;
|
|
980
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
981
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + Math.max(1, inner)));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (view === "roadmap" && !draft) {
|
|
985
|
+
const page = Math.max(1, plan.panels - 1);
|
|
986
|
+
const max = Math.max(0, roadmapLines(board, width).length - page);
|
|
987
|
+
setRoadmapOffset((current) => Math.min(max, current + page));
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
855
990
|
if (view === "ticket" && !draft) {
|
|
856
991
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
857
992
|
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,6 +6,7 @@ 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" },
|
|
@@ -14,7 +15,7 @@ export const COMMANDS = [
|
|
|
14
15
|
{ name: "/logs", args: "[raw] [HD-12]", help: "filter activity; raw reveals event JSON" },
|
|
15
16
|
{ name: "/epic", args: "new PATH | approve ID | rm ID", help: "create, approve, or remove a draft epic" },
|
|
16
17
|
{ name: "/epics", help: "list epics and ticket progress" },
|
|
17
|
-
{ name: "/architect", help: "
|
|
18
|
+
{ name: "/architect", help: "open a chat with the architect" },
|
|
18
19
|
{ name: "/plan", help: "alias for /architect" },
|
|
19
20
|
{ name: "/decide", args: "[N|ID] [answer]", help: "answer the selected or numbered decision" },
|
|
20
21
|
{ name: "/agents", args: "[add [ROLE] [flags] | rm ROLE|ID]", help: "view or manage named agents" },
|
|
@@ -24,7 +25,7 @@ export const COMMANDS = [
|
|
|
24
25
|
{ name: "/on", help: "turn on the current workspace" },
|
|
25
26
|
{ name: "/off", help: "turn off the current workspace" },
|
|
26
27
|
{ name: "/feed", help: "what just happened" },
|
|
27
|
-
{ name: "/orchestrator", help: "
|
|
28
|
+
{ name: "/orchestrator", help: "open a chat with the orchestrator" },
|
|
28
29
|
{ name: "/refresh", help: "reload the board now" },
|
|
29
30
|
{ name: "/help", help: "this list" },
|
|
30
31
|
{ 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,125 @@
|
|
|
1
|
+
import { narrateEvents } from "./narrate.js";
|
|
2
|
+
import { wrapLines } from "./ticket-view.js";
|
|
3
|
+
/** Last 50 exchanges (a human turn and an agent turn) when rehydrating. */
|
|
4
|
+
export const CHAT_EXCHANGE_LIMIT = 50;
|
|
5
|
+
export const CHAT_MESSAGE_LIMIT = CHAT_EXCHANGE_LIMIT * 2;
|
|
6
|
+
const HUMAN_ROLES = new Set(["human", "operator"]);
|
|
7
|
+
const HUMAN_TARGETS = new Set(["human", "operator", "all"]);
|
|
8
|
+
export function threadKey(workspaceId, role) {
|
|
9
|
+
return `${workspaceId}:${role}`;
|
|
10
|
+
}
|
|
11
|
+
export function emptyThread(role, workspaceId) {
|
|
12
|
+
return { role, workspaceId, turns: [], pending: null };
|
|
13
|
+
}
|
|
14
|
+
export function chatAgentLabel(role, displayName) {
|
|
15
|
+
const named = displayName?.trim();
|
|
16
|
+
if (named)
|
|
17
|
+
return named;
|
|
18
|
+
return role === "architect" ? "Architect" : "Orchestrator";
|
|
19
|
+
}
|
|
20
|
+
export function formatWorkingElapsed(ms) {
|
|
21
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
22
|
+
if (seconds < 60)
|
|
23
|
+
return `${seconds}s`;
|
|
24
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
25
|
+
}
|
|
26
|
+
export function workingStatus(label, startedAt, now = Date.now()) {
|
|
27
|
+
const start = startedAt ? Date.parse(startedAt) : Number.NaN;
|
|
28
|
+
const elapsed = Number.isFinite(start) ? Math.max(0, now - start) : 0;
|
|
29
|
+
return `${label} is working (${formatWorkingElapsed(elapsed)})`;
|
|
30
|
+
}
|
|
31
|
+
export function isChatConversation(message, role) {
|
|
32
|
+
if (message.ticket_id)
|
|
33
|
+
return false;
|
|
34
|
+
const fromAgent = message.from_role === role && HUMAN_TARGETS.has(message.to_role);
|
|
35
|
+
const toAgent = HUMAN_ROLES.has(message.from_role) && message.to_role === role;
|
|
36
|
+
return fromAgent || toAgent;
|
|
37
|
+
}
|
|
38
|
+
export function rehydrateThread(messages, role, workspaceId) {
|
|
39
|
+
const conversation = messages
|
|
40
|
+
.filter((message) => isChatConversation(message, role))
|
|
41
|
+
.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id))
|
|
42
|
+
.slice(-CHAT_MESSAGE_LIMIT);
|
|
43
|
+
return {
|
|
44
|
+
role,
|
|
45
|
+
workspaceId,
|
|
46
|
+
turns: conversation.map((message) => ({
|
|
47
|
+
id: message.id,
|
|
48
|
+
speaker: message.from_role === role ? role : "you",
|
|
49
|
+
body: message.body_md,
|
|
50
|
+
at: message.created_at,
|
|
51
|
+
})),
|
|
52
|
+
pending: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function restoreThread(threads, workspaceId, role) {
|
|
56
|
+
return threads[threadKey(workspaceId, role)] ?? null;
|
|
57
|
+
}
|
|
58
|
+
export function leaveChat() {
|
|
59
|
+
return { view: "home", mode: "browse", notice: "Back to browse." };
|
|
60
|
+
}
|
|
61
|
+
export function openChat(role) {
|
|
62
|
+
return { view: "chat", mode: role };
|
|
63
|
+
}
|
|
64
|
+
export function chatScrollOffset(count, rows, offset) {
|
|
65
|
+
if (count <= rows)
|
|
66
|
+
return 0;
|
|
67
|
+
return Math.max(0, Math.min(offset, count - rows));
|
|
68
|
+
}
|
|
69
|
+
export function chatWindow(count, rows, offset) {
|
|
70
|
+
const start = chatScrollOffset(count, rows, offset);
|
|
71
|
+
return { start, end: Math.min(count, start + Math.max(0, rows)) };
|
|
72
|
+
}
|
|
73
|
+
export function pendingActivity(events, run, label, now = Date.now()) {
|
|
74
|
+
const live = !run || ["queued", "running", ""].includes(run.status ?? "running");
|
|
75
|
+
return {
|
|
76
|
+
status: live ? workingStatus(label, run?.started_at ?? null, now) : "",
|
|
77
|
+
lines: narrateEvents(events).map((line) => line.title),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function followShouldStop(run, reply) {
|
|
81
|
+
if (reply)
|
|
82
|
+
return true;
|
|
83
|
+
if (run && !["queued", "running"].includes(run.status))
|
|
84
|
+
return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
export function chatViewLines(thread, width, label, now = Date.now()) {
|
|
88
|
+
const lines = [];
|
|
89
|
+
const bodyWidth = Math.max(8, width);
|
|
90
|
+
for (const turn of thread.turns) {
|
|
91
|
+
const speaker = turn.speaker === "you" ? "you" : label;
|
|
92
|
+
lines.push({ key: `${turn.id}:who`, kind: "speaker", text: speaker });
|
|
93
|
+
const body = turn.body.trim() || (turn.pending ? "" : "");
|
|
94
|
+
if (body) {
|
|
95
|
+
wrapLines(body, bodyWidth).forEach((text, index) => {
|
|
96
|
+
lines.push({ key: `${turn.id}:body:${index}`, kind: "body", text });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (thread.pending) {
|
|
101
|
+
const live = pendingActivity([], { started_at: thread.pending.startedAt, status: thread.pending.status }, label, now);
|
|
102
|
+
const status = thread.pending.status && !["queued", "running", ""].includes(thread.pending.status)
|
|
103
|
+
? ""
|
|
104
|
+
: live.status;
|
|
105
|
+
if (status)
|
|
106
|
+
lines.push({ key: "pending:status", kind: "status", text: status });
|
|
107
|
+
for (const [index, title] of thread.pending.activity.entries()) {
|
|
108
|
+
wrapLines(title, bodyWidth).forEach((text, line) => {
|
|
109
|
+
lines.push({ key: `pending:activity:${index}:${line}`, kind: "activity", text });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return lines;
|
|
114
|
+
}
|
|
115
|
+
export function mergeHydratedThread(local, remote) {
|
|
116
|
+
if (!local)
|
|
117
|
+
return remote;
|
|
118
|
+
const known = new Set(remote.turns.map((turn) => turn.id));
|
|
119
|
+
const extras = local.turns.filter((turn) => !known.has(turn.id));
|
|
120
|
+
return {
|
|
121
|
+
...remote,
|
|
122
|
+
turns: [...remote.turns, ...extras],
|
|
123
|
+
pending: local.pending,
|
|
124
|
+
};
|
|
125
|
+
}
|
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, 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);
|
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() };
|