@a-t-h-i/bot-lobby 0.5.0 → 0.6.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 +264 -68
- package/package.json +5 -2
- package/prompts/panel.md +13 -4
- package/prompts/planner.md +31 -13
- package/src/execution/pi-runner.ts +1 -1
- package/src/index.ts +4 -2
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/feed.ts +65 -9
- package/src/lobby/keys.ts +55 -0
- package/src/lobby/layout.ts +208 -4
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +135 -17
- package/src/lobby/runtime.ts +353 -66
- package/src/lobby/session-files.ts +126 -0
- package/src/lobby/sessions.ts +288 -0
- package/src/lobby/tabs/home.ts +225 -59
- package/src/lobby/tabs/metrics.ts +155 -42
- package/src/lobby/tabs/plan.ts +128 -51
- package/src/lobby/tabs/quickfix.ts +43 -18
- package/src/lobby/tabs/tasks.ts +221 -54
- package/src/lobby/view.ts +1303 -143
- package/src/pi/commands.ts +35 -7
- package/src/pi/events.ts +5 -2
- package/src/pi/notify.ts +18 -0
- package/src/pi/owner.ts +216 -0
- package/src/pi/settings-ui.ts +69 -1
- package/src/pi/start-task.ts +33 -2
- package/src/pi/ui.ts +3 -3
- package/src/pi/zen-large.ts +9 -1
- package/src/pi/zen.ts +7 -3
- package/src/schemas/configuration.ts +43 -4
- package/src/schemas/task.ts +2 -0
- package/src/state/auto.ts +40 -0
- package/src/state/inbox.ts +90 -0
- package/src/workflow/workflow.ts +27 -3
package/src/lobby/ask.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The oracle puts the planning panel's questions to the user one at a time
|
|
3
|
+
* through the ask-user-question library (`@juicesharp/rpiv-ask-user-question`):
|
|
4
|
+
* a tabbed questionnaire with each seat's options, the recommended one first,
|
|
5
|
+
* and a free-text row on every question. The library only publishes a pi
|
|
6
|
+
* extension entry point, so its `ask_user_question` tool is captured by
|
|
7
|
+
* calling that entry with a `pi` whose `registerTool` keeps the definition
|
|
8
|
+
* instead of registering it; its `execute` then runs the real questionnaire.
|
|
9
|
+
* Without the library, pi's own select and input dialogs ask the same
|
|
10
|
+
* questions in the same order.
|
|
11
|
+
*/
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { PanelQuestion } from "./planner.ts";
|
|
14
|
+
|
|
15
|
+
/** The library's limits: at most 4 questions per questionnaire, 2-4 options each. */
|
|
16
|
+
export const MAX_QUESTIONS = 4;
|
|
17
|
+
export const MIN_OPTIONS = 2;
|
|
18
|
+
export const MAX_OPTIONS = 4;
|
|
19
|
+
const MAX_HEADER = 16;
|
|
20
|
+
const MAX_LABEL = 60;
|
|
21
|
+
/** Labels the library reserves for its own rows. */
|
|
22
|
+
const RESERVED = new Set(["other", "type something.", "next"]);
|
|
23
|
+
|
|
24
|
+
export interface AskOption {
|
|
25
|
+
label: string;
|
|
26
|
+
description: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AskQuestion {
|
|
30
|
+
question: string;
|
|
31
|
+
header: string;
|
|
32
|
+
options: AskOption[];
|
|
33
|
+
multiSelect?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AskAnswer {
|
|
37
|
+
questionIndex: number;
|
|
38
|
+
question: string;
|
|
39
|
+
kind: "option" | "custom" | "multi";
|
|
40
|
+
answer: string | null;
|
|
41
|
+
selected?: string[];
|
|
42
|
+
notes?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AskResult {
|
|
46
|
+
answers: AskAnswer[];
|
|
47
|
+
cancelled: boolean;
|
|
48
|
+
globalNote?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Puts up to `MAX_QUESTIONS` questions to the user and returns what they chose. */
|
|
52
|
+
export type Asker = (questions: readonly AskQuestion[], ctx: ExtensionContext) => Promise<AskResult>;
|
|
53
|
+
|
|
54
|
+
interface ToolLike {
|
|
55
|
+
execute(toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ details?: unknown }>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clip(text: string, max: number): string {
|
|
59
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
60
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Filler options for a question the panel asked without any. */
|
|
64
|
+
const DEFAULT_OPTIONS: readonly AskOption[] = [
|
|
65
|
+
{ label: "Go with the recommendation", description: "Let the panel pick the option it recommends." },
|
|
66
|
+
{ label: "Leave it open", description: "Skip this one for now; the panel may ask again." },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** A panel question in the library's schema: seat as the chip, 2-4 unique, unreserved option labels. */
|
|
70
|
+
export function toAskQuestion(question: PanelQuestion): AskQuestion {
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
const options: AskOption[] = [];
|
|
73
|
+
for (const option of question.options) {
|
|
74
|
+
let label = clip(option.label, MAX_LABEL);
|
|
75
|
+
if (!label || RESERVED.has(label.toLowerCase())) continue;
|
|
76
|
+
for (let n = 2; seen.has(label.toLowerCase()); n++) label = clip(`${option.label} (${n})`, MAX_LABEL);
|
|
77
|
+
seen.add(label.toLowerCase());
|
|
78
|
+
options.push({ label, description: option.description.trim() || label });
|
|
79
|
+
if (options.length === MAX_OPTIONS) break;
|
|
80
|
+
}
|
|
81
|
+
for (const filler of DEFAULT_OPTIONS) {
|
|
82
|
+
if (options.length >= MIN_OPTIONS) break;
|
|
83
|
+
if (!seen.has(filler.label.toLowerCase())) options.push({ ...filler });
|
|
84
|
+
}
|
|
85
|
+
return { question: question.text.trim(), header: clip(question.from, MAX_HEADER), options };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Split the round's questions into questionnaires of at most `MAX_QUESTIONS`, with unique question texts. */
|
|
89
|
+
export function questionnaires(questions: readonly PanelQuestion[]): AskQuestion[][] {
|
|
90
|
+
const seen = new Set<string>();
|
|
91
|
+
const asked = questions.map((question) => {
|
|
92
|
+
const ask = toAskQuestion(question);
|
|
93
|
+
let text = ask.question;
|
|
94
|
+
for (let n = 2; seen.has(text.toLowerCase()); n++) text = `${ask.question} (${question.from}${n > 2 ? ` ${n}` : ""})`;
|
|
95
|
+
seen.add(text.toLowerCase());
|
|
96
|
+
return { ...ask, question: text };
|
|
97
|
+
});
|
|
98
|
+
const chunks: AskQuestion[][] = [];
|
|
99
|
+
for (let i = 0; i < asked.length; i += MAX_QUESTIONS) chunks.push(asked.slice(i, i + MAX_QUESTIONS));
|
|
100
|
+
return chunks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function answerText(answer: AskAnswer | undefined): string | undefined {
|
|
104
|
+
if (!answer) return undefined;
|
|
105
|
+
if (answer.kind === "multi") return answer.selected && answer.selected.length > 0 ? answer.selected.join(", ") : undefined;
|
|
106
|
+
return answer.answer?.trim() || undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The user's turn for the next round: every question with who asked it and
|
|
111
|
+
* the answer (`→ …`), their notes, and what they left unanswered. Undefined
|
|
112
|
+
* when nothing was answered.
|
|
113
|
+
*/
|
|
114
|
+
export function answerMessage(questions: readonly PanelQuestion[], results: readonly AskResult[]): string | undefined {
|
|
115
|
+
const lines: string[] = [];
|
|
116
|
+
const skipped: string[] = [];
|
|
117
|
+
const notes: string[] = [];
|
|
118
|
+
let answered = 0;
|
|
119
|
+
results.forEach((result, chunk) => {
|
|
120
|
+
if (result.globalNote?.trim()) notes.push(result.globalNote.trim());
|
|
121
|
+
const offset = chunk * MAX_QUESTIONS;
|
|
122
|
+
for (let i = 0; i < MAX_QUESTIONS; i++) {
|
|
123
|
+
const question = questions[offset + i];
|
|
124
|
+
if (!question) break;
|
|
125
|
+
const answer = result.answers.find((entry) => entry.questionIndex === i);
|
|
126
|
+
const text = answerText(answer);
|
|
127
|
+
const label = `${offset + i + 1}. [${question.from}] ${question.text.replace(/\s+/g, " ").trim()}`;
|
|
128
|
+
if (!text) {
|
|
129
|
+
skipped.push(label);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
answered += 1;
|
|
133
|
+
lines.push(`${label}\n → ${text}${answer?.kind === "custom" ? " (in my words)" : ""}`);
|
|
134
|
+
if (answer?.notes?.trim()) lines.push(` note: ${answer.notes.trim()}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
if (answered === 0 && notes.length === 0) return undefined;
|
|
138
|
+
return [
|
|
139
|
+
"Answers to the panel's questions:",
|
|
140
|
+
...lines,
|
|
141
|
+
...(notes.length > 0 ? ["", `Note: ${notes.join(" ")}`] : []),
|
|
142
|
+
...(skipped.length > 0 ? ["", "Not answered this round:", ...skipped] : []),
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Capture the library's tool through its public extension entry: the entry
|
|
148
|
+
* registers the tool on the `pi` it is given, so a `pi` whose `registerTool`
|
|
149
|
+
* keeps the definition (and whose `on` ignores the library's own tool-list
|
|
150
|
+
* reconciler) yields it without touching the session's tools. Undefined when
|
|
151
|
+
* the library is missing or registers nothing.
|
|
152
|
+
*/
|
|
153
|
+
/**
|
|
154
|
+
* The library ships TypeScript sources; a non-literal specifier keeps them out
|
|
155
|
+
* of this package's typecheck (they are compiled by pi's loader at runtime).
|
|
156
|
+
*/
|
|
157
|
+
const ASK_LIBRARY: string = "@juicesharp/rpiv-ask-user-question";
|
|
158
|
+
|
|
159
|
+
export async function loadAskTool(pi: ExtensionAPI, load: () => Promise<unknown> = () => import(ASK_LIBRARY)): Promise<ToolLike | undefined> {
|
|
160
|
+
try {
|
|
161
|
+
const module = (await load()) as { default?: (pi: ExtensionAPI) => void };
|
|
162
|
+
if (typeof module.default !== "function") return undefined;
|
|
163
|
+
let captured: ToolLike | undefined;
|
|
164
|
+
const capture = new Proxy(pi, {
|
|
165
|
+
get(target, property, receiver) {
|
|
166
|
+
if (property === "registerTool") return (tool: ToolLike & { name?: string }) => {
|
|
167
|
+
if (tool.name === "ask_user_question") captured = tool;
|
|
168
|
+
};
|
|
169
|
+
if (property === "on") return () => () => {};
|
|
170
|
+
return Reflect.get(target, property, receiver);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
module.default(capture);
|
|
174
|
+
return captured;
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Run one questionnaire through the captured tool. */
|
|
181
|
+
export function toolAsker(tool: ToolLike): Asker {
|
|
182
|
+
return async (questions, ctx) => {
|
|
183
|
+
const result = await tool.execute(`bot-lobby-panel-${Date.now().toString(36)}`, { questions }, undefined, undefined, ctx);
|
|
184
|
+
const details = result.details as Partial<AskResult> | undefined;
|
|
185
|
+
return { answers: details?.answers ?? [], cancelled: details?.cancelled !== false, ...(details?.globalNote ? { globalNote: details.globalNote } : {}) };
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const TYPE_ANSWER = "Type an answer…";
|
|
190
|
+
const SKIP = "Skip";
|
|
191
|
+
|
|
192
|
+
/** The same questions through pi's built-in dialogs: pick an option, type an answer, or skip; esc stops. */
|
|
193
|
+
export function dialogAsker(): Asker {
|
|
194
|
+
return async (questions, ctx) => {
|
|
195
|
+
const answers: AskAnswer[] = [];
|
|
196
|
+
for (const [index, question] of questions.entries()) {
|
|
197
|
+
const choices = [...question.options.map((option) => option.label), TYPE_ANSWER, SKIP];
|
|
198
|
+
const title = `${question.header} · ${index + 1}/${questions.length}\n\n${question.question}`;
|
|
199
|
+
const choice = await ctx.ui.select(title, choices);
|
|
200
|
+
if (choice === undefined) return { answers, cancelled: true };
|
|
201
|
+
if (choice === SKIP) continue;
|
|
202
|
+
if (choice === TYPE_ANSWER) {
|
|
203
|
+
const typed = await ctx.ui.input(question.question, "your answer");
|
|
204
|
+
if (typed === undefined) return { answers, cancelled: true };
|
|
205
|
+
if (typed.trim()) answers.push({ questionIndex: index, question: question.question, kind: "custom", answer: typed.trim() });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
answers.push({ questionIndex: index, question: question.question, kind: "option", answer: choice });
|
|
209
|
+
}
|
|
210
|
+
return { answers, cancelled: false };
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Put every question to the user, one questionnaire after another. Stopping
|
|
216
|
+
* a questionnaire (esc) stops the rest; what was answered before it is kept.
|
|
217
|
+
*/
|
|
218
|
+
export async function askPanel(questions: readonly PanelQuestion[], ask: Asker, ctx: ExtensionContext): Promise<{ results: AskResult[]; stopped: boolean }> {
|
|
219
|
+
const results: AskResult[] = [];
|
|
220
|
+
for (const chunk of questionnaires(questions)) {
|
|
221
|
+
const result = await ask(chunk, ctx);
|
|
222
|
+
if (result.cancelled) return { results, stopped: true };
|
|
223
|
+
results.push(result);
|
|
224
|
+
}
|
|
225
|
+
return { results, stopped: false };
|
|
226
|
+
}
|
package/src/lobby/feed.ts
CHANGED
|
@@ -225,15 +225,19 @@ export function textOf(content: unknown): string {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
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 }
|
|
228
|
+
export function chatText(role: "user" | "assistant", text: string): Array<{ role: ChatRole; text: string }> {
|
|
229
229
|
const body = text.trim();
|
|
230
|
-
if (!body) return
|
|
231
|
-
if (role === "assistant") return { role: "oracle", text: body };
|
|
230
|
+
if (!body) return [];
|
|
231
|
+
if (role === "assistant") return [{ role: "oracle", text: body }];
|
|
232
|
+
// bot-lobby's kickoff: the task starting, then what the user asked for, in their words.
|
|
232
233
|
const kickoff = /^A bot-lobby task is active: (\S+)\nTitle: (.*)/.exec(body);
|
|
233
|
-
if (kickoff)
|
|
234
|
+
if (kickoff) {
|
|
235
|
+
const request = /\nRequest: ([\s\S]*?)(?:\nState: |$)/.exec(body)?.[1]?.trim();
|
|
236
|
+
return [{ role: "note", text: `task started · ${kickoff[2]}` }, ...(request ? [{ role: "you" as const, text: request }] : [])];
|
|
237
|
+
}
|
|
234
238
|
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: `
|
|
236
|
-
return { role: "you", text: body };
|
|
239
|
+
if (comment) return [{ role: "note", text: `your comment on ${comment[1]} went to the oracle` }];
|
|
240
|
+
return [{ role: "you", text: body }];
|
|
237
241
|
}
|
|
238
242
|
|
|
239
243
|
/** Session entries (`{ type: "message", message }`) as conversation entries, oldest first. */
|
|
@@ -244,10 +248,62 @@ export function chatFromEntries(entries: readonly unknown[], max = MAX_CHAT): Ar
|
|
|
244
248
|
if (record?.type !== "message") continue;
|
|
245
249
|
const role = record.message?.role;
|
|
246
250
|
if (role !== "user" && role !== "assistant") continue;
|
|
247
|
-
const line = chatText(role, textOf(record.message?.content));
|
|
248
|
-
if (!line) continue;
|
|
249
251
|
const at = record.timestamp ? Date.parse(record.timestamp) : undefined;
|
|
250
|
-
chat.push({ ...line, ...(at !== undefined && Number.isFinite(at) ? { at } : {}) });
|
|
252
|
+
for (const line of chatText(role, textOf(record.message?.content))) chat.push({ ...line, ...(at !== undefined && Number.isFinite(at) ? { at } : {}) });
|
|
251
253
|
}
|
|
252
254
|
return chat.slice(-max);
|
|
253
255
|
}
|
|
256
|
+
|
|
257
|
+
/** The parts of pi's agent events the lobby narrates; the same shape in process and over RPC. */
|
|
258
|
+
export interface AgentEventLike {
|
|
259
|
+
type: string;
|
|
260
|
+
toolName?: string;
|
|
261
|
+
args?: unknown;
|
|
262
|
+
toolCallId?: string;
|
|
263
|
+
isError?: boolean;
|
|
264
|
+
assistantMessageEvent?: { type: string; delta?: string; content?: string };
|
|
265
|
+
message?: { role?: string; content?: unknown; stopReason?: string; errorMessage?: string };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* One of a Master's agent events into a feed: tool calls become plain-words
|
|
270
|
+
* activity, thinking goes to the thinking pane, the reply streams, and
|
|
271
|
+
* finished messages join the conversation (a failed turn says so there too).
|
|
272
|
+
*/
|
|
273
|
+
export function narrateEvent(feed: LobbyFeed, event: AgentEventLike, describe: (toolName: string, args: unknown) => string): void {
|
|
274
|
+
switch (event.type) {
|
|
275
|
+
case "tool_execution_start":
|
|
276
|
+
feed.begin("MASTER", describe(event.toolName ?? "tool", event.args), event.toolCallId);
|
|
277
|
+
return;
|
|
278
|
+
case "tool_execution_end":
|
|
279
|
+
if (event.toolCallId) feed.end(event.toolCallId, event.isError === true);
|
|
280
|
+
return;
|
|
281
|
+
case "message_update": {
|
|
282
|
+
const update = event.assistantMessageEvent;
|
|
283
|
+
if (update?.type === "thinking_delta" && update.delta) feed.thinkDelta("MASTER", update.delta);
|
|
284
|
+
else if (update?.type === "thinking_end") feed.thinkEnd("MASTER", update.content);
|
|
285
|
+
else if (update?.type === "text_delta" && update.delta) feed.replyDelta(update.delta);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
case "message_end": {
|
|
289
|
+
const message = event.message ?? {};
|
|
290
|
+
if (message.role === "assistant") {
|
|
291
|
+
feed.replyEnd();
|
|
292
|
+
feed.thinkEnd("MASTER");
|
|
293
|
+
if (message.stopReason === "error") {
|
|
294
|
+
const error = (message.errorMessage ?? "the model call failed").split("\n")[0]!;
|
|
295
|
+
feed.say("note", `✗ the oracle's turn failed: ${error}`);
|
|
296
|
+
feed.log("MASTER", `turn failed — ${error}`, "error");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (message.role !== "user" && message.role !== "assistant") return;
|
|
300
|
+
for (const line of chatText(message.role, textOf(message.content))) feed.say(line.role, line.text);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
case "agent_end":
|
|
304
|
+
feed.replyEnd();
|
|
305
|
+
return;
|
|
306
|
+
default:
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lobby's shortcuts in one table: every action has a default key, a line
|
|
3
|
+
* for the help overlay, and can be rebound under `lobby.keys` in the config
|
|
4
|
+
* (`{ "toggleThinking": "alt+t" }`). They work in typing and browsing mode
|
|
5
|
+
* alike, so each default is a key that never types a character.
|
|
6
|
+
*/
|
|
7
|
+
import { matchesKey, type KeyId } from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
export const LOBBY_ACTIONS = {
|
|
10
|
+
hide: { key: "alt+l", help: "hide the lobby (back to pi)" },
|
|
11
|
+
help: { key: "alt+h", help: "show or hide these keys" },
|
|
12
|
+
settings: { key: "alt+s", help: "bot-lobby settings: each agent's model and thinking, the lobby" },
|
|
13
|
+
search: { key: "ctrl+f", help: "search the current tab" },
|
|
14
|
+
savePlan: { key: "ctrl+s", help: "save the plan being made in the Plan tab to the pending tasks" },
|
|
15
|
+
sessions: { key: "alt+o", help: "switch session: this window, background sessions, other terminals" },
|
|
16
|
+
newSession: { key: "alt+n", help: "start a task in a new session, named after it" },
|
|
17
|
+
toggleAuto: { key: "alt+g", help: "auto mode: the oracle drives the task to completion without asking" },
|
|
18
|
+
nextTab: { key: "tab", help: "next tab" },
|
|
19
|
+
prevTab: { key: "shift+tab", help: "previous tab" },
|
|
20
|
+
toggleScene: { key: "alt+z", help: "show or hide the oracle and agent animations" },
|
|
21
|
+
toggleConversation: { key: "alt+c", help: "show or hide the conversation" },
|
|
22
|
+
toggleActivity: { key: "alt+a", help: "show or hide the activity log" },
|
|
23
|
+
toggleThinking: { key: "alt+k", help: "show or hide thinking" },
|
|
24
|
+
scrollUp: { key: "pageUp", help: "scroll up a page" },
|
|
25
|
+
scrollDown: { key: "pageDown", help: "scroll down a page" },
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export type LobbyAction = keyof typeof LOBBY_ACTIONS;
|
|
29
|
+
|
|
30
|
+
export type KeyMap = Record<LobbyAction, string>;
|
|
31
|
+
|
|
32
|
+
/** Defaults with the config's overrides on top; unknown action names are ignored. */
|
|
33
|
+
export function keyMap(overrides: Readonly<Record<string, string>> = {}): KeyMap {
|
|
34
|
+
const map = Object.fromEntries(Object.entries(LOBBY_ACTIONS).map(([action, entry]) => [action, entry.key])) as KeyMap;
|
|
35
|
+
for (const [action, key] of Object.entries(overrides)) {
|
|
36
|
+
if (action in map && key.trim()) map[action as LobbyAction] = key.trim().toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
return map;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The action `data` triggers under `map`, if any. */
|
|
42
|
+
export function actionFor(data: string, map: KeyMap): LobbyAction | undefined {
|
|
43
|
+
for (const action of Object.keys(map) as LobbyAction[]) {
|
|
44
|
+
if (matchesKey(data, map[action] as KeyId)) return action;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `alt+k` reads `Alt+K` in hints and help. */
|
|
50
|
+
export function keyLabel(key: string): string {
|
|
51
|
+
return key
|
|
52
|
+
.split("+")
|
|
53
|
+
.map((part) => (part.length === 1 ? part.toUpperCase() : part[0]!.toUpperCase() + part.slice(1)))
|
|
54
|
+
.join("+");
|
|
55
|
+
}
|
package/src/lobby/layout.ts
CHANGED
|
@@ -20,13 +20,18 @@ export type LobbyColor =
|
|
|
20
20
|
| "borderMuted"
|
|
21
21
|
| "toolTitle"
|
|
22
22
|
| "mdHeading"
|
|
23
|
-
| "mdCode"
|
|
23
|
+
| "mdCode"
|
|
24
|
+
| "userMessageText";
|
|
24
25
|
|
|
25
26
|
export interface LobbyTheme {
|
|
26
27
|
fg(color: LobbyColor, text: string): string;
|
|
27
28
|
bold(text: string): string;
|
|
28
29
|
italic?(text: string): string;
|
|
29
|
-
bg?(color: "selectedBg", text: string): string;
|
|
30
|
+
bg?(color: "selectedBg" | "searchMatchBg" | "userMessageBg", text: string): string;
|
|
31
|
+
/** Render Markdown to styled lines; plain wrapping without it (tests). */
|
|
32
|
+
markdown?(text: string, width: number): string[];
|
|
33
|
+
/** Strike text through (an abandoned task's title); plain without it (tests). */
|
|
34
|
+
strike?(text: string): string;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/** Paint only when a theme is present; tests without one get plain text. */
|
|
@@ -42,10 +47,22 @@ export function italic(theme: LobbyTheme | undefined, text: string): string {
|
|
|
42
47
|
return theme?.italic && text ? theme.italic(text) : text;
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
export function strike(theme: LobbyTheme | undefined, text: string): string {
|
|
51
|
+
return theme?.strike && text ? theme.strike(text) : text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `left` and `right` on one line of exactly `width` columns: the left side gives way first. */
|
|
55
|
+
export function spread(left: string, right: string, width: number): string {
|
|
56
|
+
if (!right) return fit(left, width);
|
|
57
|
+
const room = width - visibleWidth(right) - 1;
|
|
58
|
+
if (room < 4) return fit(left, width);
|
|
59
|
+
return `${fit(left, room)} ${right}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
/** Exactly `width` columns: truncated with an ellipsis, or padded with spaces. */
|
|
46
63
|
export function fit(text: string, width: number): string {
|
|
47
64
|
if (width <= 0) return "";
|
|
48
|
-
const cut = visibleWidth(text) > width ? truncateToWidth(text, width) : text;
|
|
65
|
+
const cut = visibleWidth(text) > width ? truncateToWidth(text, width, "…") : text;
|
|
49
66
|
const pad = width - visibleWidth(cut);
|
|
50
67
|
return pad > 0 ? `${cut}${" ".repeat(pad)}` : cut;
|
|
51
68
|
}
|
|
@@ -165,10 +182,197 @@ export function spinner(tick: number): string {
|
|
|
165
182
|
return SPINNER[((tick % SPINNER.length) + SPINNER.length) % SPINNER.length]!;
|
|
166
183
|
}
|
|
167
184
|
|
|
168
|
-
/**
|
|
185
|
+
/** Markdown for plans, reports and replies: the theme's renderer, or headings bold without their `#` and the rest wrapped. */
|
|
169
186
|
export function markdownLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
187
|
+
if (theme?.markdown) return theme.markdown(text, width);
|
|
170
188
|
return text.split("\n").flatMap((line) => {
|
|
171
189
|
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
172
190
|
return wrap(heading ? bold(theme, paint(theme, "mdHeading", heading[1]!)) : line, width);
|
|
173
191
|
});
|
|
174
192
|
}
|
|
193
|
+
|
|
194
|
+
/** Markdown behind a hanging lead (`oracle ▸ `): the first line carries the lead, the rest align under it. */
|
|
195
|
+
export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme): string[] {
|
|
196
|
+
const indent = visibleWidth(lead);
|
|
197
|
+
const body = markdownLines(text, Math.max(1, width - indent), theme);
|
|
198
|
+
if (body.length === 0) return [lead];
|
|
199
|
+
return body.map((line, index) => (index === 0 ? `${lead}${line}` : line ? `${" ".repeat(indent)}${line}` : ""));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface BoxOptions {
|
|
203
|
+
title?: string;
|
|
204
|
+
/** Muted text at the right end of the top border. */
|
|
205
|
+
right?: string;
|
|
206
|
+
/** The box that has the keyboard: its border takes the accent colour. */
|
|
207
|
+
focused?: boolean;
|
|
208
|
+
theme?: LobbyTheme;
|
|
209
|
+
/** Content longer than the box: `total` lines, the first shown at `start`; drawn as a thumb on the right border. */
|
|
210
|
+
scroll?: { total: number; start: number };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A scrollable pane as a render laid it out: its box in body cells, and its content against its rows. */
|
|
214
|
+
export interface PaneBox {
|
|
215
|
+
top: number;
|
|
216
|
+
left: number;
|
|
217
|
+
width: number;
|
|
218
|
+
height: number;
|
|
219
|
+
/** Lines of content, and rows showing them at once. */
|
|
220
|
+
total: number;
|
|
221
|
+
rows: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Scrollable panes by name, filled in by a tab's render so the lobby can clamp offsets and route the wheel. */
|
|
225
|
+
export type PaneLayout = Map<string, PaneBox>;
|
|
226
|
+
|
|
227
|
+
/** Record a pane that sits at `top`/`left` in the body, `width` × `height`, showing `total` lines. */
|
|
228
|
+
export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number): void {
|
|
229
|
+
panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2) });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** First line of a top-anchored pane scrolled `offset` lines down, stopping when its last line shows. */
|
|
233
|
+
export function detailWindow(total: number, rows: number, offset: number): number {
|
|
234
|
+
return Math.max(0, Math.min(offset, total - Math.max(1, rows)));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `12–40/96`: the lines a pane shows out of all it holds. */
|
|
238
|
+
export function position(start: number, rows: number, total: number): string {
|
|
239
|
+
return `${start + 1}–${Math.min(total, start + rows)}/${total}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The rows of a `rows`-tall track that the thumb covers, or undefined when everything fits. */
|
|
243
|
+
export function scrollThumb(total: number, rows: number, start: number): { from: number; to: number } | undefined {
|
|
244
|
+
if (rows <= 0 || total <= rows) return undefined;
|
|
245
|
+
const size = Math.max(1, Math.round((rows * rows) / total));
|
|
246
|
+
const max = total - rows;
|
|
247
|
+
const from = Math.round((Math.min(max, Math.max(0, start)) / max) * (rows - size));
|
|
248
|
+
return { from, to: from + size };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A rounded panel exactly `width` × `height`: the title set into the top
|
|
253
|
+
* border, `content` inside with one column of padding, extra lines cut and
|
|
254
|
+
* missing ones blank. Below 4 columns or 2 rows it degrades to plain lines.
|
|
255
|
+
*/
|
|
256
|
+
export function box(width: number, height: number, content: readonly string[], options: BoxOptions = {}): string[] {
|
|
257
|
+
if (height <= 0) return [];
|
|
258
|
+
if (width < 4 || height < 2) return fill(content, height, Math.max(0, width));
|
|
259
|
+
const { theme, focused } = options;
|
|
260
|
+
const edge = (text: string) => paint(theme, focused ? "borderAccent" : "borderMuted", text);
|
|
261
|
+
const inner = width - 4;
|
|
262
|
+
const title = options.title ? ` ${options.title} ` : "";
|
|
263
|
+
const right = options.right ? ` ${options.right} ` : "";
|
|
264
|
+
let room = width - 2 - visibleWidth(title) - visibleWidth(right);
|
|
265
|
+
const shownRight = room >= 1 ? right : "";
|
|
266
|
+
room = width - 2 - visibleWidth(title) - visibleWidth(shownRight);
|
|
267
|
+
const titled = room >= 1 ? title : fit(title, Math.max(0, width - 3));
|
|
268
|
+
const fillWidth = Math.max(0, width - 2 - visibleWidth(titled) - visibleWidth(shownRight));
|
|
269
|
+
const paintedTitle = titled ? bold(theme, paint(theme, focused ? "accent" : "text", titled)) : "";
|
|
270
|
+
const top = `${edge("╭")}${paintedTitle}${edge("─".repeat(fillWidth))}${shownRight ? paint(theme, "dim", shownRight) : ""}${edge("╮")}`;
|
|
271
|
+
const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
|
|
272
|
+
const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
|
|
273
|
+
const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
|
|
274
|
+
return [top, ...rows, `${edge("╰")}${edge("─".repeat(width - 2))}${edge("╯")}`];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Lay boxes out side by side, each already exactly its width and the same height. */
|
|
278
|
+
export function beside(panes: ReadonlyArray<readonly string[]>, gap = " "): string[] {
|
|
279
|
+
const height = Math.max(0, ...panes.map((pane) => pane.length));
|
|
280
|
+
return Array.from({ length: height }, (_, row) => panes.map((pane) => pane[row] ?? "").join(gap));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Escape sequences (CSI, OSC, APC) that take no columns. */
|
|
284
|
+
const ESCAPE = /\x1b(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|_[^\x07\x1b]*(?:\x07|\x1b\\))/y;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Mark every case-insensitive occurrence of `query` in a styled line with
|
|
288
|
+
* reverse video. Escape sequences are skipped when matching and kept intact,
|
|
289
|
+
* and reverse video is switched off (not reset) so the line's own colours
|
|
290
|
+
* carry on after each match.
|
|
291
|
+
*/
|
|
292
|
+
export function highlight(line: string, query: string): string {
|
|
293
|
+
const needle = query.trim().toLowerCase();
|
|
294
|
+
if (!needle) return line;
|
|
295
|
+
// Visible characters with their raw offsets.
|
|
296
|
+
const chars: Array<{ ch: string; at: number }> = [];
|
|
297
|
+
for (let i = 0; i < line.length; ) {
|
|
298
|
+
ESCAPE.lastIndex = i;
|
|
299
|
+
const escape = ESCAPE.exec(line);
|
|
300
|
+
if (escape) {
|
|
301
|
+
i += escape[0].length;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const point = line.codePointAt(i)!;
|
|
305
|
+
const ch = String.fromCodePoint(point);
|
|
306
|
+
chars.push({ ch, at: i });
|
|
307
|
+
i += ch.length;
|
|
308
|
+
}
|
|
309
|
+
const visible = chars.map((entry) => entry.ch).join("").toLowerCase();
|
|
310
|
+
const starts: Array<[number, number]> = [];
|
|
311
|
+
for (let from = visible.indexOf(needle); from >= 0; from = visible.indexOf(needle, from + needle.length)) starts.push([from, from + needle.length]);
|
|
312
|
+
if (starts.length === 0) return line;
|
|
313
|
+
// Map visible string offsets back to char indexes (they differ only for astral characters).
|
|
314
|
+
const charAt: number[] = [];
|
|
315
|
+
chars.forEach((entry, index) => {
|
|
316
|
+
for (let k = 0; k < entry.ch.length; k++) charAt.push(index);
|
|
317
|
+
});
|
|
318
|
+
let out = "";
|
|
319
|
+
let cursor = 0;
|
|
320
|
+
for (const [start, end] of starts) {
|
|
321
|
+
const from = chars[charAt[start]!]!.at;
|
|
322
|
+
const lastChar = chars[charAt[end - 1]!]!;
|
|
323
|
+
const to = lastChar.at + lastChar.ch.length;
|
|
324
|
+
out += `${line.slice(cursor, from)}\x1b[7m${line.slice(from, to)}\x1b[27m`;
|
|
325
|
+
cursor = to;
|
|
326
|
+
}
|
|
327
|
+
return out + line.slice(cursor);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
|
331
|
+
|
|
332
|
+
/** A horizontal bar `value / max` of `width` cells, with eighth-cell precision at its end. */
|
|
333
|
+
export function bar(value: number, max: number, width: number): string {
|
|
334
|
+
if (width <= 0 || !Number.isFinite(value) || !Number.isFinite(max) || max <= 0 || value <= 0) return "";
|
|
335
|
+
const cells = Math.min(width, (value / max) * width);
|
|
336
|
+
const whole = Math.floor(cells);
|
|
337
|
+
const part = EIGHTHS[Math.round((cells - whole) * 8)] ?? "";
|
|
338
|
+
// A non-zero value always shows at least a sliver.
|
|
339
|
+
return `${"█".repeat(whole)}${part}` || "▏";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A meter `fraction` full: filled cells over a dim track, exactly `width` wide. */
|
|
343
|
+
export function meter(fraction: number, width: number, fillPaint: (text: string) => string, trackPaint: (text: string) => string): string {
|
|
344
|
+
if (width <= 0) return "";
|
|
345
|
+
const safe = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
|
|
346
|
+
const filled = Math.round(safe * width);
|
|
347
|
+
return `${fillPaint("━".repeat(filled))}${trackPaint("─".repeat(width - filled))}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
351
|
+
|
|
352
|
+
/** A sparkline of the last `width` values, scaled between their min and max. */
|
|
353
|
+
export function sparkline(values: readonly number[], width: number): string {
|
|
354
|
+
const shown = values.filter((value) => Number.isFinite(value)).slice(-Math.max(0, width));
|
|
355
|
+
if (shown.length === 0) return "";
|
|
356
|
+
const low = Math.min(...shown);
|
|
357
|
+
const high = Math.max(...shown);
|
|
358
|
+
const span = high - low;
|
|
359
|
+
return shown.map((value) => SPARKS[span === 0 ? 3 : Math.min(7, Math.floor(((value - low) / span) * 8))]).join("");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A part-to-whole bar: each segment's share of `width` cells in its own
|
|
364
|
+
* colour, rounded so the segments always fill the bar exactly.
|
|
365
|
+
*/
|
|
366
|
+
export function stackedBar(segments: ReadonlyArray<{ value: number; paint: (text: string) => string }>, width: number): string {
|
|
367
|
+
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
|
|
368
|
+
if (width <= 0 || total <= 0) return "";
|
|
369
|
+
let used = 0;
|
|
370
|
+
let acc = 0;
|
|
371
|
+
return segments.map((segment) => {
|
|
372
|
+
acc += Math.max(0, segment.value);
|
|
373
|
+
const end = Math.round((acc / total) * width);
|
|
374
|
+
const cells = end - used;
|
|
375
|
+
used = end;
|
|
376
|
+
return cells > 0 ? segment.paint("█".repeat(cells)) : "";
|
|
377
|
+
}).join("");
|
|
378
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown for the lobby: pi's own renderer (headings, emphasis, lists, code
|
|
3
|
+
* with syntax highlighting, quotes, tables) under the session's theme, with
|
|
4
|
+
* heading hashes dropped for a cleaner read. Renders are cached per theme,
|
|
5
|
+
* width and text, since the lobby repaints several times a second.
|
|
6
|
+
*/
|
|
7
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Markdown, stripTerminalSequences, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
export type MarkdownRenderer = (text: string, width: number) => string[];
|
|
11
|
+
|
|
12
|
+
const CACHE_LIMIT = 96;
|
|
13
|
+
|
|
14
|
+
/** A heading line as pi renders it: optional styling, then `#`…`######` and a space. */
|
|
15
|
+
const HEADING_HASHES = /^((?:\x1b\[[0-9;]*m)*)#{1,6} ((?:\x1b\[[0-9;]*m)*)/;
|
|
16
|
+
|
|
17
|
+
/** Drop the `### ` pi keeps in front of headings, and make them bold. */
|
|
18
|
+
export function tidyHeading(line: string, bold: (text: string) => string): string {
|
|
19
|
+
if (!/^#{1,6} /.test(stripTerminalSequences(line))) return line;
|
|
20
|
+
const body = line.replace(HEADING_HASHES, "$1$2");
|
|
21
|
+
return bold(body.trimEnd());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A renderer bound to a theme provider. `theme()` is read on every call, so a
|
|
26
|
+
* theme switch re-renders instead of serving stale colours from the cache.
|
|
27
|
+
*/
|
|
28
|
+
export function createMarkdownRenderer(theme: () => MarkdownTheme = getMarkdownTheme): MarkdownRenderer {
|
|
29
|
+
const caches = new WeakMap<object, Map<string, string[]>>();
|
|
30
|
+
return (text, width) => {
|
|
31
|
+
const current = theme();
|
|
32
|
+
let cache = caches.get(current);
|
|
33
|
+
if (!cache) {
|
|
34
|
+
cache = new Map();
|
|
35
|
+
caches.set(current, cache);
|
|
36
|
+
}
|
|
37
|
+
const key = `${width}\0${text}`;
|
|
38
|
+
const hit = cache.get(key);
|
|
39
|
+
if (hit) return hit;
|
|
40
|
+
const lines = new Markdown(text, 0, 0, current).render(Math.max(1, width)).map((line) => tidyHeading(line.trimEnd(), current.bold));
|
|
41
|
+
// Drop the blank lines pi pads the render with at either end.
|
|
42
|
+
while (lines.length > 0 && !lines[0]!.trim()) lines.shift();
|
|
43
|
+
while (lines.length > 0 && !lines.at(-1)!.trim()) lines.pop();
|
|
44
|
+
cache.set(key, lines);
|
|
45
|
+
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!);
|
|
46
|
+
return lines;
|
|
47
|
+
};
|
|
48
|
+
}
|