@a-t-h-i/bot-lobby 0.6.8 → 0.6.9

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.
@@ -45,6 +45,8 @@ import { execCommand, IssuesState } from "./issues.ts";
45
45
  import { PullsState } from "./pulls.ts";
46
46
  import { PullReviews } from "./pr-review.ts";
47
47
  import { KnowledgeBook } from "./knowledge.ts";
48
+ import { ExcalidrawBook } from "../excalidraw/sessions.ts";
49
+ import { checkSession } from "../excalidraw/check.ts";
48
50
  import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
49
51
  import { lobbyTheme } from "./theme.ts";
50
52
  import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
@@ -70,6 +72,7 @@ interface Runtime {
70
72
  pulls: PullsState;
71
73
  reviews: PullReviews;
72
74
  knowledge: KnowledgeBook;
75
+ excalidraw: ExcalidrawBook;
73
76
  unsubscribeFeed?: () => void;
74
77
  /** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
75
78
  asker?: Asker;
@@ -624,6 +627,8 @@ function host(state: Runtime, tui: TUI): LobbyHost {
624
627
  pulls: state.pulls,
625
628
  reviews: state.reviews,
626
629
  knowledge: state.knowledge,
630
+ excalidraw: state.excalidraw,
631
+ checkExcalidraw: (session) => checkSession(session.link, session.name),
627
632
  editText: (title, text) => editText(state, title, text),
628
633
  profileLabel: (kind) => {
629
634
  const profile = lobbyProfile(state, kind);
@@ -848,6 +853,7 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
848
853
  backups: () => loadConfig().knowledge.backupCount,
849
854
  sessionId: () => ctx.sessionManager.getSessionId(),
850
855
  }),
856
+ excalidraw: new ExcalidrawBook({ root }),
851
857
  };
852
858
  state.reviews = new PullReviews({
853
859
  cwd: ctx.cwd,
@@ -0,0 +1,95 @@
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/view.ts CHANGED
@@ -32,6 +32,8 @@ import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
32
32
  import { renderIssues } from "./tabs/issues.ts";
33
33
  import { renderGit } from "./tabs/git.ts";
34
34
  import { renderKnowledge, type KnowledgeLayout } from "./tabs/knowledge.ts";
35
+ import { renderExcalidraw, type SessionCheck } from "./tabs/excalidraw.ts";
36
+ import { EXCALIDRAW_AGENTS, MAX_SESSIONS, type ExcalidrawBook, type ExcalidrawSession } from "../excalidraw/sessions.ts";
35
37
  import type { EntryRef, KnowledgeBook, KnowledgeFileInfo, KnowledgeView } from "./knowledge.ts";
36
38
  import type { PullsState } from "./pulls.ts";
37
39
  import type { PullReviews } from "./pr-review.ts";
@@ -41,7 +43,7 @@ import { filterRecords, renderMetrics } from "./tabs/metrics.ts";
41
43
  /** The mark before a branch name in the title (shared with the task details). */
42
44
  export { BRANCH_GLYPH };
43
45
 
44
- export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics", "git", "knowledge"] as const;
46
+ export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics", "git", "knowledge", "excalidraw"] as const;
45
47
  export type TabId = (typeof TAB_IDS)[number];
46
48
 
47
49
  export const TAB_LABELS: Record<TabId, string> = {
@@ -53,6 +55,7 @@ export const TAB_LABELS: Record<TabId, string> = {
53
55
  metrics: "Metrics",
54
56
  git: "Git",
55
57
  knowledge: "Knowledge",
58
+ excalidraw: "Excalidraw",
56
59
  };
57
60
 
58
61
  /** The tabs on show: Issues only while `lobby.issues` switches it on. */
@@ -175,6 +178,10 @@ export interface LobbyHost {
175
178
  reviews: PullReviews;
176
179
  /** Every agent's knowledge files: read, edited, commented on. */
177
180
  knowledge: KnowledgeBook;
181
+ /** The shared Excalidraw sessions (five at most) and which agents each is assigned to. */
182
+ excalidraw: ExcalidrawBook;
183
+ /** Join a session's room for a moment and report what is there. */
184
+ checkExcalidraw(session: ExcalidrawSession): Promise<SessionCheck>;
178
185
  /** Edit a text in pi's multi-line editor (the lobby steps aside meanwhile); the new text, or undefined when cancelled. */
179
186
  editText(title: string, text: string): Promise<string | undefined>;
180
187
  /** The Issues tab is switched on (`lobby.issues`). */
@@ -380,13 +387,21 @@ export class LobbyView implements Component, Focusable {
380
387
  private readonly kLayout: KnowledgeLayout = { offset: 0, total: 0, rows: 0 };
381
388
  /** The prompt is writing an edit of the picked entry, a note on it, or a new entry after it. */
382
389
  private kDraft: { kind: "edit" | "comment" | "add"; entry?: EntryRef } | undefined;
390
+ private xSelected = 0;
391
+ private xFocus: "list" | "detail" = "list";
392
+ /** The agent under the cursor in the picked session's checklist. */
393
+ private xAgent = 0;
394
+ /** The prompt is taking a link to add (with an optional name), the name of a new room, or a new name for the picked session. */
395
+ private xDraft: "add" | "new" | "rename" | undefined;
396
+ private readonly xChecks = new Map<string, SessionCheck>();
397
+ private readonly xChecking = new Set<string>();
383
398
  private metricsSelected = 0;
384
399
  private metricsBy: GroupBy = "model";
385
400
  private metricsSort: SortKey = "runs";
386
401
 
387
402
  /** The Tasks tab lists archived tasks too (v). */
388
403
  showArchived = false;
389
- private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; knowledge: { files: KnowledgeFileInfo[]; view?: KnowledgeView }; at: number } = {
404
+ private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; knowledge: { files: KnowledgeFileInfo[]; view?: KnowledgeView }; excalidraw: ExcalidrawSession[]; at: number } = {
390
405
  tasks: [],
391
406
  plans: [],
392
407
  archived: [],
@@ -394,6 +409,7 @@ export class LobbyView implements Component, Focusable {
394
409
  metrics: [],
395
410
  classifierMetrics: [],
396
411
  knowledge: { files: [] },
412
+ excalidraw: [],
397
413
  at: 0,
398
414
  };
399
415
 
@@ -486,6 +502,7 @@ export class LobbyView implements Component, Focusable {
486
502
  metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
487
503
  classifierMetrics: this.tab === "metrics" ? this.host.classifierMetrics?.() ?? [] : this.data.classifierMetrics,
488
504
  knowledge: this.tab === "knowledge" ? this.readKnowledge() : this.data.knowledge,
505
+ excalidraw: this.host.excalidraw.list(),
489
506
  at: now,
490
507
  };
491
508
  if (this.tab === "tasks") {
@@ -535,8 +552,9 @@ export class LobbyView implements Component, Focusable {
535
552
  if (tab === this.tab) return;
536
553
  if (!this.tabs().includes(tab)) return this.say(`the ${TAB_LABELS[tab]} tab is off — set lobby.${tab} to true in the config to bring it back`, "warning");
537
554
  // An entry being edited is not a draft to come back to: its text was only lent to the prompt.
538
- if (this.kDraft) {
555
+ if (this.kDraft || this.xDraft) {
539
556
  this.kDraft = undefined;
557
+ this.xDraft = undefined;
540
558
  this.editor.setText("");
541
559
  }
542
560
  this.drafts[this.tab] = this.editor.getText();
@@ -756,8 +774,9 @@ export class LobbyView implements Component, Focusable {
756
774
  this.commentTarget = undefined;
757
775
  this.issueDraft = false;
758
776
  this.gitFocusDraft = false;
759
- if (this.kDraft) {
777
+ if (this.kDraft || this.xDraft) {
760
778
  this.kDraft = undefined;
779
+ this.xDraft = undefined;
761
780
  this.editor.setText("");
762
781
  }
763
782
  this.newSession = false;
@@ -786,7 +805,7 @@ export class LobbyView implements Component, Focusable {
786
805
  if (matchesKey(data, Key.home) || matchesKey(data, Key.end)) return this.scrollToEdge(matchesKey(data, Key.home));
787
806
  const tabs = this.tabs();
788
807
  if (!PROMPT_FIRST.has(this.tab) && /^[1-9]$/.test(data) && Number(data) <= tabs.length) return this.setTab(tabs[Number(data) - 1]!);
789
- if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues" && this.tab !== "git" && this.tab !== "knowledge") return this.setMode("type");
808
+ if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues" && this.tab !== "git" && this.tab !== "knowledge" && this.tab !== "excalidraw") return this.setMode("type");
790
809
  if (this.tabCommand(data)) return;
791
810
  const printable = isPrintable(data);
792
811
  if (printable && PROMPT_FIRST.has(this.tab)) {
@@ -812,6 +831,8 @@ export class LobbyView implements Component, Focusable {
812
831
  return this.gitFocus;
813
832
  case "knowledge":
814
833
  return this.kFocus;
834
+ case "excalidraw":
835
+ return this.xFocus;
815
836
  case "metrics":
816
837
  return "table";
817
838
  }
@@ -864,6 +885,9 @@ export class LobbyView implements Component, Focusable {
864
885
  case "knowledge":
865
886
  this.kFocus = direction < 0 ? "list" : "detail";
866
887
  return;
888
+ case "excalidraw":
889
+ this.xFocus = direction < 0 ? "list" : "detail";
890
+ return;
867
891
  case "metrics":
868
892
  return;
869
893
  }
@@ -899,11 +923,25 @@ export class LobbyView implements Component, Focusable {
899
923
  return;
900
924
  case "knowledge":
901
925
  return this.moveKnowledge(delta);
926
+ case "excalidraw":
927
+ return this.moveExcalidraw(delta);
902
928
  case "metrics":
903
929
  return this.scrollPane("table", delta);
904
930
  }
905
931
  }
906
932
 
933
+ /** Arrows on the Excalidraw tab: pick a session, or (in the checklist) an agent. */
934
+ private moveExcalidraw(delta: number): void {
935
+ const step = Math.sign(delta);
936
+ if (this.xFocus === "list") {
937
+ const next = Math.max(0, Math.min(this.data.excalidraw.length - 1, this.xSelected + step));
938
+ if (next !== this.xSelected) this.xAgent = 0;
939
+ this.xSelected = next;
940
+ return;
941
+ }
942
+ this.xAgent = Math.max(0, Math.min(EXCALIDRAW_AGENTS.length - 1, this.xAgent + step));
943
+ }
944
+
907
945
  /** Arrows and pages on the Knowledge tab: pick a file, or pick an entry of the open one (a page moves several). */
908
946
  private moveKnowledge(delta: number): void {
909
947
  const knowledge = this.data.knowledge;
@@ -1169,6 +1207,8 @@ export class LobbyView implements Component, Focusable {
1169
1207
  return this.gitCommand(data, enter, escape);
1170
1208
  case "knowledge":
1171
1209
  return this.knowledgeCommand(data, enter, escape);
1210
+ case "excalidraw":
1211
+ return this.excalidrawCommand(data, enter, escape);
1172
1212
  case "metrics":
1173
1213
  if (data === "g") return (this.metricsBy = this.metricsBy === "model" ? "model-kind" : "model", this.metricsSelected = 0), true;
1174
1214
  if (data === "s") return (this.metricsSort = SORT_KEYS[(SORT_KEYS.indexOf(this.metricsSort) + 1) % SORT_KEYS.length]!), true;
@@ -1853,6 +1893,110 @@ export class LobbyView implements Component, Focusable {
1853
1893
  return true;
1854
1894
  }
1855
1895
 
1896
+ /** Assign the picked session to the agent under the cursor, or take it back. */
1897
+ private toggleExcalidrawAgent(session: ExcalidrawSession): void {
1898
+ this.say(this.host.excalidraw.toggleAgent(session.id, EXCALIDRAW_AGENTS[this.xAgent]!));
1899
+ this.data.excalidraw = this.host.excalidraw.list();
1900
+ }
1901
+
1902
+ private excalidrawCommand(data: string, enter: boolean, escape: boolean): boolean {
1903
+ const sessions = this.data.excalidraw;
1904
+ const session = sessions[this.xSelected];
1905
+ if (data === "a" || data === "n") {
1906
+ if (sessions.length >= MAX_SESSIONS) {
1907
+ this.say(`all ${MAX_SESSIONS} sessions are in use — remove one first (d d)`, "warning");
1908
+ return true;
1909
+ }
1910
+ this.xDraft = data === "a" ? "add" : "new";
1911
+ this.xFocus = "list";
1912
+ this.setMode("type");
1913
+ return true;
1914
+ }
1915
+ if (escape) return (this.xFocus = "list"), true;
1916
+ if (!session) return false;
1917
+ if (enter || (data === " " && this.xFocus === "detail")) {
1918
+ if (this.xFocus === "list") this.xFocus = "detail";
1919
+ else this.toggleExcalidrawAgent(session);
1920
+ return true;
1921
+ }
1922
+ if (data === "*") {
1923
+ this.say(this.host.excalidraw.toggleAll(session.id));
1924
+ this.data.excalidraw = this.host.excalidraw.list();
1925
+ return true;
1926
+ }
1927
+ if (data === "w") {
1928
+ this.say(this.host.excalidraw.toggleContribute(session.id));
1929
+ this.data.excalidraw = this.host.excalidraw.list();
1930
+ return true;
1931
+ }
1932
+ if (data === "r") {
1933
+ this.xDraft = "rename";
1934
+ this.editor.setText(session.name);
1935
+ this.setMode("type");
1936
+ return true;
1937
+ }
1938
+ if (data === "t") {
1939
+ if (this.xChecking.has(session.id)) return true;
1940
+ this.xChecking.add(session.id);
1941
+ this.xChecks.delete(session.id);
1942
+ void this.host.checkExcalidraw(session).then((result) => {
1943
+ this.xChecking.delete(session.id);
1944
+ this.xChecks.set(session.id, result);
1945
+ this.host.requestRender();
1946
+ });
1947
+ return true;
1948
+ }
1949
+ if (data === "d") {
1950
+ const key = `xdelete:${session.id}`;
1951
+ if (this.armed !== key) {
1952
+ this.armed = key;
1953
+ this.say(`press d again to remove “${session.name}” (agents lose it at once; the room itself is untouched)`, "warning");
1954
+ return true;
1955
+ }
1956
+ this.armed = undefined;
1957
+ this.say(this.host.excalidraw.remove(session.id));
1958
+ this.xChecks.delete(session.id);
1959
+ this.data.excalidraw = this.host.excalidraw.list();
1960
+ this.xSelected = Math.max(0, Math.min(this.xSelected, this.data.excalidraw.length - 1));
1961
+ return true;
1962
+ }
1963
+ return false;
1964
+ }
1965
+
1966
+ /** The prompt's text on the Excalidraw tab: a link to add (a name may follow it), the name of a new room, or a new name. */
1967
+ private submitExcalidraw(body: string): void {
1968
+ const draft = this.xDraft;
1969
+ if (!draft) {
1970
+ this.editor.setText(body);
1971
+ return this.say("press a to add a session from a link, n for a new room", "warning");
1972
+ }
1973
+ const book = this.host.excalidraw;
1974
+ if (draft === "rename") {
1975
+ const session = this.data.excalidraw[this.xSelected];
1976
+ this.xDraft = undefined;
1977
+ this.setMode("browse");
1978
+ if (session) this.say(book.rename(session.id, body));
1979
+ this.data.excalidraw = book.list();
1980
+ return;
1981
+ }
1982
+ // A link, then optionally a name for it; or the link's name first.
1983
+ const parts = body.split(/\s+/);
1984
+ const linkAt = parts.findIndex((part) => part.includes("#room=") || /^[\w-]+,[\w-]{22}$/.test(part));
1985
+ const added = draft === "add" ? (linkAt < 0 ? { notice: "that is not an Excalidraw room link — it looks like https://excalidraw.com/#room=<id>,<key>" } : book.add(parts[linkAt]!, parts.filter((_, index) => index !== linkAt).join(" "))) : book.create(body);
1986
+ if (!added.session) {
1987
+ // Keep what was typed, so a slip in the link can be fixed.
1988
+ this.editor.setText(body);
1989
+ return this.say(added.notice, "warning");
1990
+ }
1991
+ this.xDraft = undefined;
1992
+ this.setMode("browse");
1993
+ this.data.excalidraw = book.list();
1994
+ this.xSelected = Math.max(0, this.data.excalidraw.findIndex((session) => session.id === added.session!.id));
1995
+ this.xFocus = "detail";
1996
+ this.xAgent = 0;
1997
+ this.say(added.notice);
1998
+ }
1999
+
1856
2000
  /** The prompt's text on the Knowledge tab: an edit of the picked entry, a note on it, or a new entry after it. */
1857
2001
  private submitKnowledge(body: string): void {
1858
2002
  const draft = this.kDraft;
@@ -1900,7 +2044,7 @@ export class LobbyView implements Component, Focusable {
1900
2044
  /** The wheel scrolls the pane under the pointer (the focused one when it is over none). */
1901
2045
  private wheel(direction: number, x = -1, y = -1): void {
1902
2046
  if (this.help) return;
1903
- if (this.tab === "issues" || this.tab === "git" || this.tab === "knowledge") return this.scroll(direction * WHEEL_LINES);
2047
+ if (this.tab === "issues" || this.tab === "git" || this.tab === "knowledge" || this.tab === "excalidraw") return this.scroll(direction * WHEEL_LINES);
1904
2048
  this.scrollPane(this.paneAt(x, y) ?? this.focusedPane(), direction * WHEEL_LINES, true);
1905
2049
  }
1906
2050
 
@@ -1957,6 +2101,9 @@ export class LobbyView implements Component, Focusable {
1957
2101
  const body = text.trim();
1958
2102
  if (!body) {
1959
2103
  if (this.tab === "knowledge" && this.kDraft) return this.say(this.kDraft.kind === "edit" ? "nothing to save — d d deletes an entry, esc cancels" : "write something first, or esc to cancel", "warning");
2104
+ // A new room's name is optional: an empty enter makes it with a numbered one.
2105
+ if (this.tab === "excalidraw" && this.xDraft === "new") return this.submitExcalidraw("");
2106
+ if (this.tab === "excalidraw" && this.xDraft) return this.say(this.xDraft === "add" ? "paste the session's link first, or esc to cancel" : "write a name first, or esc to cancel", "warning");
1960
2107
  // An empty enter on the Plan tab opens the panel's questions; on the Lobby, a background session's question.
1961
2108
  if (this.tab === "plan" && !this.lineTarget && this.host.planner()?.awaitingAnswers) this.answerQuestions();
1962
2109
  if (this.tab === "lobby" && !this.newSession) this.answerSessionDialog();
@@ -1998,6 +2145,8 @@ export class LobbyView implements Component, Focusable {
1998
2145
  return;
1999
2146
  case "knowledge":
2000
2147
  return this.submitKnowledge(body);
2148
+ case "excalidraw":
2149
+ return this.submitExcalidraw(body);
2001
2150
  case "git":
2002
2151
  if (!this.gitFocusDraft) {
2003
2152
  this.editor.setText(body);
@@ -2097,6 +2246,7 @@ export class LobbyView implements Component, Focusable {
2097
2246
  if (this.issuesOn && this.host.issues.issues.length > 0) badges.issues = String(this.host.issues.issues.length);
2098
2247
  if (this.host.reviews.busy) badges.git = spinner(this.tick);
2099
2248
  else if (this.host.pulls.pulls.length > 0) badges.git = String(this.host.pulls.pulls.length);
2249
+ if (this.data.excalidraw.length > 0) badges.excalidraw = String(this.data.excalidraw.length);
2100
2250
  const cells = this.tabs().map((tab, index) => {
2101
2251
  const number = paint(theme, tab === this.tab ? "accent" : "dim", String(index + 1));
2102
2252
  const name = tab === this.tab ? bold(theme, paint(theme, "text", TAB_LABELS[tab])) : paint(theme, "muted", TAB_LABELS[tab]);
@@ -2213,6 +2363,12 @@ export class LobbyView implements Component, Focusable {
2213
2363
  if (this.kDraft?.kind === "add") return picked ? `a new entry after “${clip(picked.text, 40)}” · enter adds it` : "a new entry · enter adds it";
2214
2364
  return "e edits the picked entry, c comments on it, n adds one";
2215
2365
  }
2366
+ case "excalidraw": {
2367
+ if (this.xDraft === "add") return "paste the session's link (a name may follow it) · enter adds it · esc cancels";
2368
+ if (this.xDraft === "new") return "a name for the new room (optional) · enter makes it · esc cancels";
2369
+ if (this.xDraft === "rename") return "a new name for the session · enter saves it · esc cancels";
2370
+ return "a adds a session from a link, n makes a new room";
2371
+ }
2216
2372
  case "git": {
2217
2373
  const pull = this.selectedPull();
2218
2374
  if (this.gitFocusDraft) return `what should the review of #${pull?.number ?? "?"} look at first? · enter reviews`;
@@ -2317,6 +2473,18 @@ export class LobbyView implements Component, Focusable {
2317
2473
  { key: "E", text: "edit the whole file in pi's editor" },
2318
2474
  { key: "r", text: "reread the files from disk" },
2319
2475
  ];
2476
+ case "excalidraw":
2477
+ return [
2478
+ { key: "↑ ↓", text: "pick a session, or (in the checklist) an agent" },
2479
+ { key: "enter / ← →", text: "move between the sessions and the agents; in the checklist, enter or space assigns the picked agent" },
2480
+ { key: "a", text: "add a session: paste the link from Excalidraw (Share → Live collaboration → Start session)" },
2481
+ { key: "n", text: "make a new room, whose link you open in Excalidraw" },
2482
+ { key: "*", text: "assign the session to every agent, or (when they all have it) to none" },
2483
+ { key: "w", text: "let agents draw in the session, or only look at it" },
2484
+ { key: "t", text: "check the session: join the room for a moment and report who is there and what is on the board" },
2485
+ { key: "r", text: "rename the session" },
2486
+ { key: "d d", text: "remove the session (agents lose it at once)" },
2487
+ ];
2320
2488
  case "git":
2321
2489
  return [
2322
2490
  { key: "↑ ↓", text: "select a pull request, or scroll its detail (PageUp/PageDown)" },
@@ -2378,6 +2546,7 @@ export class LobbyView implements Component, Focusable {
2378
2546
  if (this.tab === "lobby" && this.host.masterBusy()) return [["enter", "steer"], ["esc", "stop the oracle"], [k("hide"), "hide"]];
2379
2547
  if (this.tab === "plan" && this.lineTarget) return [["enter", "add the comment"], ["esc", "cancel"]];
2380
2548
  if (this.tab === "knowledge" && this.kDraft) return [["enter", this.kDraft.kind === "edit" ? "save" : this.kDraft.kind === "comment" ? "save the note" : "add it"], ["shift+enter", "new line"], ["esc", "cancel"]];
2549
+ if (this.tab === "excalidraw" && this.xDraft) return [["enter", this.xDraft === "add" ? "add it" : this.xDraft === "new" ? "make it" : "save"], ["esc", "cancel"]];
2381
2550
  if (this.tab === "git" && this.gitFocusDraft) return [["enter", "review with this focus"], ["shift+enter", "new line"], ["esc", "cancel"]];
2382
2551
  const enter = this.tab === "plan" && session?.awaitingAnswers && !this.editor.getText().trim() ? "answer questions" : this.tab === "quickfix" ? "run it" : "send";
2383
2552
  return [["enter", enter], ["esc", "browse"], [k("hide"), "hide"]];
@@ -2419,6 +2588,12 @@ export class LobbyView implements Component, Focusable {
2419
2588
  if (this.kFocus === "detail" || this.pickedEntry()) keys.push(["e", "edit"], ["c", "comment"], ["n", "new"], ["d d", "delete"]);
2420
2589
  keys.push(["E", "edit file"]);
2421
2590
  break;
2591
+ case "excalidraw": {
2592
+ const session = this.data.excalidraw[this.xSelected];
2593
+ keys.push(["a", "add a link"], ["n", "new room"]);
2594
+ if (session) keys.push(["↑↓", this.xFocus === "detail" ? "agent" : "session"], ["enter", this.xFocus === "detail" ? "assign" : "agents"], ["t", "check"], ["w", session.contribute ? "look only" : "let agents draw"], ["d d", "remove"]);
2595
+ break;
2596
+ }
2422
2597
  case "git": {
2423
2598
  const pull = this.selectedPull();
2424
2599
  keys.push(["↑↓", "select"]);
@@ -2529,6 +2704,10 @@ export class LobbyView implements Component, Focusable {
2529
2704
  this.kOffset = this.kLayout.offset;
2530
2705
  return lines;
2531
2706
  }
2707
+ case "excalidraw": {
2708
+ this.xSelected = Math.min(this.xSelected, Math.max(0, this.data.excalidraw.length - 1));
2709
+ return renderExcalidraw({ sessions: this.data.excalidraw, selected: this.xSelected, focus: this.xFocus, agent: this.xAgent, checks: this.xChecks, checking: this.xChecking, tick: this.tick }, width, height, theme);
2710
+ }
2532
2711
  case "git": {
2533
2712
  const pulls = this.host.pulls;
2534
2713
  this.gitSelected = Math.min(this.gitSelected, Math.max(0, pulls.pulls.length - 1));