@a-t-h-i/bot-lobby 0.4.0 → 0.5.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/README.md +140 -7
- package/package.json +1 -1
- package/prompts/master.md +12 -0
- package/prompts/panel.md +39 -0
- package/prompts/planner.md +64 -0
- package/prompts/quickfix.md +41 -0
- package/src/execution/agent-runner.ts +7 -2
- package/src/execution/pi-runner.ts +25 -0
- package/src/index.ts +3 -0
- package/src/lobby/feed.ts +253 -0
- package/src/lobby/issues.ts +227 -0
- package/src/lobby/layout.ts +174 -0
- package/src/lobby/planner.ts +474 -0
- package/src/lobby/quickfix.ts +227 -0
- package/src/lobby/runtime.ts +440 -0
- package/src/lobby/tabs/home.ts +164 -0
- package/src/lobby/tabs/issues.ts +72 -0
- package/src/lobby/tabs/metrics.ts +162 -0
- package/src/lobby/tabs/plan.ts +160 -0
- package/src/lobby/tabs/quickfix.ts +101 -0
- package/src/lobby/tabs/tasks.ts +209 -0
- package/src/lobby/view.ts +855 -0
- package/src/pi/activity.ts +120 -0
- package/src/pi/commands.ts +19 -57
- package/src/pi/events.ts +5 -2
- package/src/pi/model-support.ts +34 -0
- package/src/pi/run-summary.ts +2 -0
- package/src/pi/settings-ui.ts +4 -0
- package/src/pi/start-task.ts +63 -0
- package/src/pi/tools.ts +2 -2
- package/src/pi/ui.ts +116 -55
- package/src/schemas/configuration.ts +68 -3
- package/src/schemas/findings.ts +6 -0
- package/src/schemas/task.ts +1 -0
- package/src/state/backlog.ts +106 -0
- package/src/state/comments.ts +136 -0
- package/src/state/metrics.ts +305 -0
- package/src/workflow/workflow.ts +39 -7
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the lobby narrates: an activity log of plain-words tool steps from the
|
|
3
|
+
* Master and every subagent, a single place for thoughts, and the conversation
|
|
4
|
+
* with the Master stripped of tool rows and thinking. Bounded ring buffers in
|
|
5
|
+
* one module-level store (like the zen widget state), so every event source can
|
|
6
|
+
* write without a reference to the lobby, and the lobby repaints on `version`.
|
|
7
|
+
*/
|
|
8
|
+
import type { AgentRun } from "../schemas/findings.ts";
|
|
9
|
+
import { agentName } from "../pi/run-summary.ts";
|
|
10
|
+
|
|
11
|
+
export type ActivityKind = "info" | "success" | "warning" | "error";
|
|
12
|
+
|
|
13
|
+
export interface ActivityEntry {
|
|
14
|
+
id: number;
|
|
15
|
+
at: number;
|
|
16
|
+
/** Who acted: MASTER, DEV, DESIGN, QA, RESEARCH, QUICK FIX, PLANNER, LOBBY. */
|
|
17
|
+
source: string;
|
|
18
|
+
text: string;
|
|
19
|
+
kind: ActivityKind;
|
|
20
|
+
/** Still in flight; rendered with an ellipsis until it settles. */
|
|
21
|
+
pending: boolean;
|
|
22
|
+
/** Correlates a start with its end (a tool call id or a run id). */
|
|
23
|
+
key?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ThoughtEntry {
|
|
27
|
+
id: number;
|
|
28
|
+
at: number;
|
|
29
|
+
source: string;
|
|
30
|
+
text: string;
|
|
31
|
+
/** The Master's thought still streaming. */
|
|
32
|
+
live: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ChatRole = "you" | "oracle" | "note";
|
|
36
|
+
|
|
37
|
+
export interface ChatEntry {
|
|
38
|
+
id: number;
|
|
39
|
+
at: number;
|
|
40
|
+
role: ChatRole;
|
|
41
|
+
text: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const MAX_ACTIVITY = 400;
|
|
45
|
+
export const MAX_THOUGHTS = 40;
|
|
46
|
+
export const MAX_CHAT = 200;
|
|
47
|
+
/** Longest thought kept; a streaming thought keeps its newest text. */
|
|
48
|
+
export const MAX_THOUGHT_TEXT = 4000;
|
|
49
|
+
|
|
50
|
+
function bounded<T>(list: T[], max: number): T[] {
|
|
51
|
+
return list.length > max ? list.slice(-max) : list;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class LobbyFeed {
|
|
55
|
+
activity: ActivityEntry[] = [];
|
|
56
|
+
thoughts: ThoughtEntry[] = [];
|
|
57
|
+
chat: ChatEntry[] = [];
|
|
58
|
+
/** The oracle's reply while it streams; cleared when the message ends. */
|
|
59
|
+
reply = "";
|
|
60
|
+
version = 0;
|
|
61
|
+
private nextId = 1;
|
|
62
|
+
private readonly listeners = new Set<() => void>();
|
|
63
|
+
/** Last step reported per run, so repeated run updates log each step once. */
|
|
64
|
+
private readonly runSteps = new Map<string, string>();
|
|
65
|
+
private readonly runThoughts = new Map<string, string>();
|
|
66
|
+
private readonly runStatus = new Map<string, AgentRun["status"]>();
|
|
67
|
+
|
|
68
|
+
onChange(listener: () => void): () => void {
|
|
69
|
+
this.listeners.add(listener);
|
|
70
|
+
return () => this.listeners.delete(listener);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private touch(): void {
|
|
74
|
+
this.version += 1;
|
|
75
|
+
for (const listener of this.listeners) listener();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Settle every pending entry of `source` (optionally only the one keyed `key`). */
|
|
79
|
+
private settle(source: string | undefined, key: string | undefined, kind?: ActivityKind): boolean {
|
|
80
|
+
let changed = false;
|
|
81
|
+
for (const entry of this.activity) {
|
|
82
|
+
if (!entry.pending) continue;
|
|
83
|
+
if (key !== undefined ? entry.key !== key : entry.source !== source) continue;
|
|
84
|
+
entry.pending = false;
|
|
85
|
+
if (kind) entry.kind = kind;
|
|
86
|
+
changed = true;
|
|
87
|
+
}
|
|
88
|
+
return changed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A one-off entry that is already done (a receipt, a transition, a note). */
|
|
92
|
+
log(source: string, text: string, kind: ActivityKind = "info", at = Date.now()): void {
|
|
93
|
+
this.activity = bounded([...this.activity, { id: this.nextId++, at, source, text, kind, pending: false }], MAX_ACTIVITY);
|
|
94
|
+
this.touch();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Open an in-flight step; `key` lets `end` settle exactly this one. */
|
|
98
|
+
begin(source: string, text: string, key?: string, at = Date.now()): void {
|
|
99
|
+
this.activity = bounded([...this.activity, { id: this.nextId++, at, source, text, kind: "info", pending: true, ...(key ? { key } : {}) }], MAX_ACTIVITY);
|
|
100
|
+
this.touch();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Settle the step opened with `key`; an error marks it. */
|
|
104
|
+
end(key: string, isError = false): void {
|
|
105
|
+
if (this.settle(undefined, key, isError ? "error" : undefined)) this.touch();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The next step of an agent that reports one step at a time: the previous one settles. */
|
|
109
|
+
step(source: string, text: string, key: string, at = Date.now()): void {
|
|
110
|
+
this.settle(undefined, key);
|
|
111
|
+
this.begin(source, text, key, at);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stream the Master's (or any source's) live thought. */
|
|
115
|
+
thinkDelta(source: string, delta: string, at = Date.now()): void {
|
|
116
|
+
const last = this.thoughts.at(-1);
|
|
117
|
+
if (last && last.live && last.source === source) {
|
|
118
|
+
const text = last.text + delta;
|
|
119
|
+
last.text = text.length > MAX_THOUGHT_TEXT ? text.slice(-MAX_THOUGHT_TEXT) : text;
|
|
120
|
+
} else {
|
|
121
|
+
this.thoughts = bounded([...this.thoughts, { id: this.nextId++, at, source, text: delta.slice(-MAX_THOUGHT_TEXT), live: true }], MAX_THOUGHTS);
|
|
122
|
+
}
|
|
123
|
+
this.touch();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Close the live thought of `source`, replacing it with the final text when given. */
|
|
127
|
+
thinkEnd(source: string, text?: string): void {
|
|
128
|
+
const last = [...this.thoughts].reverse().find((entry) => entry.source === source && entry.live);
|
|
129
|
+
if (!last) {
|
|
130
|
+
if (text?.trim()) this.thought(source, text);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
last.live = false;
|
|
134
|
+
if (text?.trim()) last.text = text.trim().slice(-MAX_THOUGHT_TEXT);
|
|
135
|
+
if (!last.text.trim()) this.thoughts = this.thoughts.filter((entry) => entry !== last);
|
|
136
|
+
this.touch();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A finished thought from an agent that reports whole thoughts (subagents). */
|
|
140
|
+
thought(source: string, text: string, at = Date.now()): void {
|
|
141
|
+
const body = text.trim();
|
|
142
|
+
if (!body) return;
|
|
143
|
+
this.thoughts = bounded([...this.thoughts, { id: this.nextId++, at, source, text: body.slice(-MAX_THOUGHT_TEXT), live: false }], MAX_THOUGHTS);
|
|
144
|
+
this.touch();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Stream the oracle's reply text as it arrives. */
|
|
148
|
+
replyDelta(delta: string): void {
|
|
149
|
+
this.reply = (this.reply + delta).slice(-MAX_THOUGHT_TEXT);
|
|
150
|
+
this.touch();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The streamed reply is over; `say` records the final message. */
|
|
154
|
+
replyEnd(): void {
|
|
155
|
+
if (!this.reply) return;
|
|
156
|
+
this.reply = "";
|
|
157
|
+
this.touch();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
say(role: ChatRole, text: string, at = Date.now()): void {
|
|
161
|
+
const body = text.trim();
|
|
162
|
+
if (!body) return;
|
|
163
|
+
this.chat = bounded([...this.chat, { id: this.nextId++, at, role, text: body }], MAX_CHAT);
|
|
164
|
+
this.touch();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Replace the conversation (seeding from the session when the lobby first opens). */
|
|
168
|
+
seedChat(entries: ReadonlyArray<{ role: ChatRole; text: string; at?: number }>): void {
|
|
169
|
+
this.chat = bounded(entries.filter((entry) => entry.text.trim()).map((entry) => ({ id: this.nextId++, at: entry.at ?? 0, role: entry.role, text: entry.text.trim() })), MAX_CHAT);
|
|
170
|
+
this.touch();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fold streamed subagent run updates into the log: each new plain-words step
|
|
175
|
+
* opens an entry (settling the previous one), each new thought lands in the
|
|
176
|
+
* thinking pane, and a finished run settles with its outcome.
|
|
177
|
+
*/
|
|
178
|
+
runs(runs: readonly AgentRun[], at = Date.now()): void {
|
|
179
|
+
for (const run of runs) {
|
|
180
|
+
const source = agentName(run);
|
|
181
|
+
const key = run.runId;
|
|
182
|
+
const before = this.runStatus.get(key);
|
|
183
|
+
this.runStatus.set(key, run.status);
|
|
184
|
+
if (before === undefined) this.log(source, `started as ${run.role}`, "info", at);
|
|
185
|
+
if (run.step && this.runSteps.get(key) !== run.step) {
|
|
186
|
+
this.runSteps.set(key, run.step);
|
|
187
|
+
this.step(source, run.step, key, at);
|
|
188
|
+
}
|
|
189
|
+
if (run.thought && this.runThoughts.get(key) !== run.thought) {
|
|
190
|
+
this.runThoughts.set(key, run.thought);
|
|
191
|
+
this.thought(source, run.thought, at);
|
|
192
|
+
}
|
|
193
|
+
if (run.status !== "running" && before !== run.status) {
|
|
194
|
+
this.settle(undefined, key);
|
|
195
|
+
const kind: ActivityKind = run.status === "success" ? (run.wrappedUp ? "warning" : "success") : run.status === "cancelled" ? "warning" : "error";
|
|
196
|
+
this.log(source, `${run.role} ${run.status === "success" ? "finished" : run.status}${run.error ? ` — ${run.error.split("\n")[0]}` : ""}`, kind, at);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
clear(): void {
|
|
202
|
+
this.activity = [];
|
|
203
|
+
this.thoughts = [];
|
|
204
|
+
this.chat = [];
|
|
205
|
+
this.reply = "";
|
|
206
|
+
this.runSteps.clear();
|
|
207
|
+
this.runThoughts.clear();
|
|
208
|
+
this.runStatus.clear();
|
|
209
|
+
this.touch();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The process-wide lobby feed; the Master session is the only writer that matters. */
|
|
214
|
+
export const lobbyFeed = new LobbyFeed();
|
|
215
|
+
|
|
216
|
+
/** The text parts of a message's content, joined; thinking and tool calls are dropped. */
|
|
217
|
+
export function textOf(content: unknown): string {
|
|
218
|
+
if (typeof content === "string") return content;
|
|
219
|
+
if (!Array.isArray(content)) return "";
|
|
220
|
+
return content
|
|
221
|
+
.filter((part): part is { type: string; text: string } => typeof part === "object" && part !== null && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string")
|
|
222
|
+
.map((part) => part.text)
|
|
223
|
+
.join("")
|
|
224
|
+
.trim();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The long kickoff message bot-lobby sends reads as a short note in the conversation. */
|
|
228
|
+
export function chatText(role: "user" | "assistant", text: string): { role: ChatRole; text: string } | undefined {
|
|
229
|
+
const body = text.trim();
|
|
230
|
+
if (!body) return undefined;
|
|
231
|
+
if (role === "assistant") return { role: "oracle", text: body };
|
|
232
|
+
const kickoff = /^A bot-lobby task is active: (\S+)\nTitle: (.*)/.exec(body);
|
|
233
|
+
if (kickoff) return { role: "note", text: `task ${kickoff[1]} started — ${kickoff[2]}` };
|
|
234
|
+
const comment = /^The user left (?:a comment|\d+ comments) on (the approved plan|the proposal) of (\S+) from the lobby:/.exec(body);
|
|
235
|
+
if (comment) return { role: "note", text: `plan comment sent to the oracle for ${comment[2]}` };
|
|
236
|
+
return { role: "you", text: body };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Session entries (`{ type: "message", message }`) as conversation entries, oldest first. */
|
|
240
|
+
export function chatFromEntries(entries: readonly unknown[], max = MAX_CHAT): Array<{ role: ChatRole; text: string; at?: number }> {
|
|
241
|
+
const chat: Array<{ role: ChatRole; text: string; at?: number }> = [];
|
|
242
|
+
for (const entry of entries) {
|
|
243
|
+
const record = entry as { type?: string; timestamp?: string; message?: { role?: string; content?: unknown } };
|
|
244
|
+
if (record?.type !== "message") continue;
|
|
245
|
+
const role = record.message?.role;
|
|
246
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
247
|
+
const line = chatText(role, textOf(record.message?.content));
|
|
248
|
+
if (!line) continue;
|
|
249
|
+
const at = record.timestamp ? Date.parse(record.timestamp) : undefined;
|
|
250
|
+
chat.push({ ...line, ...(at !== undefined && Number.isFinite(at) ? { at } : {}) });
|
|
251
|
+
}
|
|
252
|
+
return chat.slice(-max);
|
|
253
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub issues through the `gh` CLI: list the repository's open issues, read
|
|
3
|
+
* one with its comments, and file a new one. `gh` owns authentication and repo
|
|
4
|
+
* detection, so bot-lobby holds no token; every failure becomes one readable
|
|
5
|
+
* line (not installed, not signed in, not a GitHub repository).
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
export interface ExecResult {
|
|
10
|
+
stdout: string;
|
|
11
|
+
stderr: string;
|
|
12
|
+
code: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Runs a command without a shell; `execCommand` in the extension, a fake in tests. */
|
|
16
|
+
export type Exec = (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<ExecResult>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `execFile` as an `Exec`: a non-zero exit resolves with its code and output,
|
|
20
|
+
* while a missing binary rejects with the spawn error (`ENOENT`), so the tab
|
|
21
|
+
* can say "gh is not installed" rather than "exited with code 1".
|
|
22
|
+
*/
|
|
23
|
+
export const execCommand: Exec = (command, args, options) =>
|
|
24
|
+
new Promise((resolve, reject) => {
|
|
25
|
+
execFile(command, args, { cwd: options?.cwd, timeout: options?.timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
|
|
26
|
+
if (error && typeof (error as NodeJS.ErrnoException).code === "string") return reject(error);
|
|
27
|
+
const code = error ? (typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : 1) : 0;
|
|
28
|
+
resolve({ stdout: String(stdout), stderr: String(stderr), code });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export interface IssueSummary {
|
|
33
|
+
number: number;
|
|
34
|
+
title: string;
|
|
35
|
+
labels: string[];
|
|
36
|
+
author?: string;
|
|
37
|
+
updatedAt?: string;
|
|
38
|
+
url?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface IssueComment {
|
|
42
|
+
author?: string;
|
|
43
|
+
body: string;
|
|
44
|
+
createdAt?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface IssueDetail extends IssueSummary {
|
|
48
|
+
body: string;
|
|
49
|
+
state?: string;
|
|
50
|
+
comments: IssueComment[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const TIMEOUT_MS = 20_000;
|
|
54
|
+
export const LIST_LIMIT = 50;
|
|
55
|
+
|
|
56
|
+
/** One readable line for a failed `gh` call. */
|
|
57
|
+
export function ghError(result: Partial<ExecResult> & { error?: string }): string {
|
|
58
|
+
const text = `${result.error ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
59
|
+
if (result.code === 127 || /ENOENT|not found|command not found/i.test(text)) return "GitHub CLI (gh) is not installed — install it from https://cli.github.com and run `gh auth login`.";
|
|
60
|
+
if (/auth login|not logged|authentication|GH_TOKEN/i.test(text)) return "gh is not signed in — run `gh auth login` in a terminal.";
|
|
61
|
+
if (/not a git repository|no git remotes|could not determine|none of the git remotes/i.test(text)) return "This project has no GitHub remote gh can use.";
|
|
62
|
+
const first = text.split("\n").find((line) => line.trim()) ?? `gh exited with code ${result.code ?? "?"}`;
|
|
63
|
+
return first.length > 160 ? `${first.slice(0, 159)}…` : first;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function gh(exec: Exec, cwd: string, args: string[]): Promise<string> {
|
|
67
|
+
let result: ExecResult;
|
|
68
|
+
try {
|
|
69
|
+
result = await exec("gh", args, { cwd, timeout: TIMEOUT_MS });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw new Error(ghError({ error: (error as Error).message }));
|
|
72
|
+
}
|
|
73
|
+
if (result.code !== 0) throw new Error(ghError(result));
|
|
74
|
+
return result.stdout;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface RawIssue {
|
|
78
|
+
number?: unknown;
|
|
79
|
+
title?: unknown;
|
|
80
|
+
body?: unknown;
|
|
81
|
+
state?: unknown;
|
|
82
|
+
url?: unknown;
|
|
83
|
+
updatedAt?: unknown;
|
|
84
|
+
author?: { login?: unknown } | null;
|
|
85
|
+
labels?: Array<{ name?: unknown }> | null;
|
|
86
|
+
comments?: Array<{ author?: { login?: unknown } | null; body?: unknown; createdAt?: unknown }> | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function str(value: unknown): string | undefined {
|
|
90
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function summary(raw: RawIssue): IssueSummary | undefined {
|
|
94
|
+
if (typeof raw.number !== "number" || typeof raw.title !== "string") return undefined;
|
|
95
|
+
const author = str(raw.author?.login);
|
|
96
|
+
const updatedAt = str(raw.updatedAt);
|
|
97
|
+
const url = str(raw.url);
|
|
98
|
+
return {
|
|
99
|
+
number: raw.number,
|
|
100
|
+
title: raw.title,
|
|
101
|
+
labels: (raw.labels ?? []).map((label) => str(label?.name)).filter((name): name is string => Boolean(name)),
|
|
102
|
+
...(author ? { author } : {}),
|
|
103
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
104
|
+
...(url ? { url } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseJson<T>(stdout: string): T {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(stdout) as T;
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error("gh returned output bot-lobby could not read");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function listIssues(exec: Exec, cwd: string, limit = LIST_LIMIT): Promise<IssueSummary[]> {
|
|
117
|
+
const stdout = await gh(exec, cwd, ["issue", "list", "--state", "open", "--limit", String(limit), "--json", "number,title,labels,author,updatedAt,url"]);
|
|
118
|
+
const raw = parseJson<RawIssue[]>(stdout);
|
|
119
|
+
return (Array.isArray(raw) ? raw : []).map(summary).filter((issue): issue is IssueSummary => Boolean(issue));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function viewIssue(exec: Exec, cwd: string, number: number): Promise<IssueDetail> {
|
|
123
|
+
const stdout = await gh(exec, cwd, ["issue", "view", String(number), "--json", "number,title,body,state,labels,author,url,updatedAt,comments"]);
|
|
124
|
+
const raw = parseJson<RawIssue>(stdout);
|
|
125
|
+
const base = summary(raw);
|
|
126
|
+
if (!base) throw new Error(`gh returned no issue #${number}`);
|
|
127
|
+
const state = str(raw.state);
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
body: typeof raw.body === "string" ? raw.body : "",
|
|
131
|
+
...(state ? { state } : {}),
|
|
132
|
+
comments: (raw.comments ?? []).map((comment) => {
|
|
133
|
+
const author = str(comment?.author?.login);
|
|
134
|
+
const createdAt = str(comment?.createdAt);
|
|
135
|
+
return { body: typeof comment?.body === "string" ? comment.body : "", ...(author ? { author } : {}), ...(createdAt ? { createdAt } : {}) };
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** First non-empty line is the title, the rest is the body. */
|
|
141
|
+
export function splitIssueText(text: string): { title: string; body: string } {
|
|
142
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
143
|
+
const first = lines.findIndex((line) => line.trim());
|
|
144
|
+
if (first < 0) return { title: "", body: "" };
|
|
145
|
+
return { title: lines[first]!.trim().replace(/^#+\s*/, ""), body: lines.slice(first + 1).join("\n").trim() };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** File an issue; returns its number and URL as `gh` prints it. */
|
|
149
|
+
export async function createIssue(exec: Exec, cwd: string, title: string, body: string): Promise<{ number?: number; url: string }> {
|
|
150
|
+
if (!title.trim()) throw new Error("an issue needs a title (the first line)");
|
|
151
|
+
const stdout = await gh(exec, cwd, ["issue", "create", "--title", title.trim(), "--body", body.trim() || " "]);
|
|
152
|
+
const url = stdout.trim().split("\n").reverse().find((line) => /^https?:\/\//.test(line.trim()))?.trim() ?? stdout.trim();
|
|
153
|
+
const number = Number(/\/issues\/(\d+)/.exec(url)?.[1]);
|
|
154
|
+
return { url, ...(Number.isFinite(number) ? { number } : {}) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The issue as text for the planner: body plus comments. */
|
|
158
|
+
export function issueText(issue: IssueDetail): string {
|
|
159
|
+
const comments = issue.comments
|
|
160
|
+
.filter((comment) => comment.body.trim())
|
|
161
|
+
.map((comment) => `**${comment.author ?? "someone"}** commented:\n${comment.body.trim()}`);
|
|
162
|
+
return [issue.body.trim() || "(no description)", ...comments].join("\n\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Lobby state for the Issues tab: the list, the open issue, and what is loading. */
|
|
166
|
+
export class IssuesState {
|
|
167
|
+
issues: IssueSummary[] = [];
|
|
168
|
+
details = new Map<number, IssueDetail>();
|
|
169
|
+
loading = false;
|
|
170
|
+
loaded = false;
|
|
171
|
+
error?: string;
|
|
172
|
+
notice?: string;
|
|
173
|
+
private readonly exec: Exec;
|
|
174
|
+
private readonly cwd: string;
|
|
175
|
+
private readonly onChange: () => void;
|
|
176
|
+
|
|
177
|
+
constructor(exec: Exec, cwd: string, onChange: () => void = () => {}) {
|
|
178
|
+
this.exec = exec;
|
|
179
|
+
this.cwd = cwd;
|
|
180
|
+
this.onChange = onChange;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async refresh(): Promise<void> {
|
|
184
|
+
if (this.loading) return;
|
|
185
|
+
this.loading = true;
|
|
186
|
+
this.error = undefined;
|
|
187
|
+
this.onChange();
|
|
188
|
+
try {
|
|
189
|
+
this.issues = await listIssues(this.exec, this.cwd);
|
|
190
|
+
this.loaded = true;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.error = (error as Error).message;
|
|
193
|
+
} finally {
|
|
194
|
+
this.loading = false;
|
|
195
|
+
this.onChange();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async detail(number: number): Promise<IssueDetail | undefined> {
|
|
200
|
+
const cached = this.details.get(number);
|
|
201
|
+
if (cached) return cached;
|
|
202
|
+
try {
|
|
203
|
+
const detail = await viewIssue(this.exec, this.cwd, number);
|
|
204
|
+
this.details.set(number, detail);
|
|
205
|
+
this.onChange();
|
|
206
|
+
return detail;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
this.error = (error as Error).message;
|
|
209
|
+
this.onChange();
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async create(text: string): Promise<void> {
|
|
215
|
+
const { title, body } = splitIssueText(text);
|
|
216
|
+
try {
|
|
217
|
+
const created = await createIssue(this.exec, this.cwd, title, body);
|
|
218
|
+
this.notice = `created ${created.number ? `#${created.number}` : "issue"} — ${created.url}`;
|
|
219
|
+
this.error = undefined;
|
|
220
|
+
this.onChange();
|
|
221
|
+
await this.refresh();
|
|
222
|
+
} catch (error) {
|
|
223
|
+
this.error = (error as Error).message;
|
|
224
|
+
this.onChange();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure layout helpers for the lobby: exact-width cells, wrapped paragraphs,
|
|
3
|
+
* section rules, side-by-side columns and scroll windows. Every function takes
|
|
4
|
+
* its widths and heights explicitly and never reads a clock or the terminal,
|
|
5
|
+
* so each tab renders deterministically in tests.
|
|
6
|
+
*/
|
|
7
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
/** Colours the lobby uses; a subset of pi's theme so tests can pass a plain stub. */
|
|
10
|
+
export type LobbyColor =
|
|
11
|
+
| "accent"
|
|
12
|
+
| "text"
|
|
13
|
+
| "muted"
|
|
14
|
+
| "dim"
|
|
15
|
+
| "success"
|
|
16
|
+
| "warning"
|
|
17
|
+
| "error"
|
|
18
|
+
| "border"
|
|
19
|
+
| "borderAccent"
|
|
20
|
+
| "borderMuted"
|
|
21
|
+
| "toolTitle"
|
|
22
|
+
| "mdHeading"
|
|
23
|
+
| "mdCode";
|
|
24
|
+
|
|
25
|
+
export interface LobbyTheme {
|
|
26
|
+
fg(color: LobbyColor, text: string): string;
|
|
27
|
+
bold(text: string): string;
|
|
28
|
+
italic?(text: string): string;
|
|
29
|
+
bg?(color: "selectedBg", text: string): string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Paint only when a theme is present; tests without one get plain text. */
|
|
33
|
+
export function paint(theme: LobbyTheme | undefined, color: LobbyColor, text: string): string {
|
|
34
|
+
return theme && text ? theme.fg(color, text) : text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function bold(theme: LobbyTheme | undefined, text: string): string {
|
|
38
|
+
return theme && text ? theme.bold(text) : text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function italic(theme: LobbyTheme | undefined, text: string): string {
|
|
42
|
+
return theme?.italic && text ? theme.italic(text) : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Exactly `width` columns: truncated with an ellipsis, or padded with spaces. */
|
|
46
|
+
export function fit(text: string, width: number): string {
|
|
47
|
+
if (width <= 0) return "";
|
|
48
|
+
const cut = visibleWidth(text) > width ? truncateToWidth(text, width) : text;
|
|
49
|
+
const pad = width - visibleWidth(cut);
|
|
50
|
+
return pad > 0 ? `${cut}${" ".repeat(pad)}` : cut;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Word-wrap text (ANSI-safe) to `width`, keeping blank lines; tabs become spaces. */
|
|
54
|
+
export function wrap(text: string, width: number): string[] {
|
|
55
|
+
if (width <= 0) return [];
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
for (const raw of text.replace(/\t/g, " ").split("\n")) {
|
|
58
|
+
if (!raw.trim()) {
|
|
59
|
+
lines.push("");
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
lines.push(...wrapTextWithAnsi(raw, width));
|
|
63
|
+
}
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Wrap with a hanging indent: the first line carries `lead`, the rest align under it. */
|
|
68
|
+
export function wrapHanging(lead: string, text: string, width: number): string[] {
|
|
69
|
+
const indent = visibleWidth(lead);
|
|
70
|
+
const body = wrap(text, Math.max(1, width - indent));
|
|
71
|
+
if (body.length === 0) return [lead];
|
|
72
|
+
return body.map((line, index) => `${index === 0 ? lead : " ".repeat(indent)}${line}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `── Title ───────` spanning `width`; `right` sits at the far end when it fits. */
|
|
76
|
+
export function rule(width: number, title = "", theme?: LobbyTheme, right = "", color: LobbyColor = "borderMuted"): string {
|
|
77
|
+
if (width <= 0) return "";
|
|
78
|
+
const head = title ? `── ${title} ` : "";
|
|
79
|
+
const tail = right ? ` ${right} ──` : "";
|
|
80
|
+
const fill = width - visibleWidth(head) - visibleWidth(tail);
|
|
81
|
+
if (fill < 1) return fit(paint(theme, color, head.trimEnd() || "─".repeat(width)), width);
|
|
82
|
+
const titled = title ? `${paint(theme, color, "── ")}${bold(theme, paint(theme, "accent", title))}${paint(theme, color, " ")}` : "";
|
|
83
|
+
const ending = right ? `${paint(theme, color, " ")}${paint(theme, "muted", right)}${paint(theme, color, " ──")}` : "";
|
|
84
|
+
return `${titled}${paint(theme, color, "─".repeat(fill))}${ending}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Exactly `height` lines: extra lines dropped from the end, missing ones blank. */
|
|
88
|
+
export function fill(lines: readonly string[], height: number, width?: number): string[] {
|
|
89
|
+
const out = lines.slice(0, Math.max(0, height));
|
|
90
|
+
while (out.length < height) out.push("");
|
|
91
|
+
return width === undefined ? out : out.map((line) => fit(line, width));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The last `height` lines, shifted up by `offset` lines of scrollback. */
|
|
95
|
+
export function tail(lines: readonly string[], height: number, offset = 0): string[] {
|
|
96
|
+
if (height <= 0) return [];
|
|
97
|
+
const end = Math.max(Math.min(lines.length, height), lines.length - Math.max(0, offset));
|
|
98
|
+
return lines.slice(Math.max(0, end - height), end);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The largest useful scroll-back offset for `lines` in a window of `height`. */
|
|
102
|
+
export function maxOffset(lineCount: number, height: number): number {
|
|
103
|
+
return Math.max(0, lineCount - height);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** First index to show so `selected` stays visible in a window of `height` rows. */
|
|
107
|
+
export function windowStart(selected: number, count: number, height: number): number {
|
|
108
|
+
if (height <= 0 || count <= height) return 0;
|
|
109
|
+
const half = Math.floor(height / 2);
|
|
110
|
+
return Math.max(0, Math.min(count - height, selected - half));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Two columns side by side, each cell fitted to its width, `gap` columns apart. */
|
|
114
|
+
export function columns(left: readonly string[], right: readonly string[], leftWidth: number, rightWidth: number, gap = " │ ", theme?: LobbyTheme): string[] {
|
|
115
|
+
const rows = Math.max(left.length, right.length);
|
|
116
|
+
const separator = paint(theme, "borderMuted", gap);
|
|
117
|
+
const out: string[] = [];
|
|
118
|
+
for (let i = 0; i < rows; i++) out.push(`${fit(left[i] ?? "", leftWidth)}${separator}${fit(right[i] ?? "", rightWidth)}`);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Split `width` into two columns plus a gap; the left gets `share` of the space. */
|
|
123
|
+
export function split(width: number, share: number, gap = 3, minRight = 24): [number, number] {
|
|
124
|
+
const usable = Math.max(0, width - gap);
|
|
125
|
+
const left = Math.max(10, Math.min(usable - minRight, Math.round(usable * share)));
|
|
126
|
+
return [left, Math.max(0, usable - left)];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `12:04` in local time. */
|
|
130
|
+
export function clock(at: number): string {
|
|
131
|
+
const date = new Date(at);
|
|
132
|
+
if (!Number.isFinite(date.getTime())) return "--:--";
|
|
133
|
+
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Compact age: `now`, `45s`, `12m`, `3h`, `2d`. */
|
|
137
|
+
export function ago(ms: number): string {
|
|
138
|
+
if (!Number.isFinite(ms) || ms < 5_000) return "now";
|
|
139
|
+
const seconds = Math.floor(ms / 1000);
|
|
140
|
+
if (seconds < 60) return `${seconds}s`;
|
|
141
|
+
const minutes = Math.floor(seconds / 60);
|
|
142
|
+
if (minutes < 60) return `${minutes}m`;
|
|
143
|
+
const hours = Math.floor(minutes / 60);
|
|
144
|
+
if (hours < 48) return `${hours}h`;
|
|
145
|
+
return `${Math.floor(hours / 24)}d`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `just now` or `12m ago`. */
|
|
149
|
+
export function since(ms: number): string {
|
|
150
|
+
const age = ago(ms);
|
|
151
|
+
return age === "now" ? "just now" : `${age} ago`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Highlight a selected row: the theme's selection background, or a leading marker without one. */
|
|
155
|
+
export function selectRow(theme: LobbyTheme | undefined, text: string, width: number, selected: boolean, focused = true): string {
|
|
156
|
+
const cell = fit(text, width);
|
|
157
|
+
if (!selected) return cell;
|
|
158
|
+
if (theme?.bg && focused) return theme.bg("selectedBg", cell);
|
|
159
|
+
return cell;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
163
|
+
|
|
164
|
+
export function spinner(tick: number): string {
|
|
165
|
+
return SPINNER[((tick % SPINNER.length) + SPINNER.length) % SPINNER.length]!;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Light Markdown for plans and reports: headings bold without their `#`, everything else wrapped as written. */
|
|
169
|
+
export function markdownLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
170
|
+
return text.split("\n").flatMap((line) => {
|
|
171
|
+
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
172
|
+
return wrap(heading ? bold(theme, paint(theme, "mdHeading", heading[1]!)) : line, width);
|
|
173
|
+
});
|
|
174
|
+
}
|