@a-t-h-i/bot-lobby 0.6.13 → 0.6.15
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 +70 -79
- package/package.json +4 -2
- package/prompts/master.md +3 -2
- package/prompts/qa.md +33 -11
- package/prompts/reviewer.md +5 -2
- package/prompts/worker.md +5 -2
- package/src/ask/relay.ts +1 -2
- package/src/ask/tool.ts +4 -6
- package/src/ask/types.ts +16 -0
- package/src/ask/web.ts +63 -0
- package/src/index.ts +7 -1
- package/src/lobby/ask.ts +4 -5
- package/src/lobby/blocks.ts +57 -0
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/host.ts +178 -0
- package/src/lobby/keys.ts +10 -41
- package/src/lobby/prompt-hub.ts +30 -77
- package/src/lobby/prompts.ts +8 -12
- package/src/lobby/runtime.ts +49 -310
- package/src/lobby/service.ts +54 -134
- package/src/lobby/task-rows.ts +28 -0
- package/src/pi/commands.ts +14 -20
- package/src/pi/model-settings.ts +112 -0
- package/src/pi/start-task.ts +1 -1
- package/src/pi/tools.ts +6 -7
- package/src/pi/ui.ts +0 -9
- package/src/schemas/configuration.ts +9 -33
- package/src/state/attachments.ts +82 -0
- package/src/webui/api/index.ts +17 -15
- package/src/webui/api/lobby.ts +4 -3
- package/src/webui/api/planner.ts +6 -4
- package/src/webui/api/quickfix.ts +4 -3
- package/src/webui/api/sessions.ts +9 -7
- package/src/webui/api/settings.ts +20 -8
- package/src/webui/api/status.ts +4 -15
- package/src/webui/api/tasks.ts +11 -15
- package/src/webui/command.ts +35 -19
- package/src/webui/dev/fake-service.ts +1 -2
- package/src/webui/dev/fixtures.ts +2 -5
- package/src/webui/events.ts +1 -1
- package/src/webui/protocol.ts +22 -11
- package/src/webui/server.ts +42 -1
- package/src/webui/uploads.ts +68 -0
- package/webui/dist/assets/index-B1PnCyl2.css +2 -0
- package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
- package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/webui/dist/build.json +142 -110
- package/webui/dist/index.html +4 -3
- package/src/ask/dialog.ts +0 -193
- package/src/ask/image.ts +0 -202
- package/src/ask/png.ts +0 -179
- package/src/ask/state.ts +0 -175
- package/src/ask/view.ts +0 -163
- package/src/lobby/layout.ts +0 -496
- package/src/lobby/markdown.ts +0 -121
- package/src/lobby/mini.ts +0 -167
- package/src/lobby/tabs/excalidraw.ts +0 -95
- package/src/lobby/tabs/git.ts +0 -162
- package/src/lobby/tabs/home.ts +0 -544
- package/src/lobby/tabs/issues.ts +0 -73
- package/src/lobby/tabs/knowledge.ts +0 -135
- package/src/lobby/tabs/metrics.ts +0 -300
- package/src/lobby/tabs/plan.ts +0 -262
- package/src/lobby/tabs/quickfix.ts +0 -136
- package/src/lobby/tabs/tasks.ts +0 -412
- package/src/lobby/theme.ts +0 -30
- package/src/lobby/view.ts +0 -3018
- package/src/pi/settings-ui.ts +0 -708
- package/src/width.ts +0 -102
- package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
- package/webui/dist/assets/index-E6MPcbdC.js +0 -80
package/src/lobby/mini.ts
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The one-line status shown under pi's editor while the lobby is hidden: a
|
|
3
|
-
* small graphic of where the work stands — the task's plan steps (or its stage
|
|
4
|
-
* before there is a plan), the planning rounds, the quick fix in hand — or a
|
|
5
|
-
* quiet "idle". Pure: everything it shows arrives as input.
|
|
6
|
-
*/
|
|
7
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
|
-
import type { AgentRun } from "../schemas/findings.ts";
|
|
9
|
-
import type { Task, TaskState } from "../schemas/task.ts";
|
|
10
|
-
import { planChecklist } from "../pi/plan-checklist.ts";
|
|
11
|
-
import { agentName } from "../pi/run-summary.ts";
|
|
12
|
-
import { paint, type LobbyTheme } from "./layout.ts";
|
|
13
|
-
|
|
14
|
-
export interface MiniPlanning {
|
|
15
|
-
/** A round is running. */
|
|
16
|
-
busy: boolean;
|
|
17
|
-
/** The round in hand, and the limit (0 = unlimited). */
|
|
18
|
-
round: number;
|
|
19
|
-
limit: number;
|
|
20
|
-
/** Questions waiting for the user. */
|
|
21
|
-
questions: number;
|
|
22
|
-
/** The oracle's draft is ready to save. */
|
|
23
|
-
ready: boolean;
|
|
24
|
-
/** The plan was saved as a pending task. */
|
|
25
|
-
saved: boolean;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface MiniQuickFix {
|
|
29
|
-
title: string;
|
|
30
|
-
running: boolean;
|
|
31
|
-
queued: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface MiniInput {
|
|
35
|
-
task?: Task;
|
|
36
|
-
runs: readonly AgentRun[];
|
|
37
|
-
planning?: MiniPlanning;
|
|
38
|
-
quickfix?: MiniQuickFix;
|
|
39
|
-
/** The loopback web UI's link, while its server runs. */
|
|
40
|
-
webLink?: string;
|
|
41
|
-
/** The key that opens the lobby, as shown. */
|
|
42
|
-
key: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** A full-workflow task's stages, in order, for the strip shown before its plan has steps. */
|
|
46
|
-
const STAGES: readonly TaskState[] = ["clarifying", "scouting", "synthesizing", "awaiting_approval", "planning", "implementing", "reviewing"];
|
|
47
|
-
|
|
48
|
-
/** Widest step bar; longer plans share its cells. */
|
|
49
|
-
const MAX_CELLS = 12;
|
|
50
|
-
|
|
51
|
-
/** `■■◐□□`: the plan's steps done, current and to do, squeezed into at most `MAX_CELLS` cells. */
|
|
52
|
-
export function stepBar(statuses: readonly ("done" | "current" | "pending")[]): string {
|
|
53
|
-
if (statuses.length === 0) return "";
|
|
54
|
-
const cells = Math.min(statuses.length, MAX_CELLS);
|
|
55
|
-
const out: string[] = [];
|
|
56
|
-
for (let cell = 0; cell < cells; cell += 1) {
|
|
57
|
-
const from = Math.floor((cell * statuses.length) / cells);
|
|
58
|
-
const to = Math.max(from + 1, Math.floor(((cell + 1) * statuses.length) / cells));
|
|
59
|
-
const slice = statuses.slice(from, to);
|
|
60
|
-
out.push(slice.every((status) => status === "done") ? "■" : slice.some((status) => status === "current") ? "◐" : slice.some((status) => status === "done") ? "◐" : "□");
|
|
61
|
-
}
|
|
62
|
-
return out.join("");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** `●●◐○○○○`: how far along the workflow a task without a plan yet is. */
|
|
66
|
-
export function stageBar(state: TaskState): string {
|
|
67
|
-
const at = STAGES.indexOf(state);
|
|
68
|
-
if (at < 0) return "";
|
|
69
|
-
return STAGES.map((_stage, index) => (index < at ? "●" : index === at ? "◐" : "○")).join("");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** `■■□□□` for `round` of `limit`, or empty without a limit. */
|
|
73
|
-
function roundBar(round: number, limit: number): string {
|
|
74
|
-
if (limit <= 0) return "";
|
|
75
|
-
const done = Math.min(limit, Math.max(0, round));
|
|
76
|
-
return "■".repeat(done) + "□".repeat(limit - done);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const STATE_WORDS: Partial<Record<TaskState, string>> = {
|
|
80
|
-
awaiting_approval: "awaiting your approval",
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
function taskSegment(input: MiniInput, theme?: LobbyTheme): string | undefined {
|
|
84
|
-
const task = input.task;
|
|
85
|
-
if (!task || task.state === "completed" || task.state === "abandoned") return undefined;
|
|
86
|
-
const steps = task.plan ? planChecklist(task.plan, input.runs) : [];
|
|
87
|
-
const done = steps.filter((step) => step.status === "done").length;
|
|
88
|
-
const bar = steps.length > 0 ? `${stepBar(steps.map((step) => step.status))} ${done}/${steps.length}` : stageBar(task.state);
|
|
89
|
-
const state = task.paused ? `${task.state} (paused)` : STATE_WORDS[task.state] ?? task.state;
|
|
90
|
-
const running = [...input.runs].reverse().find((run) => run.status === "running");
|
|
91
|
-
const doing = running ? ` · ${agentName(running)}${running.activity ? ` ${running.activity}` : ""}` : "";
|
|
92
|
-
const alert = task.state === "awaiting_approval" || task.paused;
|
|
93
|
-
return `${paint(theme, "accent", task.id)} ${paint(theme, "success", bar)} ${paint(theme, alert ? "warning" : "muted", state)}${paint(theme, "dim", doing)}`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function planningSegment(planning: MiniPlanning | undefined, theme?: LobbyTheme): string | undefined {
|
|
97
|
-
if (!planning || planning.saved) return undefined;
|
|
98
|
-
const rounds = roundBar(planning.round, planning.limit);
|
|
99
|
-
const head = `${paint(theme, "accent", "planning")} ${paint(theme, "success", rounds || `round ${planning.round}`)}${rounds ? paint(theme, "muted", ` ${planning.round}/${planning.limit}`) : ""}`;
|
|
100
|
-
if (planning.questions > 0) return `${head} ${paint(theme, "warning", `● ${planning.questions} question${planning.questions === 1 ? "" : "s"} for you`)}`;
|
|
101
|
-
if (planning.busy) return `${head} ${paint(theme, "muted", "the panel is thinking")}`;
|
|
102
|
-
if (planning.ready) return `${head} ${paint(theme, "success", "plan ready to save")}`;
|
|
103
|
-
return head;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function quickFixSegment(quickfix: MiniQuickFix | undefined, theme?: LobbyTheme): string | undefined {
|
|
107
|
-
if (!quickfix) return undefined;
|
|
108
|
-
const queued = quickfix.queued > 0 ? ` +${quickfix.queued} queued` : "";
|
|
109
|
-
return `${paint(theme, "accent", "quick fix")} ${paint(theme, "success", quickfix.running ? "◐" : "○")} ${paint(theme, "muted", quickfix.title)}${paint(theme, "dim", queued)}`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** The web link, while the server runs; pure: the link arrives as input. */
|
|
113
|
-
function webSegment(link: string | undefined, theme?: LobbyTheme): string | undefined {
|
|
114
|
-
if (!link) return undefined;
|
|
115
|
-
return paint(theme, "dim", link);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** The status line, at most `width` columns. */
|
|
119
|
-
export function miniLine(input: MiniInput, width: number, theme?: LobbyTheme): string {
|
|
120
|
-
if (width < 12) return "";
|
|
121
|
-
const segments = [taskSegment(input, theme), planningSegment(input.planning, theme), quickFixSegment(input.quickfix, theme), webSegment(input.webLink, theme)].filter((segment): segment is string => Boolean(segment));
|
|
122
|
-
const body = segments.length > 0 ? segments.join(paint(theme, "dim", " │ ")) : paint(theme, "dim", "idle");
|
|
123
|
-
const hint = paint(theme, "dim", ` ${input.key} opens`);
|
|
124
|
-
const line = ` ${paint(theme, "accent", "◆ bot-lobby")} ${body}`;
|
|
125
|
-
return truncateToWidth(`${line}${hint}`, width, "…");
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** An agent at work right now, for the lobby's bottom-line indicator. */
|
|
129
|
-
export interface WorkingAgent {
|
|
130
|
-
name: string;
|
|
131
|
-
activity?: string;
|
|
132
|
-
/** Epoch ms it started, for its elapsed time. */
|
|
133
|
-
since?: number;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** The subagents running now: task runs still going, and the quick fix in hand. */
|
|
137
|
-
export function workingAgents(runs: readonly AgentRun[], quickFix?: { startedAt?: number }): WorkingAgent[] {
|
|
138
|
-
const agents: WorkingAgent[] = [];
|
|
139
|
-
for (const run of runs) {
|
|
140
|
-
if (run.status !== "running") continue;
|
|
141
|
-
const since = Date.parse(run.startedAt);
|
|
142
|
-
agents.push({ name: agentName(run), ...(run.activity ? { activity: run.activity } : {}), ...(Number.isFinite(since) ? { since } : {}) });
|
|
143
|
-
}
|
|
144
|
-
if (quickFix) agents.push({ name: "QUICK FIX", ...(quickFix.startedAt ? { since: quickFix.startedAt } : {}) });
|
|
145
|
-
return agents;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function elapsed(ms: number): string {
|
|
149
|
-
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
150
|
-
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* `◐ DESIGN editing 2m · DEV running 40s`, at most `width` columns: it gives up
|
|
155
|
-
* the activity and time, then the names, then shows only a count, and nothing
|
|
156
|
-
* when even that does not fit or nothing is working. `spin` is the spinner glyph.
|
|
157
|
-
*/
|
|
158
|
-
export function agentsIndicator(agents: readonly WorkingAgent[], now: number, spin: string, width: number, theme?: LobbyTheme): string {
|
|
159
|
-
if (agents.length === 0) return "";
|
|
160
|
-
const full = agents.map((agent) => `${agent.name}${agent.activity ? ` ${agent.activity}` : ""}${agent.since ? ` ${elapsed(now - agent.since)}` : ""}`).join(" · ");
|
|
161
|
-
const names = [...new Set(agents.map((agent) => agent.name))].join(" · ");
|
|
162
|
-
const count = `${agents.length} agent${agents.length === 1 ? "" : "s"} working`;
|
|
163
|
-
for (const text of [full, names, count]) {
|
|
164
|
-
if (text.length + 2 <= width) return `${paint(theme, "accent", spin)} ${paint(theme, "muted", text)}`;
|
|
165
|
-
}
|
|
166
|
-
return "";
|
|
167
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Excalidraw tab: the shared sessions the user has added (five at most),
|
|
3
|
-
* on the left, and the picked one on the right — its link, whether agents may
|
|
4
|
-
* draw in it, the last check of it, and every agent it can be assigned to as a
|
|
5
|
-
* checklist. Assigned agents get the Excalidraw tools and join the room under
|
|
6
|
-
* their own name.
|
|
7
|
-
*/
|
|
8
|
-
import { AGENT_LABELS, EXCALIDRAW_AGENTS, MAX_SESSIONS, type ExcalidrawSession } from "../../excalidraw/sessions.ts";
|
|
9
|
-
import { bold, columns, fill, paint, rule, selectRow, spinner, split, spread, windowStart, wrap, type LobbyTheme } from "../layout.ts";
|
|
10
|
-
|
|
11
|
-
/** What the last check of a session found. */
|
|
12
|
-
export interface SessionCheck {
|
|
13
|
-
ok: boolean;
|
|
14
|
-
text: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ExcalidrawTabInput {
|
|
18
|
-
sessions: readonly ExcalidrawSession[];
|
|
19
|
-
selected: number;
|
|
20
|
-
focus: "list" | "detail";
|
|
21
|
-
/** The agent under the cursor in the checklist. */
|
|
22
|
-
agent: number;
|
|
23
|
-
checks: ReadonlyMap<string, SessionCheck>;
|
|
24
|
-
/** Ids of the sessions being checked now. */
|
|
25
|
-
checking: ReadonlySet<string>;
|
|
26
|
-
tick: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const EXCALIDRAW_COLUMNS_MIN = 90;
|
|
30
|
-
|
|
31
|
-
function agentCount(session: ExcalidrawSession): string {
|
|
32
|
-
const count = session.agents.length;
|
|
33
|
-
return count === 0 ? "no agents" : `${count} agent${count === 1 ? "" : "s"}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The sessions, the selected one in view. */
|
|
37
|
-
export function sessionRows(input: ExcalidrawTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
38
|
-
const rows = input.sessions.map((session, index) => {
|
|
39
|
-
const selected = index === input.selected;
|
|
40
|
-
const mark = input.checking.has(session.id) ? paint(theme, "accent", spinner(input.tick)) : input.checks.get(session.id)?.ok === false ? paint(theme, "warning", "!") : input.checks.has(session.id) ? paint(theme, "success", "✓") : " ";
|
|
41
|
-
const facts = paint(theme, session.agents.length > 0 ? "accent" : "dim", agentCount(session));
|
|
42
|
-
return selectRow(theme, spread(`${mark} ${selected ? bold(theme, session.name) : session.name}`, facts, width - 2), width, selected, input.focus === "list");
|
|
43
|
-
});
|
|
44
|
-
return rows.slice(windowStart(input.selected, rows.length, height), windowStart(input.selected, rows.length, height) + height);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function emptyLines(width: number, theme?: LobbyTheme): string[] {
|
|
48
|
-
return [
|
|
49
|
-
...wrap(paint(theme, "dim", "No sessions yet. A session is a live Excalidraw room that you and your agents draw in together."), width),
|
|
50
|
-
"",
|
|
51
|
-
...wrap(`${paint(theme, "accent", "a")} add one: in Excalidraw, Share → Live collaboration → Start session, then paste the link`, width),
|
|
52
|
-
...wrap(`${paint(theme, "accent", "n")} or make a new room here and open its link in Excalidraw`, width),
|
|
53
|
-
"",
|
|
54
|
-
...wrap(paint(theme, "dim", `Up to ${MAX_SESSIONS} sessions; each can be assigned to one agent or several.`), width),
|
|
55
|
-
];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The picked session: its link and state, then the agents it can be assigned to. */
|
|
59
|
-
export function detailLines(input: ExcalidrawTabInput, width: number, theme?: LobbyTheme): string[] {
|
|
60
|
-
const session = input.sessions[input.selected];
|
|
61
|
-
if (!session) return [];
|
|
62
|
-
const lines: string[] = [];
|
|
63
|
-
lines.push(...wrap(paint(theme, "muted", session.link), width));
|
|
64
|
-
lines.push(paint(theme, session.contribute ? "success" : "warning", session.contribute ? "agents may draw here (w: look only)" : "agents may only look (w: let them draw)"));
|
|
65
|
-
const check = input.checks.get(session.id);
|
|
66
|
-
if (input.checking.has(session.id)) lines.push(paint(theme, "accent", `${spinner(input.tick)} checking the room…`));
|
|
67
|
-
else if (check) lines.push(...wrap(paint(theme, check.ok ? "success" : "warning", `${check.ok ? "✓" : "!"} ${check.text}`), width));
|
|
68
|
-
else lines.push(paint(theme, "dim", "t checks that the room can be reached"));
|
|
69
|
-
lines.push("", rule(width, "Assigned to", theme, agentCount(session)));
|
|
70
|
-
EXCALIDRAW_AGENTS.forEach((agent, index) => {
|
|
71
|
-
const on = session.agents.includes(agent);
|
|
72
|
-
const picked = input.focus === "detail" && index === input.agent;
|
|
73
|
-
const box = on ? paint(theme, "success", "[x]") : paint(theme, "dim", "[ ]");
|
|
74
|
-
const cursor = picked ? paint(theme, "accent", "▸") : " ";
|
|
75
|
-
const label = on ? AGENT_LABELS[agent] : paint(theme, "muted", AGENT_LABELS[agent]);
|
|
76
|
-
lines.push(selectRow(theme, `${cursor} ${box} ${picked ? bold(theme, label) : label}`, width, picked, true));
|
|
77
|
-
});
|
|
78
|
-
lines.push("", ...wrap(paint(theme, "dim", "Assigned agents read the board and draw on it with excalidraw_read and excalidraw_draw, and appear in the room under their own name."), width));
|
|
79
|
-
return lines;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function renderExcalidraw(input: ExcalidrawTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
83
|
-
if (height <= 0) return [];
|
|
84
|
-
const count = `${input.sessions.length}/${MAX_SESSIONS}`;
|
|
85
|
-
if (input.sessions.length === 0) return fill([rule(width, "Excalidraw", theme, count), ...emptyLines(width, theme)], height, width);
|
|
86
|
-
const selected = Math.min(Math.max(0, input.selected), input.sessions.length - 1);
|
|
87
|
-
const view = { ...input, selected };
|
|
88
|
-
const wide = width >= EXCALIDRAW_COLUMNS_MIN;
|
|
89
|
-
const [listWidth, detailWidth] = wide ? split(width, 0.34, 3, 44) : [width, width];
|
|
90
|
-
const listPane = fill([rule(listWidth, "Sessions", theme, count), ...sessionRows(view, listWidth, height - 1, theme)], height);
|
|
91
|
-
const session = input.sessions[selected]!;
|
|
92
|
-
const detailPane = fill([rule(detailWidth, `${session.name}${input.focus === "detail" ? " ◂" : ""}`, theme), ...detailLines(view, detailWidth, theme)], height);
|
|
93
|
-
if (!wide) return fill(input.focus === "detail" ? detailPane : listPane, height, width);
|
|
94
|
-
return fill(columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme), height, width);
|
|
95
|
-
}
|
package/src/lobby/tabs/git.ts
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Git tab: the repository's open pull requests through `gh`, the selected
|
|
3
|
-
* one with its checks, files, description and discussion, and — the point of
|
|
4
|
-
* the tab — a review of it by a read-only agent (v, or f with a focus), or a
|
|
5
|
-
* quick read by Jev (t), shown right under its facts.
|
|
6
|
-
*/
|
|
7
|
-
import { textWidth } from "../../width.ts";
|
|
8
|
-
import type { PullDetail, PullSummary } from "../pulls.ts";
|
|
9
|
-
import { isStale, type PullReadState, type PullReview, type Verdict } from "../pr-review.ts";
|
|
10
|
-
import { BRANCH_GLYPH, bold, columns, fill, markdownLines, paint, rule, selectRow, since, spinner, split, spread, windowStart, wrap, wrapHanging, type LobbyTheme } from "../layout.ts";
|
|
11
|
-
|
|
12
|
-
export interface GitTabInput {
|
|
13
|
-
pulls: readonly PullSummary[];
|
|
14
|
-
selected: number;
|
|
15
|
-
detail?: PullDetail;
|
|
16
|
-
/** The agent's review of the selected pull request, and Jev's read of it. */
|
|
17
|
-
review?: PullReview;
|
|
18
|
-
read?: PullReadState;
|
|
19
|
-
focus: "list" | "detail";
|
|
20
|
-
detailOffset: number;
|
|
21
|
-
loading: boolean;
|
|
22
|
-
loaded: boolean;
|
|
23
|
-
error?: string;
|
|
24
|
-
tick: number;
|
|
25
|
-
now: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const GIT_COLUMNS_MIN = 90;
|
|
29
|
-
|
|
30
|
-
const VERDICTS: Record<Verdict, { words: string; color: "success" | "error" | "warning" }> = {
|
|
31
|
-
approve: { words: "approve", color: "success" },
|
|
32
|
-
changes: { words: "request changes", color: "error" },
|
|
33
|
-
comment: { words: "comment", color: "warning" },
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/** `✓` passing, `✗` failing, `●` still running, blank without checks. */
|
|
37
|
-
function checkMark(pull: PullSummary, theme?: LobbyTheme): string {
|
|
38
|
-
if (pull.checks === "passing") return paint(theme, "success", "✓");
|
|
39
|
-
if (pull.checks === "failing") return paint(theme, "error", "✗");
|
|
40
|
-
if (pull.checks === "pending") return paint(theme, "warning", "●");
|
|
41
|
-
return " ";
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** The size of a change, `+12 −3`, added lines green and removed ones red. */
|
|
45
|
-
export function changeSize(additions: number, deletions: number, theme?: LobbyTheme): string {
|
|
46
|
-
return `${paint(theme, "success", `+${additions}`)} ${paint(theme, "error", `−${deletions}`)}`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function decisionWords(decision: string | undefined): { words: string; color: "success" | "error" | "warning" } | undefined {
|
|
50
|
-
if (decision === "APPROVED") return { words: "approved", color: "success" };
|
|
51
|
-
if (decision === "CHANGES_REQUESTED") return { words: "changes requested", color: "error" };
|
|
52
|
-
if (decision === "REVIEW_REQUIRED") return { words: "review required", color: "warning" };
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** How long a review took, `2m 05s`. */
|
|
57
|
-
function took(review: PullReview, now: number): string {
|
|
58
|
-
const ms = Math.max(0, (review.finishedAt ?? now) - review.startedAt);
|
|
59
|
-
const seconds = Math.round(ms / 1000);
|
|
60
|
-
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** The review of the selected pull request: running steps, or the finished review, or why it failed. */
|
|
64
|
-
export function reviewLines(review: PullReview, detail: PullDetail | undefined, width: number, tick: number, now: number, theme?: LobbyTheme): string[] {
|
|
65
|
-
const lines: string[] = [];
|
|
66
|
-
const verdict = review.verdict ? VERDICTS[review.verdict] : undefined;
|
|
67
|
-
// Short enough to fit beside the title in a narrow pane; the model goes on a line of its own.
|
|
68
|
-
const right = review.status === "running"
|
|
69
|
-
? `${spinner(tick)} ${took(review, now)}`
|
|
70
|
-
: [verdict ? paint(theme, verdict.color, verdict.words) : "", review.status === "done" ? took(review, now) : review.status].filter(Boolean).join(paint(theme, "dim", " · "));
|
|
71
|
-
lines.push(rule(width, "Review", theme, right));
|
|
72
|
-
if (review.model) lines.push(paint(theme, "dim", [review.model, review.thinking].filter(Boolean).join(" · ")));
|
|
73
|
-
if (review.focus) lines.push(...wrapHanging(paint(theme, "dim", "focus: "), review.focus, width));
|
|
74
|
-
if (review.saved) lines.push(paint(theme, "dim", `kept from ${since(now - review.startedAt)}`));
|
|
75
|
-
if (isStale(review, detail)) lines.push(paint(theme, "warning", "the pull request has new commits since this review — v reviews it again"));
|
|
76
|
-
if (review.status === "running") {
|
|
77
|
-
for (const step of review.steps.slice(-6)) lines.push(...wrapHanging(paint(theme, "dim", "· "), paint(theme, "muted", step), width));
|
|
78
|
-
lines.push(paint(theme, "dim", "x stops it"));
|
|
79
|
-
return lines;
|
|
80
|
-
}
|
|
81
|
-
if (review.status === "cancelled") lines.push(paint(theme, "warning", "stopped"));
|
|
82
|
-
if (review.status === "failed" || review.status === "timeout") lines.push(...wrap(paint(theme, "error", `✗ ${review.error ?? review.status}`), width));
|
|
83
|
-
if (review.text) lines.push(...markdownLines(review.text, width, theme));
|
|
84
|
-
return lines;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Jev's read: one line, or the reason there is none. */
|
|
88
|
-
export function readLines(read: PullReadState, width: number, tick: number, theme?: LobbyTheme): string[] {
|
|
89
|
-
if (read.status === "running") return [rule(width, "Jev's read", theme, spinner(tick)), paint(theme, "dim", "reading the diff…")];
|
|
90
|
-
if (read.status === "failed") return [rule(width, "Jev's read", theme), ...wrap(paint(theme, "warning", read.error ?? "Jev could not read it"), width)];
|
|
91
|
-
return [rule(width, "Jev's read", theme, read.read ? `${read.read.model} · ${read.read.ms} ms` : ""), ...wrap(read.line ?? "", width)];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function pullDetailLines(pull: PullDetail, review: PullReview | undefined, read: PullReadState | undefined, width: number, tick: number, now: number, theme?: LobbyTheme): string[] {
|
|
95
|
-
const dot = paint(theme, "dim", " · ");
|
|
96
|
-
const decision = decisionWords(pull.decision);
|
|
97
|
-
const facts = [
|
|
98
|
-
pull.state ? paint(theme, pull.state === "OPEN" ? "success" : "dim", pull.draft ? "draft" : pull.state.toLowerCase()) : pull.draft ? paint(theme, "dim", "draft") : "",
|
|
99
|
-
pull.author ? `by ${pull.author}` : "",
|
|
100
|
-
pull.updatedAt ? `updated ${since(now - Date.parse(pull.updatedAt))}` : "",
|
|
101
|
-
].filter(Boolean);
|
|
102
|
-
const checks = pull.checks ? `checks ${paint(theme, pull.checks === "passing" ? "success" : pull.checks === "failing" ? "error" : "warning", pull.checks)} (${pull.checkCount})` : "";
|
|
103
|
-
const merge = pull.mergeable ? (pull.mergeable === "CONFLICTING" ? paint(theme, "error", "conflicts") : pull.mergeable === "MERGEABLE" ? "mergeable" : "") : "";
|
|
104
|
-
const lines = [
|
|
105
|
-
...wrap(bold(theme, `#${pull.number} ${pull.title}`), width),
|
|
106
|
-
facts.join(dot),
|
|
107
|
-
`${paint(theme, "accent", `${BRANCH_GLYPH} ${pull.headRef || "?"}`)} → ${pull.baseRef || "?"}${dot}${changeSize(pull.additions, pull.deletions, theme)}${dot}${pull.changedFiles} file${pull.changedFiles === 1 ? "" : "s"}`,
|
|
108
|
-
[checks, decision ? paint(theme, decision.color, decision.words) : "", merge, pull.labels.length > 0 ? paint(theme, "warning", pull.labels.join(", ")) : ""].filter(Boolean).join(dot),
|
|
109
|
-
].filter((line) => line.length > 0);
|
|
110
|
-
if (pull.url) lines.push(paint(theme, "dim", pull.url));
|
|
111
|
-
lines.push("", ...wrap(paint(theme, "dim", "v reviews it with an agent · f with a focus you type · t is Jev's quick read"), width));
|
|
112
|
-
if (read) lines.push("", ...readLines(read, width, tick, theme));
|
|
113
|
-
if (review) lines.push("", ...reviewLines(review, pull, width, tick, now, theme));
|
|
114
|
-
if (pull.files.length > 0) {
|
|
115
|
-
lines.push("", rule(width, "Files", theme, String(pull.files.length)));
|
|
116
|
-
const numbers = pull.files.map((file) => textWidth(changeSize(file.additions, file.deletions)));
|
|
117
|
-
const room = Math.max(...numbers);
|
|
118
|
-
for (const file of pull.files) lines.push(spread(file.path, `${" ".repeat(room - textWidth(changeSize(file.additions, file.deletions)))}${changeSize(file.additions, file.deletions, theme)}`, width));
|
|
119
|
-
}
|
|
120
|
-
lines.push("", rule(width, "Description", theme), ...(pull.body.trim() ? markdownLines(pull.body.trim(), width, theme) : [paint(theme, "dim", "(no description)")]));
|
|
121
|
-
for (const note of pull.notes) {
|
|
122
|
-
const verdict = note.state && note.state !== "COMMENTED" ? paint(theme, note.state === "APPROVED" ? "success" : note.state === "CHANGES_REQUESTED" ? "error" : "warning", note.state.toLowerCase().replace(/_/g, " ")) : "";
|
|
123
|
-
lines.push("", rule(width, note.author ?? "comment", theme, [verdict, note.at ? since(now - Date.parse(note.at)) : ""].filter(Boolean).join(" · ")), ...(note.body.trim() ? markdownLines(note.body.trim(), width, theme) : []));
|
|
124
|
-
}
|
|
125
|
-
return lines;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function empty(input: GitTabInput, width: number, theme?: LobbyTheme): string[] {
|
|
129
|
-
if (input.loading) return [`${paint(theme, "accent", spinner(input.tick))} loading pull requests from GitHub…`];
|
|
130
|
-
if (input.error) return [];
|
|
131
|
-
if (!input.loaded) return wrap(paint(theme, "dim", "Press r to load open pull requests with the GitHub CLI (gh)."), width);
|
|
132
|
-
return wrap(paint(theme, "dim", "No open pull requests."), width);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** One row of the list: number, checks, title, and the review's outcome when there is one. */
|
|
136
|
-
function row(pull: PullSummary, width: number, selected: boolean, focused: boolean, review: PullReview | undefined, theme?: LobbyTheme): string {
|
|
137
|
-
const verdict = review?.status === "done" && review.verdict ? paint(theme, VERDICTS[review.verdict].color, review.verdict === "approve" ? "✓ reviewed" : review.verdict === "changes" ? "✗ reviewed" : "◆ reviewed") : review?.status === "running" ? paint(theme, "accent", "reviewing") : "";
|
|
138
|
-
const title = selected ? bold(theme, pull.title) : pull.title;
|
|
139
|
-
const draft = pull.draft ? `${paint(theme, "dim", "draft")} ` : "";
|
|
140
|
-
const head = `${paint(theme, "dim", `#${pull.number}`)} ${checkMark(pull, theme)} ${draft}${title}`;
|
|
141
|
-
return selectRow(theme, spread(head, [verdict, changeSize(pull.additions, pull.deletions, theme)].filter(Boolean).join(" "), width), width, selected, focused);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function renderGit(input: GitTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
145
|
-
if (height <= 0) return [];
|
|
146
|
-
const banner = input.error ? wrap(paint(theme, "error", `✗ ${input.error}`), width) : [];
|
|
147
|
-
const bodyHeight = Math.max(0, height - banner.length);
|
|
148
|
-
if (input.pulls.length === 0) return fill([...banner, rule(width, "Pull requests", theme), ...empty(input, width, theme)], height, width);
|
|
149
|
-
const selected = Math.min(Math.max(0, input.selected), input.pulls.length - 1);
|
|
150
|
-
const wide = width >= GIT_COLUMNS_MIN;
|
|
151
|
-
const [listWidth, detailWidth] = wide ? split(width, 0.4, 3, 40) : [width, width];
|
|
152
|
-
const rows = input.pulls.map((pull, index) => row(pull, listWidth, index === selected, input.focus === "list", pull.number === input.review?.number ? input.review : undefined, theme));
|
|
153
|
-
const right = input.loading ? `${spinner(input.tick)} refreshing` : `${input.pulls.length} open`;
|
|
154
|
-
const listPane = fill([rule(listWidth, "Pull requests", theme, right), ...rows.slice(windowStart(selected, rows.length, bodyHeight - 1))], bodyHeight);
|
|
155
|
-
const current = input.pulls[selected]!;
|
|
156
|
-
const detail = input.detail && input.detail.number === current.number
|
|
157
|
-
? pullDetailLines(input.detail, input.review, input.read, detailWidth, input.tick, input.now, theme)
|
|
158
|
-
: [`${paint(theme, "accent", spinner(input.tick))} loading #${current.number}…`];
|
|
159
|
-
const detailPane = fill([rule(detailWidth, input.focus === "detail" ? "Pull request ◂" : "Pull request", theme), ...detail.slice(Math.max(0, Math.min(input.detailOffset, detail.length - 1)))], bodyHeight);
|
|
160
|
-
if (!wide) return fill([...banner, ...(input.focus === "detail" ? detailPane : listPane)], height, width);
|
|
161
|
-
return fill([...banner, ...columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme)], height, width);
|
|
162
|
-
}
|