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

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 (58) hide show
  1. package/README.md +183 -11
  2. package/package.json +1 -1
  3. package/prompts/master.md +21 -0
  4. package/prompts/panel.md +4 -0
  5. package/prompts/planner.md +5 -0
  6. package/prompts/pr-review.md +62 -0
  7. package/prompts/splitter.md +58 -0
  8. package/src/ask/dialog.ts +23 -7
  9. package/src/ask/state.ts +18 -3
  10. package/src/ask/tool.ts +9 -2
  11. package/src/ask/view.ts +8 -4
  12. package/src/classifier/instance.ts +6 -0
  13. package/src/classifier/knowledge.ts +158 -0
  14. package/src/classifier/review.ts +146 -0
  15. package/src/execution/agent-runner.ts +23 -1
  16. package/src/execution/fallback.ts +75 -0
  17. package/src/execution/workspace.ts +185 -0
  18. package/src/knowledge/edit.ts +109 -0
  19. package/src/knowledge/notes.ts +143 -0
  20. package/src/knowledge/selector.ts +1 -1
  21. package/src/knowledge/store.ts +16 -2
  22. package/src/lobby/ask.ts +42 -21
  23. package/src/lobby/feed.ts +7 -0
  24. package/src/lobby/issues.ts +1 -1
  25. package/src/lobby/knowledge.ts +179 -0
  26. package/src/lobby/layout.ts +99 -1
  27. package/src/lobby/mini.ts +159 -0
  28. package/src/lobby/planner.ts +209 -10
  29. package/src/lobby/pr-review.ts +360 -0
  30. package/src/lobby/pulls.ts +250 -0
  31. package/src/lobby/quickfix.ts +17 -3
  32. package/src/lobby/runtime.ts +142 -17
  33. package/src/lobby/split.ts +230 -0
  34. package/src/lobby/tabs/git.ts +162 -0
  35. package/src/lobby/tabs/knowledge.ts +135 -0
  36. package/src/lobby/tabs/plan.ts +3 -1
  37. package/src/lobby/tabs/tasks.ts +11 -2
  38. package/src/lobby/view.ts +450 -35
  39. package/src/master/master.ts +40 -14
  40. package/src/master/research.ts +1 -0
  41. package/src/pi/commands.ts +12 -7
  42. package/src/pi/events.ts +2 -0
  43. package/src/pi/master-fallback.ts +61 -0
  44. package/src/pi/model-support.ts +27 -3
  45. package/src/pi/plan-checklist.ts +42 -0
  46. package/src/pi/route.ts +2 -1
  47. package/src/pi/settings-ui.ts +104 -11
  48. package/src/pi/start-flags.ts +14 -0
  49. package/src/pi/start-task.ts +56 -5
  50. package/src/pi/tools.ts +4 -2
  51. package/src/schemas/configuration.ts +74 -11
  52. package/src/schemas/findings.ts +2 -0
  53. package/src/schemas/task.ts +15 -0
  54. package/src/state/backlog.ts +27 -4
  55. package/src/state/metrics.ts +2 -2
  56. package/src/state/persistence.ts +5 -5
  57. package/src/text.ts +38 -0
  58. package/src/workflow/workflow.ts +23 -2
package/src/lobby/view.ts CHANGED
@@ -22,16 +22,26 @@ import type { BackgroundSession } from "./sessions.ts";
22
22
  import type { QuickFixQueue } from "./quickfix.ts";
23
23
  import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
24
24
  import { issueText, type IssuesState } from "./issues.ts";
25
+ import { agentsIndicator, workingAgents } from "./mini.ts";
25
26
  import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
26
- import { beside, bold, box, fit, highlight, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
27
+ import { beside, BRANCH_GLYPH, bold, box, fit, highlight, pageStep, pagerButton, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
27
28
  import { chatTail, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
28
29
  import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
29
30
  import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
30
31
  import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
31
32
  import { renderIssues } from "./tabs/issues.ts";
33
+ import { renderGit } from "./tabs/git.ts";
34
+ import { renderKnowledge, type KnowledgeLayout } from "./tabs/knowledge.ts";
35
+ import type { EntryRef, KnowledgeBook, KnowledgeFileInfo, KnowledgeView } from "./knowledge.ts";
36
+ import type { PullsState } from "./pulls.ts";
37
+ import type { PullReviews } from "./pr-review.ts";
38
+ import type { WorkspaceInfo } from "../execution/workspace.ts";
32
39
  import { filterRecords, renderMetrics } from "./tabs/metrics.ts";
33
40
 
34
- export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics"] as const;
41
+ /** The mark before a branch name in the title (shared with the task details). */
42
+ export { BRANCH_GLYPH };
43
+
44
+ export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics", "git", "knowledge"] as const;
35
45
  export type TabId = (typeof TAB_IDS)[number];
36
46
 
37
47
  export const TAB_LABELS: Record<TabId, string> = {
@@ -41,6 +51,8 @@ export const TAB_LABELS: Record<TabId, string> = {
41
51
  quickfix: "Quick fix",
42
52
  issues: "Issues",
43
53
  metrics: "Metrics",
54
+ git: "Git",
55
+ knowledge: "Knowledge",
44
56
  };
45
57
 
46
58
  /** The tabs on show: Issues only while `lobby.issues` switches it on. */
@@ -148,6 +160,8 @@ export interface LobbyHost {
148
160
  newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
149
161
  /** The oracle puts the panel's open questions to the user, one questionnaire at a time; returns a notice. */
150
162
  answerPanel(): Promise<string>;
163
+ /** Save the plan as a pending task, offering to split a long one into several first; returns a notice. */
164
+ savePlan(): Promise<string>;
151
165
  /** The seats a new session starts with, from settings. */
152
166
  defaultPanel(): readonly PanelMember[];
153
167
  /** Planning rounds before the oracle finalizes alone (`lobby.maxPlanningRounds`); 0 = unlimited. */
@@ -155,6 +169,14 @@ export interface LobbyHost {
155
169
  /** `model · thinking` a panel seat runs on. */
156
170
  seatLabel(member: PanelMember): string;
157
171
  issues: IssuesState;
172
+ /** The Git tab's pull requests, read through `gh`. */
173
+ pulls: PullsState;
174
+ /** Reviews of pull requests by an agent, and Jev's reads of them. */
175
+ reviews: PullReviews;
176
+ /** Every agent's knowledge files: read, edited, commented on. */
177
+ knowledge: KnowledgeBook;
178
+ /** Edit a text in pi's multi-line editor (the lobby steps aside meanwhile); the new text, or undefined when cancelled. */
179
+ editText(title: string, text: string): Promise<string | undefined>;
158
180
  /** The Issues tab is switched on (`lobby.issues`). */
159
181
  issuesEnabled(): boolean;
160
182
  /** Which panes show (`lobby.panels`), and remembering a change. */
@@ -196,6 +218,10 @@ export interface LobbyHost {
196
218
  /** A session's whole conversation, oldest first: this window's (no id) or another's; loaded only while scrolled back to it. */
197
219
  chatHistory(sessionId?: string): readonly ChatEntry[];
198
220
  profileLabel(kind: LobbyAgentKind): string;
221
+ /** The repository (or folder) and branch the title shows; the folder's name alone until git has answered. */
222
+ workspace?(): WorkspaceInfo;
223
+ /** Ask for the workspace to be read again (the branch may have changed); it arrives through `workspace()`. */
224
+ refreshWorkspace?(): void;
199
225
  requestRender(): void;
200
226
  now?(): number;
201
227
  }
@@ -208,6 +234,9 @@ export const DATA_REFRESH_MS = 2000;
208
234
  const NOTICE_MS = 6000;
209
235
  /** Lines one wheel notch scrolls. */
210
236
  const WHEEL_LINES = 3;
237
+ /** The longest repository and branch names the title shows whole. */
238
+ const TITLE_NAME_MAX = 28;
239
+ const TITLE_BRANCH_MAX = 36;
211
240
 
212
241
  /** The prompt editor, with its target written into the top border. */
213
242
  class LobbyEditor extends Editor {
@@ -338,19 +367,33 @@ export class LobbyView implements Component, Focusable {
338
367
  private issueFocus: "list" | "detail" = "list";
339
368
  private issueDetailOffset = 0;
340
369
  private issueDraft = false;
370
+ private gitSelected = 0;
371
+ private gitFocus: "list" | "detail" = "list";
372
+ private gitDetailOffset = 0;
373
+ /** The prompt is taking what the review of the selected pull request should look at first. */
374
+ private gitFocusDraft = false;
375
+ private kSelected = 0;
376
+ private kFocus: "list" | "detail" = "list";
377
+ /** The picked entry of the open knowledge file (an index into its entries), and how far its pane is scrolled. */
378
+ private kCursor = 0;
379
+ private kOffset = 0;
380
+ private readonly kLayout: KnowledgeLayout = { offset: 0, total: 0, rows: 0 };
381
+ /** The prompt is writing an edit of the picked entry, a note on it, or a new entry after it. */
382
+ private kDraft: { kind: "edit" | "comment" | "add"; entry?: EntryRef } | undefined;
341
383
  private metricsSelected = 0;
342
384
  private metricsBy: GroupBy = "model";
343
385
  private metricsSort: SortKey = "runs";
344
386
 
345
387
  /** The Tasks tab lists archived tasks too (v). */
346
388
  showArchived = false;
347
- private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; at: number } = {
389
+ private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; knowledge: { files: KnowledgeFileInfo[]; view?: KnowledgeView }; at: number } = {
348
390
  tasks: [],
349
391
  plans: [],
350
392
  archived: [],
351
393
  comments: new Map(),
352
394
  metrics: [],
353
395
  classifierMetrics: [],
396
+ knowledge: { files: [] },
354
397
  at: 0,
355
398
  };
356
399
 
@@ -414,6 +457,8 @@ export class LobbyView implements Component, Focusable {
414
457
  || Boolean(this.host.planner()?.busy)
415
458
  || this.host.sessions().some((session) => session.busy)
416
459
  || (this.issuesOn && this.host.issues.loading)
460
+ || (this.tab === "git" && this.host.pulls.loading)
461
+ || this.host.reviews.busy
417
462
  || zen.runs.some((run) => run.status === "running")
418
463
  || Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
419
464
  }
@@ -432,6 +477,7 @@ export class LobbyView implements Component, Focusable {
432
477
  const now = this.now();
433
478
  if (!force && now - this.data.at < DATA_REFRESH_MS) return;
434
479
  this.dataVersion += 1;
480
+ this.host.refreshWorkspace?.();
435
481
  this.data = {
436
482
  tasks: this.host.tasks(),
437
483
  plans: this.host.plans(),
@@ -439,6 +485,7 @@ export class LobbyView implements Component, Focusable {
439
485
  comments: new Map(),
440
486
  metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
441
487
  classifierMetrics: this.tab === "metrics" ? this.host.classifierMetrics?.() ?? [] : this.data.classifierMetrics,
488
+ knowledge: this.tab === "knowledge" ? this.readKnowledge() : this.data.knowledge,
442
489
  at: now,
443
490
  };
444
491
  if (this.tab === "tasks") {
@@ -447,6 +494,19 @@ export class LobbyView implements Component, Focusable {
447
494
  }
448
495
  }
449
496
 
497
+ /** Every knowledge file, and the selected one read as entries. */
498
+ private readKnowledge(): { files: KnowledgeFileInfo[]; view?: KnowledgeView } {
499
+ const files = this.host.knowledge.files();
500
+ this.kSelected = Math.max(0, Math.min(this.kSelected, files.length - 1));
501
+ const info = files[this.kSelected];
502
+ return { files, ...(info ? { view: this.host.knowledge.open(info.agent, info.file) } : {}) };
503
+ }
504
+
505
+ /** Read the open knowledge file again (after an edit, or another file was picked). */
506
+ private reloadKnowledge(): void {
507
+ this.data.knowledge = this.readKnowledge();
508
+ }
509
+
450
510
  private say(text: string, kind: Notice["kind"] = "info"): void {
451
511
  this.notice = { text, at: this.now(), kind };
452
512
  }
@@ -474,12 +534,18 @@ export class LobbyView implements Component, Focusable {
474
534
  setTab(tab: TabId): void {
475
535
  if (tab === this.tab) return;
476
536
  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
+ // An entry being edited is not a draft to come back to: its text was only lent to the prompt.
538
+ if (this.kDraft) {
539
+ this.kDraft = undefined;
540
+ this.editor.setText("");
541
+ }
477
542
  this.drafts[this.tab] = this.editor.getText();
478
543
  this.tab = tab;
479
544
  this.editor.setText(this.drafts[tab] ?? "");
480
545
  this.commentTarget = undefined;
481
546
  this.lineTarget = undefined;
482
547
  this.issueDraft = false;
548
+ this.gitFocusDraft = false;
483
549
  this.armed = undefined;
484
550
  this.help = false;
485
551
  this.picking = false;
@@ -488,6 +554,8 @@ export class LobbyView implements Component, Focusable {
488
554
  this.setMode(PROMPT_FIRST.has(tab) ? "type" : "browse");
489
555
  this.refreshData(true);
490
556
  if (tab === "issues" && !this.host.issues.loaded && !this.host.issues.loading) void this.host.issues.refresh().then(() => this.loadIssueDetail());
557
+ if (tab === "git" && !this.host.pulls.loaded && !this.host.pulls.loading) void this.host.pulls.refresh().then(() => this.loadPullDetail());
558
+ else if (tab === "git") void this.loadPullDetail();
491
559
  this.host.requestRender();
492
560
  }
493
561
 
@@ -687,6 +755,11 @@ export class LobbyView implements Component, Focusable {
687
755
  }
688
756
  this.commentTarget = undefined;
689
757
  this.issueDraft = false;
758
+ this.gitFocusDraft = false;
759
+ if (this.kDraft) {
760
+ this.kDraft = undefined;
761
+ this.editor.setText("");
762
+ }
690
763
  this.newSession = false;
691
764
  if (this.lineTarget) {
692
765
  this.lineTarget = undefined;
@@ -713,7 +786,7 @@ export class LobbyView implements Component, Focusable {
713
786
  if (matchesKey(data, Key.home) || matchesKey(data, Key.end)) return this.scrollToEdge(matchesKey(data, Key.home));
714
787
  const tabs = this.tabs();
715
788
  if (!PROMPT_FIRST.has(this.tab) && /^[1-9]$/.test(data) && Number(data) <= tabs.length) return this.setTab(tabs[Number(data) - 1]!);
716
- if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues") return this.setMode("type");
789
+ if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues" && this.tab !== "git" && this.tab !== "knowledge") return this.setMode("type");
717
790
  if (this.tabCommand(data)) return;
718
791
  const printable = isPrintable(data);
719
792
  if (printable && PROMPT_FIRST.has(this.tab)) {
@@ -735,6 +808,10 @@ export class LobbyView implements Component, Focusable {
735
808
  return this.fixFocus;
736
809
  case "issues":
737
810
  return this.issueFocus;
811
+ case "git":
812
+ return this.gitFocus;
813
+ case "knowledge":
814
+ return this.kFocus;
738
815
  case "metrics":
739
816
  return "table";
740
817
  }
@@ -781,6 +858,12 @@ export class LobbyView implements Component, Focusable {
781
858
  case "issues":
782
859
  this.issueFocus = direction < 0 ? "list" : "detail";
783
860
  return;
861
+ case "git":
862
+ this.gitFocus = direction < 0 ? "list" : "detail";
863
+ return;
864
+ case "knowledge":
865
+ this.kFocus = direction < 0 ? "list" : "detail";
866
+ return;
784
867
  case "metrics":
785
868
  return;
786
869
  }
@@ -806,17 +889,44 @@ export class LobbyView implements Component, Focusable {
806
889
  void this.loadIssueDetail();
807
890
  }
808
891
  return;
892
+ case "git":
893
+ if (this.gitFocus === "detail" || Math.abs(delta) > 1) this.gitDetailOffset = Math.max(0, this.gitDetailOffset + delta);
894
+ else {
895
+ this.gitSelected = Math.max(0, Math.min(this.host.pulls.pulls.length - 1, this.gitSelected + delta));
896
+ this.gitDetailOffset = 0;
897
+ void this.loadPullDetail();
898
+ }
899
+ return;
900
+ case "knowledge":
901
+ return this.moveKnowledge(delta);
809
902
  case "metrics":
810
903
  return this.scrollPane("table", delta);
811
904
  }
812
905
  }
813
906
 
907
+ /** Arrows and pages on the Knowledge tab: pick a file, or pick an entry of the open one (a page moves several). */
908
+ private moveKnowledge(delta: number): void {
909
+ const knowledge = this.data.knowledge;
910
+ if (this.kFocus === "list") {
911
+ const next = Math.max(0, Math.min(knowledge.files.length - 1, this.kSelected + delta));
912
+ if (next === this.kSelected) return;
913
+ this.kSelected = next;
914
+ this.kCursor = 0;
915
+ this.kOffset = 0;
916
+ this.reloadKnowledge();
917
+ return;
918
+ }
919
+ const count = knowledge.view?.entries.length ?? 0;
920
+ const step = Math.abs(delta) > 1 ? Math.sign(delta) * Math.max(2, Math.round(Math.abs(delta) / 3)) : delta;
921
+ this.kCursor = Math.max(0, Math.min(count - 1, this.kCursor + step));
922
+ }
923
+
814
924
  /**
815
925
  * Scroll one pane of the current tab by `delta` lines, positive toward its
816
926
  * end. Lists move their selection instead; the draft moves its cursor (the
817
927
  * wheel scrolls it and the cursor follows only when it would leave the screen).
818
928
  */
819
- private scrollPane(pane: string, delta: number, wheel = false): void {
929
+ private scrollPane(pane: string, delta: number, wheel = false, page = false): void {
820
930
  if (delta === 0) return;
821
931
  switch (pane) {
822
932
  case "conversation":
@@ -828,13 +938,13 @@ export class LobbyView implements Component, Focusable {
828
938
  this.planOffset = Math.max(0, this.planOffset - delta);
829
939
  break;
830
940
  case "draft":
831
- if (wheel) this.wheelDraft(delta);
941
+ if (wheel || page) this.wheelDraft(delta);
832
942
  else this.moveCursor(delta);
833
943
  break;
834
944
  case "list":
835
- if (this.tab === "tasks") this.selectTask(this.tasksSelected + Math.sign(delta));
945
+ if (this.tab === "tasks") this.selectTask(this.tasksSelected + (page ? delta : Math.sign(delta)));
836
946
  else if (this.tab === "quickfix") {
837
- this.fixSelected = Math.max(0, Math.min(this.fixJobs().length - 1, this.fixSelected + Math.sign(delta)));
947
+ this.fixSelected = Math.max(0, Math.min(this.fixJobs().length - 1, this.fixSelected + (page ? delta : Math.sign(delta))));
838
948
  this.fixDetailOffset = 0;
839
949
  }
840
950
  break;
@@ -843,7 +953,7 @@ export class LobbyView implements Component, Focusable {
843
953
  else if (this.tab === "quickfix") this.fixDetailOffset = Math.max(0, this.fixDetailOffset + delta);
844
954
  break;
845
955
  case "table":
846
- this.metricsSelected = Math.max(0, this.metricsSelected + (wheel ? Math.sign(delta) : delta));
956
+ this.metricsSelected = Math.max(0, this.metricsSelected + (wheel && !page ? Math.sign(delta) : delta));
847
957
  break;
848
958
  }
849
959
  this.clampScroll();
@@ -989,6 +1099,11 @@ export class LobbyView implements Component, Focusable {
989
1099
  if (row?.kind === "task" && !this.data.comments.has(row.id)) this.data.comments.set(row.id, this.host.comments(row.id));
990
1100
  }
991
1101
 
1102
+ private async loadPullDetail(): Promise<void> {
1103
+ const pull = this.host.pulls.pulls[this.gitSelected];
1104
+ if (pull) await this.host.pulls.detail(pull.number);
1105
+ }
1106
+
992
1107
  private async loadIssueDetail(): Promise<void> {
993
1108
  const issue = this.host.issues.issues[this.issueSelected];
994
1109
  if (issue) await this.host.issues.detail(issue.number);
@@ -1050,6 +1165,10 @@ export class LobbyView implements Component, Focusable {
1050
1165
  return false;
1051
1166
  case "issues":
1052
1167
  return this.issuesCommand(data, enter, escape);
1168
+ case "git":
1169
+ return this.gitCommand(data, enter, escape);
1170
+ case "knowledge":
1171
+ return this.knowledgeCommand(data, enter, escape);
1053
1172
  case "metrics":
1054
1173
  if (data === "g") return (this.metricsBy = this.metricsBy === "model" ? "model-kind" : "model", this.metricsSelected = 0), true;
1055
1174
  if (data === "s") return (this.metricsSort = SORT_KEYS[(SORT_KEYS.indexOf(this.metricsSort) + 1) % SORT_KEYS.length]!), true;
@@ -1461,17 +1580,25 @@ export class LobbyView implements Component, Focusable {
1461
1580
  this.say(on ? `auto mode on — the oracle drives ${task.id} to completion without asking` : `auto mode off — the oracle asks you again on ${task.id}`);
1462
1581
  }
1463
1582
 
1464
- /** Save the planning session's draft to the pending tasks, from any tab and in either mode. */
1583
+ /**
1584
+ * Save the planning session's draft to the pending tasks, from any tab and
1585
+ * in either mode. A plan with many steps is first offered a split into
1586
+ * several tasks, which takes the oracle a moment and asks the user.
1587
+ */
1465
1588
  savePlan(): void {
1466
1589
  const session = this.host.planner();
1467
1590
  if (!session?.reply?.plan) return this.say(session?.busy ? "the first draft is still being written" : "no plan to save yet — describe a task in the Plan tab", "warning");
1468
- try {
1469
- const saved = session.save();
1470
- this.say(`saved ${saved.id} to the pending tasks — start it from the Tasks tab${session.reply.status === "ready" ? "" : " (the panel had not agreed yet)"}`);
1471
- } catch (error) {
1472
- this.say((error as Error).message, "warning");
1473
- }
1474
- this.refreshData(true);
1591
+ void this.host.savePlan().then(
1592
+ (notice) => {
1593
+ this.say(notice);
1594
+ this.refreshData(true);
1595
+ this.host.requestRender();
1596
+ },
1597
+ (error: Error) => {
1598
+ this.say(error.message, "warning");
1599
+ this.host.requestRender();
1600
+ },
1601
+ );
1475
1602
  }
1476
1603
 
1477
1604
  /** The oracle puts the round's questions to the user through the questionnaire. */
@@ -1588,6 +1715,162 @@ export class LobbyView implements Component, Focusable {
1588
1715
  return false;
1589
1716
  }
1590
1717
 
1718
+ /** The selected pull request on the Git tab. */
1719
+ private selectedPull() {
1720
+ return this.host.pulls.pulls[this.gitSelected];
1721
+ }
1722
+
1723
+ /** Review the selected pull request with a read-only agent, optionally looking at `focus` first. */
1724
+ private startReview(focus?: string): void {
1725
+ const pull = this.selectedPull();
1726
+ if (!pull) return this.say("no pull request selected — r loads the open ones", "warning");
1727
+ if (this.host.reviews.running(pull.number)) return this.say(`already reviewing #${pull.number} — x stops it`, "warning");
1728
+ this.gitFocus = "detail";
1729
+ this.gitDetailOffset = 0;
1730
+ this.say(`reviewing #${pull.number}${focus ? ` — looking at: ${clip(focus, 40)}` : ""} — it streams into the activity log; x stops it`);
1731
+ void this.host.reviews.start(pull.number, { ...(focus ? { focus } : {}), ...(this.host.pulls.details.has(pull.number) ? { detail: this.host.pulls.details.get(pull.number)! } : {}) }).then(() => this.host.requestRender());
1732
+ }
1733
+
1734
+ private gitCommand(data: string, enter: boolean, escape: boolean): boolean {
1735
+ const pull = this.selectedPull();
1736
+ if (enter) {
1737
+ this.gitFocus = this.gitFocus === "list" ? "detail" : "list";
1738
+ void this.loadPullDetail();
1739
+ return true;
1740
+ }
1741
+ if (escape) return (this.gitFocus = "list"), true;
1742
+ if (data === "r") {
1743
+ this.host.pulls.forget();
1744
+ void this.host.pulls.refresh().then(() => this.loadPullDetail());
1745
+ return true;
1746
+ }
1747
+ if (!pull) {
1748
+ // Nothing listed: the review keys say so instead of doing nothing.
1749
+ if (!"vftx".includes(data) || data.length !== 1) return false;
1750
+ this.say("no pull request selected — r loads the open ones", "warning");
1751
+ return true;
1752
+ }
1753
+ if (data === "v") return this.startReview(), true;
1754
+ if (data === "f") {
1755
+ this.gitFocusDraft = true;
1756
+ this.setMode("type");
1757
+ return true;
1758
+ }
1759
+ if (data === "t") {
1760
+ this.gitFocus = "detail";
1761
+ this.gitDetailOffset = 0;
1762
+ this.say(`asking Jev to read #${pull.number}…`);
1763
+ void this.host.reviews.readWithJev(pull.number, this.host.pulls.details.get(pull.number)).then(() => this.host.requestRender());
1764
+ return true;
1765
+ }
1766
+ if (data === "x") {
1767
+ this.say(this.host.reviews.cancel(pull.number) ? `stopping the review of #${pull.number}` : `no review of #${pull.number} is running`, "warning");
1768
+ return true;
1769
+ }
1770
+ return false;
1771
+ }
1772
+
1773
+ /** The picked entry of the open knowledge file, as the edit functions find it again. */
1774
+ private pickedEntry(): { entry: EntryRef; text: string } | undefined {
1775
+ const entry = this.data.knowledge.view?.entries[this.kCursor];
1776
+ return entry ? { entry: { text: entry.text, occurrence: entry.occurrence }, text: entry.text } : undefined;
1777
+ }
1778
+
1779
+ private knowledgeCommand(data: string, enter: boolean, escape: boolean): boolean {
1780
+ const info = this.data.knowledge.files[this.kSelected];
1781
+ if (enter) return (this.kFocus = this.kFocus === "list" ? "detail" : "list"), true;
1782
+ if (escape) return (this.kFocus = "list"), true;
1783
+ if (data === "r") {
1784
+ this.reloadKnowledge();
1785
+ this.say("knowledge reread from disk");
1786
+ return true;
1787
+ }
1788
+ if (!info) return false;
1789
+ if (data === "E") {
1790
+ const view = this.data.knowledge.view;
1791
+ if (!view) return true;
1792
+ void this.host.editText(`${info.label} — ${info.agent}/${info.file}`, view.content).then((text) => {
1793
+ if (text !== undefined) this.say(this.host.knowledge.replaceFile(info.agent, info.file, text));
1794
+ this.reloadKnowledge();
1795
+ this.host.requestRender();
1796
+ });
1797
+ return true;
1798
+ }
1799
+ if (!"ecndx".includes(data) || data.length !== 1) return false;
1800
+ const picked = this.pickedEntry();
1801
+ // Adding needs no entry to start from: it goes at the end of an empty or entry-less file.
1802
+ if (!picked && data !== "n") {
1803
+ this.say("nothing is picked — n adds an entry", "warning");
1804
+ return true;
1805
+ }
1806
+ this.kFocus = "detail";
1807
+ if (data === "e" && picked) {
1808
+ this.kDraft = { kind: "edit", entry: picked.entry };
1809
+ this.editor.setText(picked.text);
1810
+ this.setMode("type");
1811
+ return true;
1812
+ }
1813
+ if (data === "c" && picked) {
1814
+ this.kDraft = { kind: "comment", entry: picked.entry };
1815
+ this.setMode("type");
1816
+ return true;
1817
+ }
1818
+ if (data === "n") {
1819
+ this.kDraft = { kind: "add", ...(picked ? { entry: picked.entry } : {}) };
1820
+ this.setMode("type");
1821
+ return true;
1822
+ }
1823
+ if (data === "d" && picked) {
1824
+ const key = `kdelete:${info.agent}/${info.file}:${picked.entry.occurrence}:${picked.text}`;
1825
+ if (this.armed !== key) {
1826
+ this.armed = key;
1827
+ this.say("press d again to delete this entry (the file is archived first, and its notes go with it)", "warning");
1828
+ return true;
1829
+ }
1830
+ this.armed = undefined;
1831
+ this.say(this.host.knowledge.remove(info.agent, info.file, picked.entry));
1832
+ this.reloadKnowledge();
1833
+ return true;
1834
+ }
1835
+ if (data === "x" && picked) {
1836
+ const notes = this.data.knowledge.view?.attached.filter((note) => note.entry === picked.text) ?? [];
1837
+ const newest = notes.at(-1);
1838
+ if (!newest) {
1839
+ this.say("this entry has no notes", "warning");
1840
+ return true;
1841
+ }
1842
+ const key = `kunnote:${newest.id}`;
1843
+ if (this.armed !== key) {
1844
+ this.armed = key;
1845
+ this.say(`press x again to remove the note “${clip(newest.text, 40)}”`, "warning");
1846
+ return true;
1847
+ }
1848
+ this.armed = undefined;
1849
+ this.say(this.host.knowledge.unnote(newest.id));
1850
+ this.reloadKnowledge();
1851
+ return true;
1852
+ }
1853
+ return true;
1854
+ }
1855
+
1856
+ /** 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
+ private submitKnowledge(body: string): void {
1858
+ const draft = this.kDraft;
1859
+ const info = this.data.knowledge.files[this.kSelected];
1860
+ if (!draft || !info) {
1861
+ this.editor.setText(body);
1862
+ return this.say("press e to edit the picked entry, c to comment on it, n to add one", "warning");
1863
+ }
1864
+ this.kDraft = undefined;
1865
+ this.setMode("browse");
1866
+ const book = this.host.knowledge;
1867
+ const notice = draft.kind === "edit" ? book.edit(info.agent, info.file, draft.entry!, body) : draft.kind === "comment" ? book.comment(info.agent, info.file, draft.entry!, body) : book.add(info.agent, info.file, draft.entry, body);
1868
+ this.say(notice, /^(saved|note saved)/.test(notice) ? "info" : "warning");
1869
+ this.reloadKnowledge();
1870
+ // A new entry goes right after the picked one, which the cursor then moves onto.
1871
+ if (draft.kind === "add" && notice.startsWith("saved")) this.kCursor = Math.min(this.kCursor + (draft.entry ? 1 : 0), Math.max(0, (this.data.knowledge.view?.entries.length ?? 1) - 1));
1872
+ }
1873
+
1591
1874
  /* --------------------------------------------------------------- mouse */
1592
1875
 
1593
1876
  /** Mouse events when pi runs full screen (pi-tui dispatches them to the overlay). */
@@ -1617,7 +1900,7 @@ export class LobbyView implements Component, Focusable {
1617
1900
  /** The wheel scrolls the pane under the pointer (the focused one when it is over none). */
1618
1901
  private wheel(direction: number, x = -1, y = -1): void {
1619
1902
  if (this.help) return;
1620
- if (this.tab === "issues") return this.scroll(direction * WHEEL_LINES);
1903
+ if (this.tab === "issues" || this.tab === "git" || this.tab === "knowledge") return this.scroll(direction * WHEEL_LINES);
1621
1904
  this.scrollPane(this.paneAt(x, y) ?? this.focusedPane(), direction * WHEEL_LINES, true);
1622
1905
  }
1623
1906
 
@@ -1640,6 +1923,15 @@ export class LobbyView implements Component, Focusable {
1640
1923
  if (this.tab === "lobby" && pane) this.homeFocus = pane as HomePane;
1641
1924
  if (this.tab === "tasks" && (pane === "list" || pane === "detail")) this.tasksFocus = pane;
1642
1925
  if (this.tab === "quickfix" && (pane === "list" || pane === "detail")) this.fixFocus = pane;
1926
+ // The page buttons in a scrolling pane's bottom border scroll it a page or two.
1927
+ const paneBox = pane ? this.panes.get(pane) : undefined;
1928
+ if (pane && paneBox && paneBox.total > paneBox.rows && row === paneBox.top + paneBox.height - 1) {
1929
+ const button = pagerButton(paneBox.width, x - paneBox.left, paneBox.total, paneBox.rows);
1930
+ if (button) {
1931
+ this.scrollPane(pane, button * pageStep(paneBox.rows), false, true);
1932
+ return;
1933
+ }
1934
+ }
1643
1935
  if (this.tab !== "plan" || row < 0 || !this.host.planner()) return;
1644
1936
  const layout = this.planLayout;
1645
1937
  // The draft pane: its border sits one row above its first line and two columns left of its text.
@@ -1664,6 +1956,7 @@ export class LobbyView implements Component, Focusable {
1664
1956
  private submit(text: string): void {
1665
1957
  const body = text.trim();
1666
1958
  if (!body) {
1959
+ 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");
1667
1960
  // An empty enter on the Plan tab opens the panel's questions; on the Lobby, a background session's question.
1668
1961
  if (this.tab === "plan" && !this.lineTarget && this.host.planner()?.awaitingAnswers) this.answerQuestions();
1669
1962
  if (this.tab === "lobby" && !this.newSession) this.answerSessionDialog();
@@ -1703,6 +1996,16 @@ export class LobbyView implements Component, Focusable {
1703
1996
  this.say("filing the issue…");
1704
1997
  void this.host.issues.create(body);
1705
1998
  return;
1999
+ case "knowledge":
2000
+ return this.submitKnowledge(body);
2001
+ case "git":
2002
+ if (!this.gitFocusDraft) {
2003
+ this.editor.setText(body);
2004
+ return this.say("press f to review the selected pull request with a focus", "warning");
2005
+ }
2006
+ this.gitFocusDraft = false;
2007
+ this.setMode("browse");
2008
+ return this.startReview(body);
1706
2009
  case "metrics":
1707
2010
  return;
1708
2011
  }
@@ -1792,46 +2095,83 @@ export class LobbyView implements Component, Focusable {
1792
2095
  else if (session?.awaitingAnswers) badges.plan = `${session.questions.length}?`;
1793
2096
  if (this.host.quickfix.running) badges.quickfix = spinner(this.tick);
1794
2097
  if (this.issuesOn && this.host.issues.issues.length > 0) badges.issues = String(this.host.issues.issues.length);
1795
- const brand = bold(theme, paint(theme, "accent", " ◆ bot-lobby "));
1796
- let left = `${brand}${paint(theme, "borderMuted", "│")}`;
1797
- const spans: Array<{ tab: TabId; from: number; to: number }> = [];
1798
- for (const [index, tab] of this.tabs().entries()) {
2098
+ if (this.host.reviews.busy) badges.git = spinner(this.tick);
2099
+ else if (this.host.pulls.pulls.length > 0) badges.git = String(this.host.pulls.pulls.length);
2100
+ const cells = this.tabs().map((tab, index) => {
1799
2101
  const number = paint(theme, tab === this.tab ? "accent" : "dim", String(index + 1));
1800
2102
  const name = tab === this.tab ? bold(theme, paint(theme, "text", TAB_LABELS[tab])) : paint(theme, "muted", TAB_LABELS[tab]);
1801
2103
  const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
1802
- let cell = ` ${number} ${name}${badge} `;
1803
- if (tab === this.tab) cell = theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`;
1804
- const from = textWidth(left);
1805
- left += cell;
1806
- spans.push({ tab, from, to: textWidth(left) });
1807
- }
1808
- this.tabSpans = spans;
1809
- const room = width - textWidth(left) - 1;
2104
+ const cell = ` ${number} ${name}${badge} `;
2105
+ return { tab, text: tab === this.tab ? (theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`) : cell };
2106
+ });
1810
2107
  const entry = this.viewedEntry();
1811
2108
  const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
1812
2109
  const auto = entry.auto ? ` ${bold(theme, paint(theme, "success", "⟳ AUTO"))}` : "";
1813
2110
  const waiting = this.host.sessions().reduce((count, session) => count + session.dialogs.length, 0);
1814
2111
  const asking = waiting > 0 ? ` ${paint(theme, "warning", `● ${waiting} waiting · ${keyLabel(this.keys.sessions)}`)}` : "";
2112
+ // Short of room, the key to open the sessions goes before the waiting count does.
2113
+ const asked = waiting > 0 ? ` ${paint(theme, "warning", `● ${waiting} waiting`)}` : "";
1815
2114
  let choices: string[];
1816
2115
  if (entry.view.kind !== "here") {
1817
2116
  // Another session in view: its name and what it is doing lead, so it is never mistaken for this window.
1818
2117
  const working = entry.status === "working";
1819
2118
  const dot = paint(theme, working ? "accent" : "dim", working ? spinner(this.tick) : "◆");
1820
2119
  const where = paint(theme, "muted", `${entry.name} · ${entry.status}`);
1821
- choices = [`${dot} ${where}${auto}${asking} ${help}`, `${dot} ${where}${auto}${asking} `, `${dot} ${where}${auto} `, `${dot}${auto} `];
2120
+ choices = [`${dot} ${where}${auto}${asking} ${help}`, `${dot} ${where}${auto}${asking} `, `${dot} ${where}${auto}${asked} `, `${dot}${auto}${asked} `, `${dot} ${where}${auto} `, `${dot}${auto} `];
1822
2121
  } else {
1823
2122
  const dot = paint(theme, this.host.masterBusy() ? "accent" : "dim", this.host.masterBusy() ? spinner(this.tick) : "●");
1824
2123
  const state = zen.task ? paint(theme, "muted", zen.task.paused ? `${zen.task.state} (paused)` : zen.task.state) : "";
1825
2124
  // Long task ids give way to the state, then to the dot alone.
1826
2125
  choices = zen.task
1827
- ? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
1828
- : [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, help, ""];
2126
+ ? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${zen.task.id} ${state}${auto}${asked} `, `${dot} ${state}${auto}${asked} `, `${dot}${auto}${asked} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
2127
+ : [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, `${paint(theme, "dim", "no task in this session")}${asked} `, `${asked.trimStart()} `, help, ""];
2128
+ }
2129
+ // The longest title that leaves room for the tabs and, when a session waits on you, for saying so.
2130
+ const tabsWidth = cells.reduce((sum, cell) => sum + textWidth(cell.text), 0);
2131
+ const titles = this.titles(entry.task, theme);
2132
+ let brand = titles.at(-1)!;
2133
+ let status = "";
2134
+ for (const [index, title] of titles.entries()) {
2135
+ const room = width - textWidth(title) - 1 - tabsWidth - 1;
2136
+ const shown = choices.find((choice) => textWidth(choice) <= room) ?? "";
2137
+ if (index === titles.length - 1 || (room >= 0 && (waiting === 0 || shown.includes("waiting")))) {
2138
+ brand = title;
2139
+ status = shown;
2140
+ break;
2141
+ }
2142
+ }
2143
+ let left = `${brand}${paint(theme, "borderMuted", "│")}`;
2144
+ const spans: Array<{ tab: TabId; from: number; to: number }> = [];
2145
+ for (const cell of cells) {
2146
+ const from = textWidth(left);
2147
+ left += cell.text;
2148
+ spans.push({ tab: cell.tab, from, to: textWidth(left) });
1829
2149
  }
1830
- const status = choices.find((choice) => textWidth(choice) <= room) ?? "";
2150
+ this.tabSpans = spans;
1831
2151
  const gap = width - textWidth(left) - textWidth(status);
1832
2152
  return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
1833
2153
  }
1834
2154
 
2155
+ /**
2156
+ * The lobby's title, longest first: the repository (or folder) it works
2157
+ * from and the branch, `◆ my-repo (⎇ main)`, then the name alone, then just
2158
+ * the mark. A task with a worktree of its own shows that branch, since that
2159
+ * is where its work happens.
2160
+ */
2161
+ private titles(task: Task | undefined, theme: LobbyTheme): string[] {
2162
+ const workspace = this.host.workspace?.();
2163
+ const name = clip(workspace?.name || "bot-lobby", TITLE_NAME_MAX);
2164
+ const own = task?.git?.mode === "worktree" ? task.git.branch : undefined;
2165
+ const branch = own ?? workspace?.branch;
2166
+ const glyph = paint(theme, "accent", " ◆ ");
2167
+ const label = (text: string) => bold(theme, paint(theme, "accent", text));
2168
+ return [
2169
+ ...(branch ? [`${glyph}${label(name)} ${paint(theme, "muted", `(${BRANCH_GLYPH} ${clip(branch, TITLE_BRANCH_MAX)})`)} `] : []),
2170
+ `${glyph}${label(name)} `,
2171
+ glyph,
2172
+ ];
2173
+ }
2174
+
1835
2175
  private promptLabel(): string {
1836
2176
  const zen = this.host.zen();
1837
2177
  const browsing = this.mode === "browse";
@@ -1866,6 +2206,18 @@ export class LobbyView implements Component, Focusable {
1866
2206
  return this.host.quickfix.running ? "describe a quick change; it runs after the current one" : "describe a quick change";
1867
2207
  case "issues":
1868
2208
  return this.issueDraft ? "new issue · first line is the title" : "n files a new issue";
2209
+ case "knowledge": {
2210
+ const picked = this.pickedEntry();
2211
+ if (this.kDraft?.kind === "edit") return `edit “${clip(picked?.text ?? "", 40)}” · enter saves it · esc cancels`;
2212
+ if (this.kDraft?.kind === "comment") return `a note on “${clip(picked?.text ?? "", 40)}” — every agent reads it under the entry · enter saves`;
2213
+ 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
+ return "e edits the picked entry, c comments on it, n adds one";
2215
+ }
2216
+ case "git": {
2217
+ const pull = this.selectedPull();
2218
+ if (this.gitFocusDraft) return `what should the review of #${pull?.number ?? "?"} look at first? · enter reviews`;
2219
+ return pull ? `f reviews #${pull.number} with a focus you type, v without one` : "r loads the open pull requests";
2220
+ }
1869
2221
  case "metrics":
1870
2222
  return "";
1871
2223
  }
@@ -1953,6 +2305,28 @@ export class LobbyView implements Component, Focusable {
1953
2305
  { key: "n", text: "file a new issue" },
1954
2306
  { key: "r", text: "reload issues" },
1955
2307
  ];
2308
+ case "knowledge":
2309
+ return [
2310
+ { key: "↑ ↓", text: "pick a file, or (in the entries) pick an entry; PageUp/PageDown several" },
2311
+ { key: "enter / ← →", text: "move between the files and their entries" },
2312
+ { key: "e", text: "edit the picked entry: it comes into the prompt (Shift+Enter for a new line, enter saves)" },
2313
+ { key: "c", text: "comment on it: a note every agent reads under that entry" },
2314
+ { key: "n", text: "add an entry after the picked one" },
2315
+ { key: "d d", text: "delete the picked entry, and its notes" },
2316
+ { key: "x x", text: "take back the newest note on the picked entry" },
2317
+ { key: "E", text: "edit the whole file in pi's editor" },
2318
+ { key: "r", text: "reread the files from disk" },
2319
+ ];
2320
+ case "git":
2321
+ return [
2322
+ { key: "↑ ↓", text: "select a pull request, or scroll its detail (PageUp/PageDown)" },
2323
+ { key: "enter / ← →", text: "move between the list and the detail" },
2324
+ { key: "v", text: "review it with a read-only agent on QA's model (nothing is posted to GitHub)" },
2325
+ { key: "f", text: "review it with a focus you type first (Shift+Enter for a new line, enter reviews)" },
2326
+ { key: "t", text: "Jev's quick read: size, risk, security, breaking changes, missing tests" },
2327
+ { key: "x", text: "stop the review that is running" },
2328
+ { key: "r", text: "reload the pull requests" },
2329
+ ];
1956
2330
  case "metrics":
1957
2331
  return [
1958
2332
  { key: "↑ ↓", text: "select a row of the table" },
@@ -1978,7 +2352,11 @@ export class LobbyView implements Component, Focusable {
1978
2352
  if (this.help) return fit(`${badge("KEYS", "accent")} ${this.chips([["any key", "closes"]], theme)}`, width);
1979
2353
  if (this.searching) return fit(`${badge("SEARCH", "warning")} ${this.chips([["enter", "keep"], ["esc", "clear"], ["↑↓", "results"], [k("nextTab"), "next tab"]], theme)}`, width);
1980
2354
  const mode = this.mode === "type" ? badge("TYPE", "accent") : badge("BROWSE", "muted");
1981
- return fit(`${mode} ${this.chips(this.hintChips(), theme)}`, width);
2355
+ const keys = `${mode} ${this.chips(this.hintChips(), theme)}`;
2356
+ // The subagents at work sit at the right end of the line, when the keys leave room.
2357
+ const working = this.viewing.kind === "here" ? agentsIndicator(workingAgents(this.host.zen().runs, this.host.quickfix.running), this.now(), spinner(this.tick), width - textWidth(keys) - 3, theme) : "";
2358
+ const gap = width - textWidth(keys) - textWidth(working) - 1;
2359
+ return working && gap >= 2 ? `${keys}${" ".repeat(gap)}${working} ` : fit(keys, width);
1982
2360
  }
1983
2361
 
1984
2362
  /**
@@ -1999,6 +2377,8 @@ export class LobbyView implements Component, Focusable {
1999
2377
  }
2000
2378
  if (this.tab === "lobby" && this.host.masterBusy()) return [["enter", "steer"], ["esc", "stop the oracle"], [k("hide"), "hide"]];
2001
2379
  if (this.tab === "plan" && this.lineTarget) return [["enter", "add the comment"], ["esc", "cancel"]];
2380
+ 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"]];
2381
+ if (this.tab === "git" && this.gitFocusDraft) return [["enter", "review with this focus"], ["shift+enter", "new line"], ["esc", "cancel"]];
2002
2382
  const enter = this.tab === "plan" && session?.awaitingAnswers && !this.editor.getText().trim() ? "answer questions" : this.tab === "quickfix" ? "run it" : "send";
2003
2383
  return [["enter", enter], ["esc", "browse"], [k("hide"), "hide"]];
2004
2384
  }
@@ -2034,6 +2414,19 @@ export class LobbyView implements Component, Focusable {
2034
2414
  case "issues":
2035
2415
  keys.push(["↑↓", "select"], ["p", "plan it"], ["n", "new"]);
2036
2416
  break;
2417
+ case "knowledge":
2418
+ keys.push(["↑↓", "pick"], ["enter", this.kFocus === "list" ? "entries" : "files"]);
2419
+ if (this.kFocus === "detail" || this.pickedEntry()) keys.push(["e", "edit"], ["c", "comment"], ["n", "new"], ["d d", "delete"]);
2420
+ keys.push(["E", "edit file"]);
2421
+ break;
2422
+ case "git": {
2423
+ const pull = this.selectedPull();
2424
+ keys.push(["↑↓", "select"]);
2425
+ if (pull) keys.push(["v", "review"], ["f", "review with a focus"], ["t", "Jev's read"]);
2426
+ if (pull && this.host.reviews.running(pull.number)) keys.push(["x", "stop"]);
2427
+ keys.push(["r", "reload"]);
2428
+ break;
2429
+ }
2037
2430
  case "metrics":
2038
2431
  keys.push(["↑↓", "select"], ["g", "group"], ["s", "sort"]);
2039
2432
  break;
@@ -2054,6 +2447,7 @@ export class LobbyView implements Component, Focusable {
2054
2447
  ...row("Ctrl+C", "clear the prompt, or hide the lobby", inner),
2055
2448
  ...row("click", "a tab to open it, a pane to give it the keys, a draft plan line to comment on it", inner),
2056
2449
  ...row("wheel", "scroll the pane under the pointer", inner),
2450
+ ...row("prev next", "click on a long pane's bottom edge (▲ prev · page 2/5 · next ▼) to move a page; dimmed at either end", inner),
2057
2451
  ];
2058
2452
  const modes = (inner: number) => [
2059
2453
  section("Typing"),
@@ -2127,6 +2521,26 @@ export class LobbyView implements Component, Focusable {
2127
2521
  loading: issues.loading, loaded: issues.loaded, ...(issues.error ? { error: issues.error } : {}), ...(issues.notice ? { notice: issues.notice } : {}), tick: this.tick, now,
2128
2522
  }, width, height, theme);
2129
2523
  }
2524
+ case "knowledge": {
2525
+ const knowledge = this.data.knowledge;
2526
+ this.kSelected = Math.min(this.kSelected, Math.max(0, knowledge.files.length - 1));
2527
+ this.kCursor = Math.min(this.kCursor, Math.max(0, (knowledge.view?.entries.length ?? 1) - 1));
2528
+ const lines = renderKnowledge({ files: knowledge.files, selected: this.kSelected, ...(knowledge.view ? { view: knowledge.view } : {}), cursor: this.kCursor, focus: this.kFocus, offset: this.kOffset, layout: this.kLayout }, width, height, theme);
2529
+ this.kOffset = this.kLayout.offset;
2530
+ return lines;
2531
+ }
2532
+ case "git": {
2533
+ const pulls = this.host.pulls;
2534
+ this.gitSelected = Math.min(this.gitSelected, Math.max(0, pulls.pulls.length - 1));
2535
+ const selected = pulls.pulls[this.gitSelected];
2536
+ const detail = selected ? pulls.details.get(selected.number) : undefined;
2537
+ const review = selected ? this.host.reviews.review(selected.number) : undefined;
2538
+ const read = selected ? this.host.reviews.reads.get(selected.number) : undefined;
2539
+ return renderGit({
2540
+ pulls: pulls.pulls, selected: this.gitSelected, ...(detail ? { detail } : {}), ...(review ? { review } : {}), ...(read ? { read } : {}),
2541
+ focus: this.gitFocus, detailOffset: this.gitDetailOffset, loading: pulls.loading, loaded: pulls.loaded, ...(pulls.error ? { error: pulls.error } : {}), tick: this.tick, now,
2542
+ }, width, height, theme);
2543
+ }
2130
2544
  case "metrics": {
2131
2545
  const { records, groups, taskTimes, stats, jev } = this.metricsFigures(query);
2132
2546
  this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
@@ -2183,6 +2597,7 @@ export class LobbyView implements Component, Focusable {
2183
2597
  nextMode: session.nextMode,
2184
2598
  ...(session.seed ? { seed: session.seed } : {}),
2185
2599
  ...(session.saved ? { saved: session.saved } : {}),
2600
+ ...(session.savedParts.length > 1 ? { savedParts: session.savedParts.length } : {}),
2186
2601
  ...(session.title ? { title: session.title } : {}),
2187
2602
  awaitingAnswers: session.awaitingAnswers,
2188
2603
  answeredChunks: session.answered.length,