@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.
Files changed (71) hide show
  1. package/README.md +70 -79
  2. package/package.json +4 -2
  3. package/prompts/master.md +3 -2
  4. package/prompts/qa.md +33 -11
  5. package/prompts/reviewer.md +5 -2
  6. package/prompts/worker.md +5 -2
  7. package/src/ask/relay.ts +1 -2
  8. package/src/ask/tool.ts +4 -6
  9. package/src/ask/types.ts +16 -0
  10. package/src/ask/web.ts +63 -0
  11. package/src/index.ts +7 -1
  12. package/src/lobby/ask.ts +4 -5
  13. package/src/lobby/blocks.ts +57 -0
  14. package/src/lobby/feed.ts +1 -1
  15. package/src/lobby/host.ts +178 -0
  16. package/src/lobby/keys.ts +10 -41
  17. package/src/lobby/prompt-hub.ts +30 -77
  18. package/src/lobby/prompts.ts +8 -12
  19. package/src/lobby/runtime.ts +49 -310
  20. package/src/lobby/service.ts +54 -134
  21. package/src/lobby/task-rows.ts +28 -0
  22. package/src/pi/commands.ts +14 -20
  23. package/src/pi/model-settings.ts +112 -0
  24. package/src/pi/start-task.ts +1 -1
  25. package/src/pi/tools.ts +6 -7
  26. package/src/pi/ui.ts +0 -9
  27. package/src/schemas/configuration.ts +9 -33
  28. package/src/state/attachments.ts +82 -0
  29. package/src/webui/api/index.ts +17 -15
  30. package/src/webui/api/lobby.ts +4 -3
  31. package/src/webui/api/planner.ts +6 -4
  32. package/src/webui/api/quickfix.ts +4 -3
  33. package/src/webui/api/sessions.ts +9 -7
  34. package/src/webui/api/settings.ts +20 -8
  35. package/src/webui/api/status.ts +4 -15
  36. package/src/webui/api/tasks.ts +11 -15
  37. package/src/webui/command.ts +35 -19
  38. package/src/webui/dev/fake-service.ts +1 -2
  39. package/src/webui/dev/fixtures.ts +2 -5
  40. package/src/webui/events.ts +1 -1
  41. package/src/webui/protocol.ts +22 -11
  42. package/src/webui/server.ts +42 -1
  43. package/src/webui/uploads.ts +68 -0
  44. package/webui/dist/assets/index-B1PnCyl2.css +2 -0
  45. package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
  46. package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  47. package/webui/dist/build.json +142 -110
  48. package/webui/dist/index.html +4 -3
  49. package/src/ask/dialog.ts +0 -193
  50. package/src/ask/image.ts +0 -202
  51. package/src/ask/png.ts +0 -179
  52. package/src/ask/state.ts +0 -175
  53. package/src/ask/view.ts +0 -163
  54. package/src/lobby/layout.ts +0 -496
  55. package/src/lobby/markdown.ts +0 -121
  56. package/src/lobby/mini.ts +0 -167
  57. package/src/lobby/tabs/excalidraw.ts +0 -95
  58. package/src/lobby/tabs/git.ts +0 -162
  59. package/src/lobby/tabs/home.ts +0 -544
  60. package/src/lobby/tabs/issues.ts +0 -73
  61. package/src/lobby/tabs/knowledge.ts +0 -135
  62. package/src/lobby/tabs/metrics.ts +0 -300
  63. package/src/lobby/tabs/plan.ts +0 -262
  64. package/src/lobby/tabs/quickfix.ts +0 -136
  65. package/src/lobby/tabs/tasks.ts +0 -412
  66. package/src/lobby/theme.ts +0 -30
  67. package/src/lobby/view.ts +0 -3018
  68. package/src/pi/settings-ui.ts +0 -708
  69. package/src/width.ts +0 -102
  70. package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
  71. package/webui/dist/assets/index-E6MPcbdC.js +0 -80
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Plain Markdown helpers the lobby shares: model text with escaped line
3
+ * breaks made real, and a reply split into blocks that can be rendered one by
4
+ * one (the page renders them, not the terminal).
5
+ */
6
+
7
+ /**
8
+ * Line breaks a model wrote out as `\n` (text escaped twice on its way here,
9
+ * so a whole plan arrives as one line): made real when the text has no real
10
+ * line break at all, outside inline code, where `\n` is usually meant.
11
+ */
12
+ export function unescapeBreaks(text: string): string {
13
+ if (text.includes("\n") || !text.includes("\\n")) return text;
14
+ return text
15
+ .split(/(`[^`\n]*`)/)
16
+ .map((part, index) => (index % 2 === 1 ? part : part.replace(/(?:\\r)?\\n/g, "\n")))
17
+ .join("");
18
+ }
19
+
20
+ /** A fenced code block's opening or closing line. */
21
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
22
+ /** A list item's first line: after a blank line it continues the list above, so blocks never split there. */
23
+ const LIST_ITEM = /^(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
24
+
25
+ /**
26
+ * Markdown split into blocks that render, one by one, into the same output as
27
+ * the whole (each block's lines, a blank line between blocks, blocks that
28
+ * render to nothing left out). A split falls on a blank line outside code
29
+ * fences that is followed by an unindented line that does not start a list
30
+ * item — the only places where one block can never run into the next. A
31
+ * streaming reply can then keep its finished blocks rendered and render only
32
+ * the one still being written.
33
+ */
34
+ export function markdownBlocks(text: string): string[] {
35
+ const lines = text.split("\n");
36
+ const blocks: string[] = [];
37
+ let fence: string | undefined;
38
+ let start = 0;
39
+ for (let index = 0; index < lines.length; index += 1) {
40
+ const line = lines[index]!;
41
+ const marker = FENCE.exec(line)?.[1];
42
+ if (marker) {
43
+ if (!fence) fence = marker;
44
+ else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
45
+ continue;
46
+ }
47
+ if (fence || line.trim() || index === 0) continue;
48
+ const next = lines[index + 1];
49
+ if (next === undefined || !/^\S/.test(next) || LIST_ITEM.test(next)) continue;
50
+ const block = lines.slice(start, index).join("\n");
51
+ if (block.trim()) blocks.push(block);
52
+ start = index + 1;
53
+ }
54
+ const last = lines.slice(start).join("\n");
55
+ if (last.trim()) blocks.push(last);
56
+ return blocks;
57
+ }
package/src/lobby/feed.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { AgentRun } from "../schemas/findings.ts";
9
9
  import { agentName } from "../pi/run-summary.ts";
10
- import { markdownBlocks } from "./markdown.ts";
10
+ import { markdownBlocks } from "./blocks.ts";
11
11
 
12
12
  export type ActivityKind = "info" | "success" | "warning" | "error";
13
13
 
@@ -0,0 +1,178 @@
1
+ /**
2
+ * What the lobby's backend offers: the service interface the web server
3
+ * drives, and the shapes of the sessions it lists. Pure types (the
4
+ * implementation is `service.ts`).
5
+ */
6
+ import type { Task } from "../schemas/task.ts";
7
+ import type { AgentRun } from "../schemas/findings.ts";
8
+ import type { PlannedTask } from "../state/backlog.ts";
9
+ import type { PlanComment } from "../state/comments.ts";
10
+ import type { MetricRecord } from "../state/metrics.ts";
11
+ import type { BotLobbyConfig, LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
12
+ import type { ChatEntry, LobbyFeed } from "./feed.ts";
13
+ import type { BackgroundSession } from "./sessions.ts";
14
+ import type { QuickFixQueue } from "./quickfix.ts";
15
+ import type { PlannerSeed, PlanningSession } from "./planner.ts";
16
+ import type { IssuesState } from "./issues.ts";
17
+ import type { PullsState } from "./pulls.ts";
18
+ import type { PullReviews } from "./pr-review.ts";
19
+ import type { KnowledgeBook } from "./knowledge.ts";
20
+ import type { ExcalidrawBook, ExcalidrawSession } from "../excalidraw/sessions.ts";
21
+ import type { WorkspaceInfo } from "../execution/workspace.ts";
22
+
23
+ export { TAB_IDS, TAB_LABELS, visibleTabs } from "./prompts.ts";
24
+ export type { TabId } from "./prompts.ts";
25
+
26
+ /** What a checked Excalidraw session reported. */
27
+ export interface SessionCheck {
28
+ ok: boolean;
29
+ text: string;
30
+ }
31
+
32
+ /**
33
+ * Which session the Lobby tab shows and talks to: this window, one it started
34
+ * in the background, one running in another terminal, or a task whose
35
+ * session is not running at all.
36
+ */
37
+ export type SessionView =
38
+ | { kind: "here" }
39
+ | { kind: "background"; key: string }
40
+ | { kind: "other"; sessionId: string }
41
+ | { kind: "idle"; taskId: string };
42
+
43
+ export type SessionWhere = "this window" | "background" | "other terminal" | "not running";
44
+
45
+ /** One row of the session browser. */
46
+ export interface SessionEntry {
47
+ view: SessionView;
48
+ name: string;
49
+ where: SessionWhere;
50
+ task?: Task;
51
+ /** What it is doing: working, idle, starting, ended, or its task's state. */
52
+ status: string;
53
+ auto: boolean;
54
+ /** Questions it waits on you for. */
55
+ waiting: number;
56
+ /** The pi session it is (absent for a task no session ever owned). */
57
+ sessionId?: string;
58
+ /** The process running it, for sessions in other terminals. */
59
+ pid?: number;
60
+ }
61
+
62
+ /** A session running in this project, as its heartbeat tells (see state/presence.ts). */
63
+ export interface LiveSession {
64
+ sessionId: string;
65
+ pid: number;
66
+ name?: string;
67
+ taskId?: string;
68
+ mode: string;
69
+ }
70
+
71
+ /** What `switchTo` needs: the session to run in this window, and the background process to stop first. */
72
+ export interface SwitchTarget {
73
+ name: string;
74
+ sessionId?: string;
75
+ background?: BackgroundSession;
76
+ /** A task no session owns: this window claims it instead. */
77
+ claimTaskId?: string;
78
+ }
79
+
80
+ /** One model Pi offers, for the settings page's picker: `provider/id`, its label and thinking levels. */
81
+ export interface ModelChoice {
82
+ id: string;
83
+ label: string;
84
+ thinkingLevels: string[];
85
+ }
86
+
87
+ /** Everything the web server needs from pi and bot-lobby. */
88
+ export interface LobbyService {
89
+ sessionId(): string | undefined;
90
+ /** This session's active task and its runs, as the lobby reads them. */
91
+ zen(): { task?: Task; runs: readonly AgentRun[] };
92
+ feed: LobbyFeed;
93
+ masterBusy(): boolean;
94
+ tasks(): Task[];
95
+ plans(): PlannedTask[];
96
+ comments(taskId: string): PlanComment[];
97
+ metrics(): MetricRecord[];
98
+ /** Classifier calls (kind `classifier`), kept out of the agent tables. */
99
+ classifierMetrics?(): MetricRecord[];
100
+ /** Send text to the oracle, or start a task when none is active; returns a notice. */
101
+ toOracle(text: string): string | undefined;
102
+ comment(taskId: string, text: string): string;
103
+ startPlanned(plan: PlannedTask): string;
104
+ discardPlan(id: string): void;
105
+ abortMaster(): void;
106
+ quickfix: QuickFixQueue;
107
+ planner(): PlanningSession | undefined;
108
+ /** Start a planning session (replacing any other) with these seats on the panel. */
109
+ newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
110
+ /** The oracle puts the panel's open questions to the user, one questionnaire at a time; returns a notice. */
111
+ answerPanel(): Promise<string>;
112
+ /** Save the plan as a pending task, offering to split a long one into several first; returns a notice. */
113
+ savePlan(): Promise<string>;
114
+ /** The seats a new session starts with, from settings. */
115
+ defaultPanel(): readonly PanelMember[];
116
+ /** Planning rounds before the oracle finalizes alone (`lobby.maxPlanningRounds`); 0 = unlimited. */
117
+ planningRounds?(): number;
118
+ /** `model · thinking` a panel seat runs on. */
119
+ seatLabel(member: PanelMember): string;
120
+ issues: IssuesState;
121
+ /** The Git tab's pull requests, read through `gh`. */
122
+ pulls: PullsState;
123
+ /** Reviews of pull requests by an agent, and Jev's reads of them. */
124
+ reviews: PullReviews;
125
+ /** Every agent's knowledge files: read, edited, commented on. */
126
+ knowledge: KnowledgeBook;
127
+ /** The shared Excalidraw sessions (five at most) and which agents each is assigned to. */
128
+ excalidraw: ExcalidrawBook;
129
+ /** Join a session's room for a moment and report what is there. */
130
+ checkExcalidraw(session: ExcalidrawSession): Promise<SessionCheck>;
131
+ /** The effective config, never a secret; the web settings page reads it. */
132
+ config?(): BotLobbyConfig;
133
+ /** Save a whole config, the way the settings menu does; the web settings page writes it. */
134
+ saveConfig?(config: BotLobbyConfig): void;
135
+ /** Reload cached config (keys, panes) after a settings change. */
136
+ configChanged?(): void;
137
+ /** The models Pi offers, for the settings page's picker. */
138
+ models?(): ModelChoice[];
139
+ /** The model this window's session runs on (`provider/id`), when it has one. */
140
+ sessionModel?(): string | undefined;
141
+ /** The Issues tab is switched on (`lobby.issues`). */
142
+ issuesEnabled(): boolean;
143
+ /** This window's pi session name, when it has one. */
144
+ sessionName(): string | undefined;
145
+ /** Background sessions this window started, oldest first. */
146
+ sessions(): readonly BackgroundSession[];
147
+ /** Start a task in a new background session, named after the task; the session, or why not. */
148
+ startSession(start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string;
149
+ /** Auto mode for any task, whichever session drives it. */
150
+ isAuto(taskId: string): boolean;
151
+ setAuto(taskId: string, on: boolean): void;
152
+ /** Leave a message for a task's oracle, delivered by whichever session drives it (now or once one resumes it); returns a notice. */
153
+ sendToTask(taskId: string, text: string): string;
154
+ /** Leave a message for a session running in another terminal; it delivers it within seconds. Returns a notice. */
155
+ sendToSession(sessionId: string, text: string): string;
156
+ /** Sessions running in this project, other than this window's, from their heartbeats. */
157
+ liveSessions(): readonly LiveSession[];
158
+ /** Run another session in this window (stopping its background process first); resolves to a notice. */
159
+ switchTo(target: SwitchTarget): Promise<string>;
160
+ /** Archived tasks, most recently archived first. */
161
+ archivedTasks(): Task[];
162
+ /** Archive, restore or delete a task; each returns a notice. */
163
+ archiveTask(taskId: string): string;
164
+ restoreTask(taskId: string): string;
165
+ deleteTask(taskId: string, where: "list" | "archive"): string;
166
+ /** Another session's conversation, read from its saved session file (its newest messages). */
167
+ sessionChat(sessionId: string): ChatEntry[];
168
+ /** Whether another session has messages earlier than `sessionChat` returns. */
169
+ hasOlderChat(sessionId: string): boolean;
170
+ /** A session's whole conversation, oldest first: this window's (no id) or another's; loaded only while scrolled back to it. */
171
+ chatHistory(sessionId?: string): readonly ChatEntry[];
172
+ profileLabel(kind: LobbyAgentKind): string;
173
+ /** The repository (or folder) and branch the title shows; the folder's name alone until git has answered. */
174
+ workspace?(): WorkspaceInfo;
175
+ /** Ask for the workspace to be read again (the branch may have changed); it arrives through `workspace()`. */
176
+ refreshWorkspace?(): void;
177
+ }
178
+
package/src/lobby/keys.ts CHANGED
@@ -1,27 +1,17 @@
1
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.
2
+ * The web lobby's shortcuts in one table: every action has a default key, a
3
+ * line for the help overlay, and can be rebound under `lobby.keys` in the
4
+ * config (`{ "search": "alt+f" }`). Chords are written `alt+k`,
5
+ * `ctrl+f`, `shift+tab`; the page matches them on the physical key.
6
6
  */
7
- import { Key, matchesKey, type KeyId } from "@earendil-works/pi-tui";
8
7
 
9
8
  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: "browse sessions: view one, message it, or switch this window to it" },
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
- toggleConversation: { key: "alt+c", help: "show or hide the conversation" },
21
- toggleActivity: { key: "alt+a", help: "show or hide the activity log" },
22
- toggleThinking: { key: "alt+k", help: "show or hide thinking" },
23
- scrollUp: { key: "pageUp", help: "scroll up a page" },
24
- scrollDown: { key: "pageDown", help: "scroll down a page" },
9
+ help: { key: "alt+h", help: "show or hide this list" },
10
+ settings: { key: "alt+s", help: "settings: each agent's model and effort" },
11
+ savePlan: { key: "ctrl+s", help: "save the plan from the Plan tab" },
12
+ sessions: { key: "alt+o", help: "browse and message your sessions" },
13
+ nextTab: { key: "alt+]", help: "next tab" },
14
+ prevTab: { key: "alt+[", help: "previous tab" },
25
15
  } as const;
26
16
 
27
17
  export type LobbyAction = keyof typeof LOBBY_ACTIONS;
@@ -37,27 +27,6 @@ export function keyMap(overrides: Readonly<Record<string, string>> = {}): KeyMap
37
27
  return map;
38
28
  }
39
29
 
40
- /** Alt+digit jumps to the tab at `index`; the same key the TUI matches. */
41
- export function tabJumpKey(index: number): KeyId {
42
- return Key.alt(String(index + 1) as "1");
43
- }
44
-
45
- /** `keyMap` with the web-only tab cycling defaults; user overrides win. */
46
- export function webKeyMap(overrides: Readonly<Record<string, string>> = {}): KeyMap {
47
- const map = keyMap(overrides);
48
- if (!overrides.nextTab?.trim()) map.nextTab = "alt+]";
49
- if (!overrides.prevTab?.trim()) map.prevTab = "alt+[";
50
- return map;
51
- }
52
-
53
- /** The action `data` triggers under `map`, if any. */
54
- export function actionFor(data: string, map: KeyMap): LobbyAction | undefined {
55
- for (const action of Object.keys(map) as LobbyAction[]) {
56
- if (matchesKey(data, map[action] as KeyId)) return action;
57
- }
58
- return undefined;
59
- }
60
-
61
30
  /** `alt+k` reads `Alt+K` in hints and help. */
62
31
  export function keyLabel(key: string): string {
63
32
  return key
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Bot-lobby's questions answerable from several surfaces, with the first
3
- * answer winning. A surface registers (`terminal` today; the web page later);
4
- * `open` shows the prompt on every surface, `answer` settles it once, and a
5
- * cancelled caller's `signal` withdraws every surface. The terminal drives
6
- * its prompts through `run`, which wraps today's code unchanged.
2
+ * Bot-lobby's questions, answered in the web page. `open` puts a prompt in
3
+ * the page's queue, `answer` settles it once (a late answer is refused), and
4
+ * `ask` is what the agents' code awaits: it opens a prompt and resolves with
5
+ * the answer, or with how it was put away. A cancelled caller's `signal`
6
+ * withdraws the prompt.
7
7
  */
8
8
  import { lobbyTopics } from "./topics.ts";
9
9
 
10
10
  export type PromptKind = "questionnaire" | "choose" | "confirm" | "text" | "sessionDialog";
11
11
 
12
- /** A pending question in the shape the later web API lists. */
12
+ /** A pending question in the shape the web API lists. */
13
13
  export interface WebPrompt {
14
14
  id: string;
15
15
  kind: PromptKind;
@@ -18,13 +18,6 @@ export interface WebPrompt {
18
18
  payload: unknown;
19
19
  }
20
20
 
21
- /** One surface a prompt shows on; `withdraw` takes it back off screen. */
22
- export interface PromptSurface {
23
- name: string;
24
- show(prompt: WebPrompt): void;
25
- withdraw(id: string): void;
26
- }
27
-
28
21
  interface Pending {
29
22
  prompt: WebPrompt;
30
23
  }
@@ -42,30 +35,13 @@ function nextId(seq: number): string {
42
35
  return `p${seq}-${Date.now().toString(36)}`;
43
36
  }
44
37
 
45
- function showOn(surfaces: Iterable<PromptSurface>, prompt: WebPrompt): void {
46
- for (const surface of surfaces) surface.show(prompt);
47
- }
48
-
49
- function withdrawFrom(surfaces: Iterable<PromptSurface>, id: string): void {
50
- for (const surface of surfaces) surface.withdraw(id);
51
- }
52
-
53
- /** Questions from several surfaces; the first answer wins. */
38
+ /** The questions waiting for the page; the first answer wins. */
54
39
  export class PromptHub {
55
40
  private seq = 0;
56
41
  private readonly pendingMap = new Map<string, Pending>();
57
- private readonly surfaces = new Map<string, PromptSurface>();
42
+ private readonly waiters = new Map<string, (outcome: Outcome) => void>();
58
43
  private readonly outcomes: Outcome[] = [];
59
44
 
60
- /** Put a surface on show duty; returns its unregister function. */
61
- registerSurface(surface: PromptSurface): () => void {
62
- this.surfaces.set(surface.name, surface);
63
- for (const entry of this.pendingMap.values()) surface.show(entry.prompt);
64
- return () => {
65
- this.surfaces.delete(surface.name);
66
- };
67
- }
68
-
69
45
  /** The questions still waiting for an answer, oldest first. */
70
46
  pending(): WebPrompt[] {
71
47
  return [...this.pendingMap.values()].map((entry) => entry.prompt);
@@ -76,14 +52,13 @@ export class PromptHub {
76
52
  this.seq += 1;
77
53
  const prompt: WebPrompt = { id: nextId(this.seq), kind, createdAt: Date.now(), from, payload };
78
54
  this.pendingMap.set(prompt.id, { prompt });
79
- showOn(this.surfaces.values(), prompt);
80
55
  lobbyTopics.bump("prompts");
81
56
  return prompt;
82
57
  }
83
58
 
84
59
  /**
85
60
  * Settle `id` with `value`. True when this call won; false when the prompt
86
- * is already settled or unknown (the later web API answers 409 then).
61
+ * is already settled or unknown (the web API answers 409 then).
87
62
  */
88
63
  answer(id: string, value: unknown): boolean {
89
64
  if (!this.pendingMap.has(id)) return false;
@@ -105,57 +80,35 @@ export class PromptHub {
105
80
 
106
81
  private settle(id: string, value: unknown, how: Outcome["how"]): void {
107
82
  this.pendingMap.delete(id);
108
- withdrawFrom(this.surfaces.values(), id);
109
- this.outcomes.push({ id, value, how });
83
+ const outcome: Outcome = { id, value, how };
84
+ this.outcomes.push(outcome);
110
85
  if (this.outcomes.length > MAX_OUTCOMES) this.outcomes.splice(0, this.outcomes.length - MAX_OUTCOMES);
86
+ const waiter = this.waiters.get(id);
87
+ this.waiters.delete(id);
88
+ waiter?.(outcome);
111
89
  lobbyTopics.bump("prompts");
112
90
  }
113
91
 
114
92
  /**
115
- * Run today's terminal code for a prompt: it shows on every surface, the
116
- * terminal's `show` answers it, and a cancelled `signal` withdraws them
117
- * all. With only the terminal registered this is exactly today's behaviour.
93
+ * Open a prompt and wait for its outcome: the page's answer, or `dismissed`
94
+ * / `cancelled` when it is put away or `signal` aborts first.
118
95
  */
119
- async run<T>(kind: PromptKind, from: string, payload: unknown, show: (prompt: WebPrompt) => Promise<T>, options?: { signal?: AbortSignal }): Promise<T> {
120
- const prompt = this.open(kind, from, payload);
96
+ ask(kind: PromptKind, from: string, payload: unknown, options?: { signal?: AbortSignal }): Promise<{ how: Outcome["how"]; value: unknown }> {
121
97
  const signal = options?.signal;
122
- if (signal?.aborted) return this.cancelled(prompt.id, show, prompt);
123
- const onAbort = () => this.dismiss(prompt.id);
124
- signal?.addEventListener("abort", onAbort, { once: true });
125
- try {
126
- const value = await show(prompt);
127
- return this.claim(prompt.id, value);
128
- } finally {
129
- signal?.removeEventListener("abort", onAbort);
130
- }
131
- }
132
-
133
- /** The caller was already cancelled: withdraw every surface, keep today's result. */
134
- private async cancelled<T>(id: string, show: (prompt: WebPrompt) => Promise<T>, prompt: WebPrompt): Promise<T> {
135
- this.pendingMap.delete(id);
136
- withdrawFrom(this.surfaces.values(), id);
137
- lobbyTopics.bump("prompts");
138
- return this.remember(id, await show(prompt));
139
- }
140
-
141
- /** Claim the win for `value`, unless another surface answered first. */
142
- private claim<T>(id: string, value: T): T {
143
- if (!this.pendingMap.has(id)) return this.remembered(id, value);
144
- this.settle(id, value, "answered");
145
- return value;
146
- }
147
-
148
- private remember<T>(id: string, value: T): T {
149
- if (!this.outcome(id)) this.outcomes.push({ id, value, how: "cancelled" });
150
- lobbyTopics.bump("prompts");
151
- return value;
152
- }
153
-
154
- private remembered<T>(id: string, fallback: T): T {
155
- const outcome = this.outcome(id);
156
- return (outcome?.value ?? fallback) as T;
98
+ if (signal?.aborted) return Promise.resolve({ how: "cancelled", value: undefined });
99
+ const prompt = this.open(kind, from, payload);
100
+ return new Promise((resolve) => {
101
+ const onAbort = () => {
102
+ if (this.pendingMap.has(prompt.id)) this.settle(prompt.id, undefined, "cancelled");
103
+ };
104
+ this.waiters.set(prompt.id, (outcome) => {
105
+ signal?.removeEventListener("abort", onAbort);
106
+ resolve({ how: outcome.how, value: outcome.value });
107
+ });
108
+ signal?.addEventListener("abort", onAbort, { once: true });
109
+ });
157
110
  }
158
111
  }
159
112
 
160
- /** The process-wide hub; the terminal surface registers at lobby start. */
113
+ /** The process-wide hub. */
161
114
  export const promptHub = new PromptHub();
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Strings the terminal lobby and the web lobby share: tab metadata and the
3
- * short status lines shown while a question is answered elsewhere. Pure on
4
- * purpose (no `node:*`, no `@earendil-works/pi-tui`): the web page imports
5
- * this file directly.
2
+ * Strings the server and the page share: tab metadata and the short line
3
+ * shown when a question was answered elsewhere. Pure on purpose (no
4
+ * `node:*`): the web page imports this file directly.
6
5
  */
7
6
 
8
- export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics", "git", "knowledge", "excalidraw"] as const;
7
+ export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "excalidraw", "git", "knowledge", "metrics"] as const;
9
8
  export type TabId = (typeof TAB_IDS)[number];
10
9
 
11
10
  export const TAB_LABELS: Record<TabId, string> = {
@@ -25,11 +24,8 @@ export function visibleTabs(issues: boolean): TabId[] {
25
24
  return TAB_IDS.filter((tab) => issues || tab !== "issues");
26
25
  }
27
26
 
28
- /** A question answered in the terminal while the web lobby watched it. */
29
- export const ANSWERED_IN_TERMINAL = "already answered in the terminal";
27
+ /** A question answered in another window of the page (or put away) before this one's answer arrived. */
28
+ export const ANSWERED_ELSEWHERE = "already answered in another window";
30
29
 
31
- /** Pi itself is asking in the terminal; the web lobby waits. */
32
- export const PI_ASKING_IN_TERMINAL = "pi is asking in the terminal";
33
-
34
- /** The window is too narrow to show the lobby. */
35
- export const NARROW_WINDOW = "the window is too narrow to show the lobby";
30
+ /** Opens the list of files a message carries (see src/webui/uploads.ts); the page shows them as thumbnails and chips. */
31
+ export const ATTACHMENTS_MARK = "Attached files:";