@a-t-h-i/bot-lobby 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +264 -68
- package/package.json +5 -2
- package/prompts/panel.md +13 -4
- package/prompts/planner.md +31 -13
- package/src/execution/pi-runner.ts +1 -1
- package/src/index.ts +4 -2
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/feed.ts +65 -9
- package/src/lobby/keys.ts +55 -0
- package/src/lobby/layout.ts +208 -4
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +135 -17
- package/src/lobby/runtime.ts +353 -66
- package/src/lobby/session-files.ts +126 -0
- package/src/lobby/sessions.ts +288 -0
- package/src/lobby/tabs/home.ts +225 -59
- package/src/lobby/tabs/metrics.ts +155 -42
- package/src/lobby/tabs/plan.ts +128 -51
- package/src/lobby/tabs/quickfix.ts +43 -18
- package/src/lobby/tabs/tasks.ts +221 -54
- package/src/lobby/view.ts +1303 -143
- package/src/pi/commands.ts +35 -7
- package/src/pi/events.ts +5 -2
- package/src/pi/notify.ts +18 -0
- package/src/pi/owner.ts +216 -0
- package/src/pi/settings-ui.ts +69 -1
- package/src/pi/start-task.ts +33 -2
- package/src/pi/ui.ts +3 -3
- package/src/pi/zen-large.ts +9 -1
- package/src/pi/zen.ts +7 -3
- package/src/schemas/configuration.ts +43 -4
- package/src/schemas/task.ts +2 -0
- package/src/state/auto.ts +40 -0
- package/src/state/inbox.ts +90 -0
- package/src/workflow/workflow.ts +27 -3
package/src/lobby/view.ts
CHANGED
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The lobby: a full-screen, tabbed view over everything bot-lobby does. A tab
|
|
3
|
-
* bar on top, the active tab's body, a
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
3
|
+
* bar on top, the active tab's body, a search bar while one is in force, a
|
|
4
|
+
* prompt at the bottom whose target follows the tab (the oracle, a plan
|
|
5
|
+
* comment, the planner, a comment on one draft line, a quick fix) and a
|
|
6
|
+
* key-hint line. Two modes, like a modal editor: typing mode sends keys to the
|
|
7
|
+
* prompt; browsing mode (esc) moves through lists and runs the tab's
|
|
8
|
+
* single-key commands. The shortcuts in `keys.ts` (hide, help, search, tabs,
|
|
9
|
+
* pane toggles, paging) work in both. Everything bot-lobby-specific arrives
|
|
10
|
+
* through `LobbyHost`, so the view renders and reacts the same in tests.
|
|
9
11
|
*/
|
|
10
|
-
import { decodeKittyPrintable, Editor, Key, matchesKey, visibleWidth, type Component, type EditorTheme, type Focusable, type TUI } from "@earendil-works/pi-tui";
|
|
12
|
+
import { decodeKittyPrintable, Editor, Input, Key, matchesKey, visibleWidth, type Component, type EditorTheme, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
11
13
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
12
14
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
13
15
|
import type { PlannedTask } from "../state/backlog.ts";
|
|
14
16
|
import type { PlanComment } from "../state/comments.ts";
|
|
15
17
|
import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
|
|
16
|
-
import { PANEL_MEMBERS, type LobbyAgentKind, type PanelMember } from "../schemas/configuration.ts";
|
|
17
|
-
import type { LobbyFeed } from "./feed.ts";
|
|
18
|
+
import { PANEL_MEMBERS, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
|
|
19
|
+
import type { ChatEntry, LobbyFeed } from "./feed.ts";
|
|
20
|
+
import type { BackgroundSession } from "./sessions.ts";
|
|
18
21
|
import type { QuickFixQueue } from "./quickfix.ts";
|
|
19
22
|
import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
|
|
20
23
|
import { issueText, type IssuesState } from "./issues.ts";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
|
|
25
|
+
import { beside, bold, box, fit, highlight, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
|
|
26
|
+
import { HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
|
|
27
|
+
import { filterRows, planDetailLines, renderTasks, taskDetailLines, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
|
|
28
|
+
import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
|
|
29
|
+
import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
|
|
26
30
|
import { renderIssues } from "./tabs/issues.ts";
|
|
27
|
-
import { renderMetrics } from "./tabs/metrics.ts";
|
|
31
|
+
import { filterRecords, renderMetrics } from "./tabs/metrics.ts";
|
|
28
32
|
|
|
29
33
|
export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics"] as const;
|
|
30
34
|
export type TabId = (typeof TAB_IDS)[number];
|
|
@@ -38,11 +42,36 @@ export const TAB_LABELS: Record<TabId, string> = {
|
|
|
38
42
|
metrics: "Metrics",
|
|
39
43
|
};
|
|
40
44
|
|
|
45
|
+
/** The tabs on show: Issues only while `lobby.issues` switches it on. */
|
|
46
|
+
export function visibleTabs(issues: boolean): TabId[] {
|
|
47
|
+
return TAB_IDS.filter((tab) => issues || tab !== "issues");
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
/** Tabs where the prompt is the point: they open in typing mode and typing in browsing mode resumes it. */
|
|
42
51
|
const PROMPT_FIRST: ReadonlySet<TabId> = new Set(["lobby", "plan", "quickfix"]);
|
|
43
52
|
|
|
44
53
|
export type LobbyMode = "type" | "browse";
|
|
45
54
|
|
|
55
|
+
/** What each pane toggle is called in notices and help. */
|
|
56
|
+
const PANEL_NAMES: Record<LobbyPanel, string> = { animations: "oracle and agent animations", conversation: "conversation", activity: "activity log", thinking: "thinking" };
|
|
57
|
+
const PANEL_ACTIONS: Record<LobbyPanel, LobbyAction> = { animations: "toggleScene", conversation: "toggleConversation", activity: "toggleActivity", thinking: "toggleThinking" };
|
|
58
|
+
|
|
59
|
+
/** Which session the Lobby tab shows and talks to: this window, one it started in the background, or one in another terminal. */
|
|
60
|
+
export type SessionView = { kind: "here" } | { kind: "background"; key: string } | { kind: "other"; taskId: string };
|
|
61
|
+
|
|
62
|
+
/** One row of the session switcher. */
|
|
63
|
+
export interface SessionEntry {
|
|
64
|
+
view: SessionView;
|
|
65
|
+
name: string;
|
|
66
|
+
where: "this window" | "background" | "other terminal";
|
|
67
|
+
task?: Task;
|
|
68
|
+
/** What it is doing: working, idle, starting, ended, or its task's state. */
|
|
69
|
+
status: string;
|
|
70
|
+
auto: boolean;
|
|
71
|
+
/** Questions it waits on you for. */
|
|
72
|
+
waiting: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
/** Everything the view needs from pi and bot-lobby. */
|
|
47
76
|
export interface LobbyHost {
|
|
48
77
|
rows(): number;
|
|
@@ -50,8 +79,8 @@ export interface LobbyHost {
|
|
|
50
79
|
sessionId(): string | undefined;
|
|
51
80
|
/** This session's active task and its runs, as the zen widget sees them. */
|
|
52
81
|
zen(): { task?: Task; runs: readonly AgentRun[] };
|
|
53
|
-
/** Draw the zen scene into at most `height` lines. */
|
|
54
|
-
scene(width: number, height: number): string[];
|
|
82
|
+
/** Draw the zen scene into at most `height` lines: animated, or only the task's status when `animated` is false. */
|
|
83
|
+
scene(width: number, height: number, animated: boolean): string[];
|
|
55
84
|
/** Advance the scene's clock; returns the delay it wants until the next step. */
|
|
56
85
|
advanceScene(now: number): number;
|
|
57
86
|
feed: LobbyFeed;
|
|
@@ -71,11 +100,39 @@ export interface LobbyHost {
|
|
|
71
100
|
planner(): PlanningSession | undefined;
|
|
72
101
|
/** Start a planning session (replacing any other) with these seats on the panel. */
|
|
73
102
|
newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
|
|
103
|
+
/** The oracle puts the panel's open questions to the user, one questionnaire at a time; returns a notice. */
|
|
104
|
+
answerPanel(): Promise<string>;
|
|
74
105
|
/** The seats a new session starts with, from settings. */
|
|
75
106
|
defaultPanel(): readonly PanelMember[];
|
|
76
107
|
/** `model · thinking` a panel seat runs on. */
|
|
77
108
|
seatLabel(member: PanelMember): string;
|
|
78
109
|
issues: IssuesState;
|
|
110
|
+
/** The Issues tab is switched on (`lobby.issues`). */
|
|
111
|
+
issuesEnabled(): boolean;
|
|
112
|
+
/** Which panes show (`lobby.panels`), and remembering a change. */
|
|
113
|
+
panels(): Record<LobbyPanel, boolean>;
|
|
114
|
+
savePanels(panels: Record<LobbyPanel, boolean>): void;
|
|
115
|
+
/** Key overrides from the config (`lobby.keys`). */
|
|
116
|
+
keys(): Readonly<Record<string, string>>;
|
|
117
|
+
/** Open bot-lobby's settings (or one agent's entry); the lobby steps aside and rereads the config after. */
|
|
118
|
+
openSettings(entry?: "quickfix" | "planner"): Promise<void>;
|
|
119
|
+
/** This window's pi session name, when it has one. */
|
|
120
|
+
sessionName(): string | undefined;
|
|
121
|
+
/** Background sessions this window started, oldest first. */
|
|
122
|
+
sessions(): readonly BackgroundSession[];
|
|
123
|
+
/** Start a task in a new background session, named after the task; the session, or why not. */
|
|
124
|
+
startSession(start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string;
|
|
125
|
+
/** Put the oldest question a background session waits on to the user in this window. */
|
|
126
|
+
answerDialog(session: BackgroundSession): Promise<void>;
|
|
127
|
+
/** Auto mode for any task, whichever session drives it. */
|
|
128
|
+
isAuto(taskId: string): boolean;
|
|
129
|
+
setAuto(taskId: string, on: boolean): void;
|
|
130
|
+
/** Leave a message for the oracle of a task another terminal's session drives; returns a notice. */
|
|
131
|
+
sendToTask(taskId: string, text: string): string;
|
|
132
|
+
/** Another session's conversation, read from its saved session file. */
|
|
133
|
+
sessionChat(sessionId: string): ChatEntry[];
|
|
134
|
+
/** A task's status without animations, for a session other than this window's. */
|
|
135
|
+
taskScene(task: Task, width: number, height: number): string[];
|
|
79
136
|
profileLabel(kind: LobbyAgentKind): string;
|
|
80
137
|
requestRender(): void;
|
|
81
138
|
now?(): number;
|
|
@@ -87,6 +144,8 @@ export const IDLE_MS = 1000;
|
|
|
87
144
|
/** How often task and metrics data is reread from disk while the lobby is open. */
|
|
88
145
|
export const DATA_REFRESH_MS = 2000;
|
|
89
146
|
const NOTICE_MS = 6000;
|
|
147
|
+
/** Lines one wheel notch scrolls. */
|
|
148
|
+
const WHEEL_LINES = 3;
|
|
90
149
|
|
|
91
150
|
/** The prompt editor, with its target written into the top border. */
|
|
92
151
|
class LobbyEditor extends Editor {
|
|
@@ -112,17 +171,52 @@ interface Notice {
|
|
|
112
171
|
kind: "info" | "warning";
|
|
113
172
|
}
|
|
114
173
|
|
|
174
|
+
/** A key and what it does, for the help screen. */
|
|
175
|
+
interface KeyHelp {
|
|
176
|
+
key: string;
|
|
177
|
+
text: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
115
180
|
function isPrintable(data: string): string | undefined {
|
|
116
181
|
const kitty = decodeKittyPrintable(data);
|
|
117
182
|
if (kitty !== undefined) return kitty;
|
|
118
183
|
return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) !== 127 ? data : undefined;
|
|
119
184
|
}
|
|
120
185
|
|
|
186
|
+
/** A mouse report in SGR encoding (`ESC [ < b ; x ; y M` or `m`), zero-based cells. */
|
|
187
|
+
export interface MouseReport {
|
|
188
|
+
kind: "press" | "release" | "wheel";
|
|
189
|
+
button: number;
|
|
190
|
+
x: number;
|
|
191
|
+
y: number;
|
|
192
|
+
/** Wheel direction: -1 up, 1 down. */
|
|
193
|
+
delta: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
197
|
+
|
|
198
|
+
/** Parse an SGR mouse report, the form terminals send once the lobby turns mouse reporting on. */
|
|
199
|
+
export function parseMouse(data: string): MouseReport | undefined {
|
|
200
|
+
const match = SGR_MOUSE.exec(data);
|
|
201
|
+
if (!match) return undefined;
|
|
202
|
+
const code = Number(match[1]);
|
|
203
|
+
const x = Number(match[2]) - 1;
|
|
204
|
+
const y = Number(match[3]) - 1;
|
|
205
|
+
if (code & 64) return { kind: "wheel", button: code, x, y, delta: (code & 1) === 1 ? 1 : -1 };
|
|
206
|
+
return { kind: match[4] === "M" ? "press" : "release", button: code & 3, x, y, delta: 0 };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function clip(text: string, max: number): string {
|
|
210
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
211
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
212
|
+
}
|
|
213
|
+
|
|
121
214
|
export class LobbyView implements Component, Focusable {
|
|
122
215
|
tab: TabId = "lobby";
|
|
123
216
|
mode: LobbyMode = "type";
|
|
124
217
|
private readonly host: LobbyHost;
|
|
125
218
|
private readonly editor: LobbyEditor;
|
|
219
|
+
private readonly search: Input;
|
|
126
220
|
private focusedState = false;
|
|
127
221
|
private tick = 0;
|
|
128
222
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -131,8 +225,35 @@ export class LobbyView implements Component, Focusable {
|
|
|
131
225
|
private readonly drafts: Partial<Record<TabId, string>> = {};
|
|
132
226
|
/** Double-press confirmations, such as discarding a plan. */
|
|
133
227
|
private armed: string | undefined;
|
|
228
|
+
/** The keys, the panes on show and whether Issues is on, read once from the config. */
|
|
229
|
+
private keys: KeyMap;
|
|
230
|
+
private panels: Record<LobbyPanel, boolean>;
|
|
231
|
+
private issuesOn: boolean;
|
|
232
|
+
/** The help screen covers the body. */
|
|
233
|
+
help = false;
|
|
234
|
+
/** The session switcher covers the body. */
|
|
235
|
+
picking = false;
|
|
236
|
+
private pickIndex = 0;
|
|
237
|
+
/** The session the Lobby tab shows and talks to. */
|
|
238
|
+
viewing: SessionView = { kind: "here" };
|
|
239
|
+
/** The Lobby prompt starts a task in a new session instead of talking to an oracle. */
|
|
240
|
+
newSession = false;
|
|
241
|
+
/** The search bar has the keys. */
|
|
242
|
+
searching = false;
|
|
243
|
+
/** Each tab's search, kept while you move between tabs. */
|
|
244
|
+
private readonly queries: Partial<Record<TabId, string>> = {};
|
|
245
|
+
/** Where the body starts and where each tab's label sits in the tab bar, for clicks. */
|
|
246
|
+
private bodyTop = 1;
|
|
247
|
+
private promptTop = Number.POSITIVE_INFINITY;
|
|
248
|
+
private tabSpans: Array<{ tab: TabId; from: number; to: number }> = [];
|
|
134
249
|
|
|
135
|
-
|
|
250
|
+
/** Each Lobby pane's scroll, in lines back from its newest, and the pane the keys scroll. */
|
|
251
|
+
private readonly homeOffsets: Record<HomePane, number> = { conversation: 0, activity: 0, thinking: 0 };
|
|
252
|
+
homeFocus: HomePane = "conversation";
|
|
253
|
+
/** How many lines each pane held last frame, so a pane scrolled back stays on what you are reading as lines arrive. */
|
|
254
|
+
private readonly seenTotals = new Map<string, number>();
|
|
255
|
+
/** Where the current tab's scrollable panes landed in the last frame. */
|
|
256
|
+
private readonly panes: PaneLayout = new Map();
|
|
136
257
|
private tasksSelected = 0;
|
|
137
258
|
private tasksFocus: "list" | "detail" = "list";
|
|
138
259
|
private tasksDetailOffset = 0;
|
|
@@ -140,6 +261,10 @@ export class LobbyView implements Component, Focusable {
|
|
|
140
261
|
private planOffset = 0;
|
|
141
262
|
private planFocus: "talk" | "draft" = "talk";
|
|
142
263
|
private planDraftOffset = 0;
|
|
264
|
+
/** The draft line the cursor is on, and the one a comment is being written for. */
|
|
265
|
+
planCursor = 0;
|
|
266
|
+
private lineTarget: string | undefined;
|
|
267
|
+
private readonly planLayout: PlanLayout = { draftTop: 0, draftLeft: 0, draftWidth: 0, draftRows: 0, draftStart: 0, draftText: [] };
|
|
143
268
|
/** Seats for the next planning session, until one exists. */
|
|
144
269
|
private seats: Set<PanelMember> | undefined;
|
|
145
270
|
private fixSelected = 0;
|
|
@@ -165,6 +290,10 @@ export class LobbyView implements Component, Focusable {
|
|
|
165
290
|
this.host = host;
|
|
166
291
|
this.editor = new LobbyEditor(tui, editorTheme, { paddingX: 1 });
|
|
167
292
|
this.editor.onSubmit = (text) => this.submit(text);
|
|
293
|
+
this.search = new Input({ prompt: "", placeholder: "type to search this tab" });
|
|
294
|
+
this.keys = keyMap(host.keys());
|
|
295
|
+
this.panels = { ...host.panels() };
|
|
296
|
+
this.issuesOn = host.issuesEnabled();
|
|
168
297
|
}
|
|
169
298
|
|
|
170
299
|
get focused(): boolean {
|
|
@@ -173,7 +302,12 @@ export class LobbyView implements Component, Focusable {
|
|
|
173
302
|
|
|
174
303
|
set focused(value: boolean) {
|
|
175
304
|
this.focusedState = value;
|
|
176
|
-
this.
|
|
305
|
+
this.syncFocus();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private syncFocus(): void {
|
|
309
|
+
this.editor.focused = this.focusedState && this.mode === "type" && !this.searching;
|
|
310
|
+
this.search.focused = this.focusedState && this.searching;
|
|
177
311
|
}
|
|
178
312
|
|
|
179
313
|
private now(): number {
|
|
@@ -210,7 +344,8 @@ export class LobbyView implements Component, Focusable {
|
|
|
210
344
|
return this.host.masterBusy()
|
|
211
345
|
|| Boolean(this.host.quickfix.running)
|
|
212
346
|
|| Boolean(this.host.planner()?.busy)
|
|
213
|
-
|| this.host.
|
|
347
|
+
|| this.host.sessions().some((session) => session.busy)
|
|
348
|
+
|| (this.issuesOn && this.host.issues.loading)
|
|
214
349
|
|| zen.runs.some((run) => run.status === "running")
|
|
215
350
|
|| Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
|
|
216
351
|
}
|
|
@@ -221,7 +356,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
221
356
|
const now = this.now();
|
|
222
357
|
this.tick += 1;
|
|
223
358
|
let delay = this.isLive() ? LIVE_MS : IDLE_MS;
|
|
224
|
-
if (this.tab === "lobby" && this.host.zen().task) delay = Math.min(delay, this.host.advanceScene(now));
|
|
359
|
+
if (this.tab === "lobby" && this.panels.animations && this.host.zen().task) delay = Math.min(delay, this.host.advanceScene(now));
|
|
225
360
|
this.refreshData(false);
|
|
226
361
|
this.host.requestRender();
|
|
227
362
|
this.schedule(delay);
|
|
@@ -231,35 +366,48 @@ export class LobbyView implements Component, Focusable {
|
|
|
231
366
|
refreshData(force: boolean): void {
|
|
232
367
|
const now = this.now();
|
|
233
368
|
if (!force && now - this.data.at < DATA_REFRESH_MS) return;
|
|
234
|
-
const tasks = this.host.tasks();
|
|
235
|
-
const comments = new Map<string, PlanComment[]>();
|
|
236
|
-
if (this.tab === "tasks") {
|
|
237
|
-
const row = this.taskRowList(tasks)[this.tasksSelected];
|
|
238
|
-
if (row?.kind === "task") comments.set(row.id, this.host.comments(row.id));
|
|
239
|
-
}
|
|
240
369
|
this.data = {
|
|
241
|
-
tasks,
|
|
370
|
+
tasks: this.host.tasks(),
|
|
242
371
|
plans: this.host.plans(),
|
|
243
|
-
comments,
|
|
372
|
+
comments: new Map(),
|
|
244
373
|
metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
|
|
245
374
|
at: now,
|
|
246
375
|
};
|
|
376
|
+
if (this.tab === "tasks") {
|
|
377
|
+
const row = this.taskRowList()[this.tasksSelected];
|
|
378
|
+
if (row?.kind === "task") this.data.comments.set(row.id, this.host.comments(row.id));
|
|
379
|
+
}
|
|
247
380
|
}
|
|
248
381
|
|
|
249
382
|
private say(text: string, kind: Notice["kind"] = "info"): void {
|
|
250
383
|
this.notice = { text, at: this.now(), kind };
|
|
251
384
|
}
|
|
252
385
|
|
|
386
|
+
/** The search in force on this tab. */
|
|
387
|
+
query(): string | undefined {
|
|
388
|
+
return this.queries[this.tab]?.trim() || undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
253
391
|
/* ---------------------------------------------------------------- tabs */
|
|
254
392
|
|
|
393
|
+
tabs(): TabId[] {
|
|
394
|
+
return visibleTabs(this.issuesOn);
|
|
395
|
+
}
|
|
396
|
+
|
|
255
397
|
setTab(tab: TabId): void {
|
|
256
398
|
if (tab === this.tab) return;
|
|
399
|
+
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");
|
|
257
400
|
this.drafts[this.tab] = this.editor.getText();
|
|
258
401
|
this.tab = tab;
|
|
259
402
|
this.editor.setText(this.drafts[tab] ?? "");
|
|
260
403
|
this.commentTarget = undefined;
|
|
404
|
+
this.lineTarget = undefined;
|
|
261
405
|
this.issueDraft = false;
|
|
262
406
|
this.armed = undefined;
|
|
407
|
+
this.help = false;
|
|
408
|
+
this.picking = false;
|
|
409
|
+
this.newSession = false;
|
|
410
|
+
this.closeSearch();
|
|
263
411
|
this.setMode(PROMPT_FIRST.has(tab) ? "type" : "browse");
|
|
264
412
|
this.refreshData(true);
|
|
265
413
|
if (tab === "issues" && !this.host.issues.loaded && !this.host.issues.loading) void this.host.issues.refresh().then(() => this.loadIssueDetail());
|
|
@@ -267,19 +415,21 @@ export class LobbyView implements Component, Focusable {
|
|
|
267
415
|
}
|
|
268
416
|
|
|
269
417
|
private cycleTab(direction: 1 | -1): void {
|
|
270
|
-
const
|
|
271
|
-
|
|
418
|
+
const tabs = this.tabs();
|
|
419
|
+
const index = Math.max(0, tabs.indexOf(this.tab));
|
|
420
|
+
this.setTab(tabs[(index + direction + tabs.length) % tabs.length]!);
|
|
272
421
|
}
|
|
273
422
|
|
|
274
423
|
private setMode(mode: LobbyMode): void {
|
|
275
424
|
this.mode = this.tab === "metrics" ? "browse" : mode;
|
|
276
|
-
this.
|
|
425
|
+
this.syncFocus();
|
|
277
426
|
}
|
|
278
427
|
|
|
279
428
|
/** Open the prompt for a comment on `taskId`'s plan (from any tab). */
|
|
280
429
|
commentOn(taskId: string): void {
|
|
281
430
|
this.setTab("tasks");
|
|
282
|
-
|
|
431
|
+
delete this.queries.tasks;
|
|
432
|
+
const rows = this.taskRowList();
|
|
283
433
|
const index = rows.findIndex((row) => row.id === taskId);
|
|
284
434
|
if (index >= 0) this.tasksSelected = index;
|
|
285
435
|
this.refreshData(true);
|
|
@@ -297,6 +447,92 @@ export class LobbyView implements Component, Focusable {
|
|
|
297
447
|
void session.open();
|
|
298
448
|
}
|
|
299
449
|
|
|
450
|
+
/** Show or hide one pane and remember it in the config. */
|
|
451
|
+
togglePanel(panel: LobbyPanel): void {
|
|
452
|
+
const shown = !this.panels[panel];
|
|
453
|
+
this.panels = { ...this.panels, [panel]: shown };
|
|
454
|
+
this.host.savePanels(this.panels);
|
|
455
|
+
const key = keyLabel(this.keys[PANEL_ACTIONS[panel]]);
|
|
456
|
+
const where = this.tab === "lobby" ? "" : " on the Lobby tab";
|
|
457
|
+
this.say(`${PANEL_NAMES[panel]} ${shown ? "shown" : "hidden"}${where} · ${key} ${shown ? "hides" : "shows"} it`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
panelShown(panel: LobbyPanel): boolean {
|
|
461
|
+
return this.panels[panel];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Reread keys, panes and the Issues switch, after the settings changed them. */
|
|
465
|
+
reloadConfig(): void {
|
|
466
|
+
this.keys = keyMap(this.host.keys());
|
|
467
|
+
this.panels = { ...this.host.panels() };
|
|
468
|
+
this.issuesOn = this.host.issuesEnabled();
|
|
469
|
+
if (!this.tabs().includes(this.tab)) this.setTab("lobby");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private openSettings(entry?: "quickfix" | "planner"): void {
|
|
473
|
+
void this.host.openSettings(entry).then(() => this.host.requestRender());
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* -------------------------------------------------------------- search */
|
|
477
|
+
|
|
478
|
+
private openSearch(): void {
|
|
479
|
+
this.help = false;
|
|
480
|
+
this.searching = true;
|
|
481
|
+
this.search.setValue(this.queries[this.tab] ?? "");
|
|
482
|
+
this.syncFocus();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Close the search bar; the query stays in force unless `clear`. */
|
|
486
|
+
private closeSearch(clear = false): void {
|
|
487
|
+
if (clear) delete this.queries[this.tab];
|
|
488
|
+
this.searching = false;
|
|
489
|
+
this.syncFocus();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private searchKey(data: string): void {
|
|
493
|
+
if (matchesKey(data, Key.escape)) {
|
|
494
|
+
const had = Boolean(this.query());
|
|
495
|
+
this.closeSearch(true);
|
|
496
|
+
if (had) this.say("search cleared");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (matchesKey(data, Key.enter)) {
|
|
500
|
+
if (!this.query()) delete this.queries[this.tab];
|
|
501
|
+
return this.closeSearch();
|
|
502
|
+
}
|
|
503
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
|
|
504
|
+
// Keep the query, let the arrows move through what it found.
|
|
505
|
+
this.closeSearch();
|
|
506
|
+
return this.scroll(matchesKey(data, Key.up) ? -1 : 1);
|
|
507
|
+
}
|
|
508
|
+
this.search.handleInput(data);
|
|
509
|
+
const value = this.search.getValue();
|
|
510
|
+
if (value) this.queries[this.tab] = value;
|
|
511
|
+
else delete this.queries[this.tab];
|
|
512
|
+
this.resetSelections();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** A new search starts every list at its top. */
|
|
516
|
+
private resetSelections(): void {
|
|
517
|
+
for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
|
|
518
|
+
this.planOffset = 0;
|
|
519
|
+
switch (this.tab) {
|
|
520
|
+
case "tasks":
|
|
521
|
+
this.tasksSelected = 0;
|
|
522
|
+
this.tasksDetailOffset = 0;
|
|
523
|
+
return;
|
|
524
|
+
case "quickfix":
|
|
525
|
+
this.fixSelected = 0;
|
|
526
|
+
this.fixDetailOffset = 0;
|
|
527
|
+
return;
|
|
528
|
+
case "metrics":
|
|
529
|
+
this.metricsSelected = 0;
|
|
530
|
+
return;
|
|
531
|
+
default:
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
300
536
|
/* --------------------------------------------------------------- input */
|
|
301
537
|
|
|
302
538
|
handleInput(data: string): void {
|
|
@@ -305,22 +541,69 @@ export class LobbyView implements Component, Focusable {
|
|
|
305
541
|
}
|
|
306
542
|
|
|
307
543
|
private handleKey(data: string): void {
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
544
|
+
const mouse = parseMouse(data);
|
|
545
|
+
if (mouse) return this.mouseReport(mouse);
|
|
546
|
+
const action = actionFor(data, this.keys);
|
|
547
|
+
if (this.help) {
|
|
548
|
+
this.help = false;
|
|
549
|
+
// Esc, ?, q and the help key only close it; anything else also does its job.
|
|
550
|
+
if (action === "help" || matchesKey(data, Key.escape) || data === "?" || data === "q" || matchesKey(data, Key.enter)) return;
|
|
551
|
+
}
|
|
552
|
+
// The session switcher takes the keys while it is open (shortcuts other than its own still work).
|
|
553
|
+
if (this.picking && (!action || action === "sessions" || action === "newSession") && this.pickerKey(data, action)) return;
|
|
554
|
+
// While searching, the shortcuts still work; everything else edits the query.
|
|
555
|
+
if (this.searching) return action ? this.runAction(action) : this.searchKey(data);
|
|
556
|
+
if (action) return this.runAction(action);
|
|
557
|
+
const tabs = this.tabs();
|
|
558
|
+
for (const [index, tab] of tabs.entries()) {
|
|
312
559
|
if (matchesKey(data, Key.alt(String(index + 1) as "1"))) return this.setTab(tab);
|
|
313
560
|
}
|
|
314
561
|
if (matchesKey(data, Key.ctrl("c"))) {
|
|
315
562
|
if (this.editor.getText()) return this.editor.setText("");
|
|
316
563
|
return this.host.hide();
|
|
317
564
|
}
|
|
318
|
-
if (matchesKey(data, Key.pageUp)) return this.scroll(-10);
|
|
319
|
-
if (matchesKey(data, Key.pageDown)) return this.scroll(10);
|
|
320
565
|
if (this.mode === "type") return this.typeKey(data);
|
|
321
566
|
return this.browseKey(data);
|
|
322
567
|
}
|
|
323
568
|
|
|
569
|
+
private runAction(action: LobbyAction): void {
|
|
570
|
+
switch (action) {
|
|
571
|
+
case "hide":
|
|
572
|
+
return this.host.hide();
|
|
573
|
+
case "help":
|
|
574
|
+
this.help = !this.help;
|
|
575
|
+
return;
|
|
576
|
+
case "settings":
|
|
577
|
+
return this.openSettings();
|
|
578
|
+
case "savePlan":
|
|
579
|
+
return this.savePlan();
|
|
580
|
+
case "sessions":
|
|
581
|
+
return this.picking ? void (this.picking = false) : this.openPicker();
|
|
582
|
+
case "newSession":
|
|
583
|
+
return this.startNewSessionPrompt();
|
|
584
|
+
case "toggleAuto":
|
|
585
|
+
return this.toggleAuto();
|
|
586
|
+
case "search":
|
|
587
|
+
return this.searching ? this.closeSearch() : this.openSearch();
|
|
588
|
+
case "nextTab":
|
|
589
|
+
return this.cycleTab(1);
|
|
590
|
+
case "prevTab":
|
|
591
|
+
return this.cycleTab(-1);
|
|
592
|
+
case "toggleScene":
|
|
593
|
+
return this.togglePanel("animations");
|
|
594
|
+
case "toggleConversation":
|
|
595
|
+
return this.togglePanel("conversation");
|
|
596
|
+
case "toggleActivity":
|
|
597
|
+
return this.togglePanel("activity");
|
|
598
|
+
case "toggleThinking":
|
|
599
|
+
return this.togglePanel("thinking");
|
|
600
|
+
case "scrollUp":
|
|
601
|
+
return this.scroll(-this.pageSize());
|
|
602
|
+
case "scrollDown":
|
|
603
|
+
return this.scroll(this.pageSize());
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
324
607
|
private typeKey(data: string): void {
|
|
325
608
|
if (matchesKey(data, Key.escape)) {
|
|
326
609
|
if (this.tab === "lobby" && this.host.masterBusy() && !this.editor.getText().trim()) {
|
|
@@ -329,15 +612,32 @@ export class LobbyView implements Component, Focusable {
|
|
|
329
612
|
}
|
|
330
613
|
this.commentTarget = undefined;
|
|
331
614
|
this.issueDraft = false;
|
|
615
|
+
this.newSession = false;
|
|
616
|
+
if (this.lineTarget) {
|
|
617
|
+
this.lineTarget = undefined;
|
|
618
|
+
this.planFocus = "draft";
|
|
619
|
+
}
|
|
332
620
|
return this.setMode("browse");
|
|
333
621
|
}
|
|
334
622
|
this.editor.handleInput(data);
|
|
335
623
|
}
|
|
336
624
|
|
|
337
625
|
private browseKey(data: string): void {
|
|
626
|
+
if (matchesKey(data, Key.escape) && this.query()) {
|
|
627
|
+
delete this.queries[this.tab];
|
|
628
|
+
return this.say("search cleared");
|
|
629
|
+
}
|
|
630
|
+
if (data === "/") return this.openSearch();
|
|
631
|
+
if (data === "?") {
|
|
632
|
+
this.help = true;
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
338
635
|
if (matchesKey(data, Key.up) || (!PROMPT_FIRST.has(this.tab) && data === "k")) return this.scroll(-1);
|
|
339
636
|
if (matchesKey(data, Key.down) || (!PROMPT_FIRST.has(this.tab) && data === "j")) return this.scroll(1);
|
|
340
|
-
if (
|
|
637
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) return this.movePaneFocus(matchesKey(data, Key.left) ? -1 : 1);
|
|
638
|
+
if (matchesKey(data, Key.home) || matchesKey(data, Key.end)) return this.scrollToEdge(matchesKey(data, Key.home));
|
|
639
|
+
const tabs = this.tabs();
|
|
640
|
+
if (!PROMPT_FIRST.has(this.tab) && /^[1-9]$/.test(data) && Number(data) <= tabs.length) return this.setTab(tabs[Number(data) - 1]!);
|
|
341
641
|
if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues") return this.setMode("type");
|
|
342
642
|
if (this.tabCommand(data)) return;
|
|
343
643
|
const printable = isPrintable(data);
|
|
@@ -347,27 +647,82 @@ export class LobbyView implements Component, Focusable {
|
|
|
347
647
|
}
|
|
348
648
|
}
|
|
349
649
|
|
|
350
|
-
/**
|
|
351
|
-
private
|
|
650
|
+
/** The pane the keys scroll on this tab. */
|
|
651
|
+
private focusedPane(): string {
|
|
352
652
|
switch (this.tab) {
|
|
353
653
|
case "lobby":
|
|
354
|
-
this.
|
|
654
|
+
return this.homePane();
|
|
655
|
+
case "plan":
|
|
656
|
+
return this.planFocus;
|
|
657
|
+
case "tasks":
|
|
658
|
+
return this.tasksFocus;
|
|
659
|
+
case "quickfix":
|
|
660
|
+
return this.fixFocus;
|
|
661
|
+
case "issues":
|
|
662
|
+
return this.issueFocus;
|
|
663
|
+
case "metrics":
|
|
664
|
+
return "table";
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The focused Lobby pane, or the first one showing when it is hidden. */
|
|
669
|
+
private homePane(): HomePane {
|
|
670
|
+
const shown = this.shownHomePanes();
|
|
671
|
+
if (!shown.includes(this.homeFocus) && shown.length > 0) this.homeFocus = shown[0]!;
|
|
672
|
+
return this.homeFocus;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private shownHomePanes(): HomePane[] {
|
|
676
|
+
const drawn = HOME_PANES.filter((pane) => this.tab === "lobby" && this.panes.has(pane));
|
|
677
|
+
return drawn.length > 0 ? drawn : HOME_PANES.filter((pane) => this.panels[pane]);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** A page of the focused pane: its rows less one, so a line of context carries over. */
|
|
681
|
+
private pageSize(): number {
|
|
682
|
+
const pane = this.panes.get(this.focusedPane());
|
|
683
|
+
return pane && pane.rows > 2 ? pane.rows - 1 : 10;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** ← → move between the tab's panes. */
|
|
687
|
+
private movePaneFocus(direction: -1 | 1): void {
|
|
688
|
+
switch (this.tab) {
|
|
689
|
+
case "lobby": {
|
|
690
|
+
const shown = this.shownHomePanes();
|
|
691
|
+
if (shown.length === 0) return;
|
|
692
|
+
const index = Math.max(0, shown.indexOf(this.homePane()));
|
|
693
|
+
this.homeFocus = shown[(index + direction + shown.length) % shown.length]!;
|
|
355
694
|
return;
|
|
695
|
+
}
|
|
356
696
|
case "plan":
|
|
357
|
-
|
|
358
|
-
|
|
697
|
+
this.planFocus = direction < 0 ? "talk" : "draft";
|
|
698
|
+
if (this.planFocus === "draft") this.cursorToView();
|
|
359
699
|
return;
|
|
360
700
|
case "tasks":
|
|
361
|
-
|
|
362
|
-
else this.selectTask(this.tasksSelected + delta);
|
|
701
|
+
this.tasksFocus = direction < 0 ? "list" : "detail";
|
|
363
702
|
return;
|
|
364
703
|
case "quickfix":
|
|
365
|
-
|
|
366
|
-
else {
|
|
367
|
-
this.fixSelected = Math.max(0, Math.min(this.host.quickfix.jobs.length - 1, this.fixSelected + delta));
|
|
368
|
-
this.fixDetailOffset = 0;
|
|
369
|
-
}
|
|
704
|
+
this.fixFocus = direction < 0 ? "list" : "detail";
|
|
370
705
|
return;
|
|
706
|
+
case "issues":
|
|
707
|
+
this.issueFocus = direction < 0 ? "list" : "detail";
|
|
708
|
+
return;
|
|
709
|
+
case "metrics":
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Arrow keys (±1) and page keys (± a page): scroll or select in the tab's focused pane. */
|
|
715
|
+
private scroll(delta: number): void {
|
|
716
|
+
switch (this.tab) {
|
|
717
|
+
case "lobby":
|
|
718
|
+
return this.scrollPane(this.homePane(), delta);
|
|
719
|
+
case "plan":
|
|
720
|
+
return this.scrollPane(this.planFocus, delta);
|
|
721
|
+
case "tasks":
|
|
722
|
+
// Page keys always page the detail; the arrows follow the focus.
|
|
723
|
+
return this.scrollPane(Math.abs(delta) > 1 ? "detail" : this.tasksFocus, delta);
|
|
724
|
+
case "quickfix":
|
|
725
|
+
return this.scrollPane(Math.abs(delta) > 1 ? "detail" : this.fixFocus, delta);
|
|
371
726
|
case "issues":
|
|
372
727
|
if (this.issueFocus === "detail" || Math.abs(delta) > 1) this.issueDetailOffset = Math.max(0, this.issueDetailOffset + delta);
|
|
373
728
|
else {
|
|
@@ -377,13 +732,160 @@ export class LobbyView implements Component, Focusable {
|
|
|
377
732
|
}
|
|
378
733
|
return;
|
|
379
734
|
case "metrics":
|
|
380
|
-
this.
|
|
735
|
+
return this.scrollPane("table", delta);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Scroll one pane of the current tab by `delta` lines, positive toward its
|
|
741
|
+
* end. Lists move their selection instead; the draft moves its cursor (the
|
|
742
|
+
* wheel scrolls it and the cursor follows only when it would leave the screen).
|
|
743
|
+
*/
|
|
744
|
+
private scrollPane(pane: string, delta: number, wheel = false): void {
|
|
745
|
+
if (delta === 0) return;
|
|
746
|
+
switch (pane) {
|
|
747
|
+
case "conversation":
|
|
748
|
+
case "activity":
|
|
749
|
+
case "thinking":
|
|
750
|
+
this.homeOffsets[pane] = Math.max(0, this.homeOffsets[pane] - delta);
|
|
751
|
+
break;
|
|
752
|
+
case "talk":
|
|
753
|
+
this.planOffset = Math.max(0, this.planOffset - delta);
|
|
754
|
+
break;
|
|
755
|
+
case "draft":
|
|
756
|
+
if (wheel) this.wheelDraft(delta);
|
|
757
|
+
else this.moveCursor(delta);
|
|
758
|
+
break;
|
|
759
|
+
case "list":
|
|
760
|
+
if (this.tab === "tasks") this.selectTask(this.tasksSelected + Math.sign(delta));
|
|
761
|
+
else if (this.tab === "quickfix") {
|
|
762
|
+
this.fixSelected = Math.max(0, Math.min(this.fixJobs().length - 1, this.fixSelected + Math.sign(delta)));
|
|
763
|
+
this.fixDetailOffset = 0;
|
|
764
|
+
}
|
|
765
|
+
break;
|
|
766
|
+
case "detail":
|
|
767
|
+
if (this.tab === "tasks") this.tasksDetailOffset = Math.max(0, this.tasksDetailOffset + delta);
|
|
768
|
+
else if (this.tab === "quickfix") this.fixDetailOffset = Math.max(0, this.fixDetailOffset + delta);
|
|
769
|
+
break;
|
|
770
|
+
case "table":
|
|
771
|
+
this.metricsSelected = Math.max(0, this.metricsSelected + (wheel ? Math.sign(delta) : delta));
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
this.clampScroll();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Home and End: the oldest or first line of the focused pane, or back to its newest or last. */
|
|
778
|
+
private scrollToEdge(start: boolean): void {
|
|
779
|
+
const pane = this.focusedPane();
|
|
780
|
+
const far = start ? -1e9 : 1e9;
|
|
781
|
+
if (pane === "draft") {
|
|
782
|
+
const last = this.planLayout.draftText.length - 1;
|
|
783
|
+
this.planCursor = start ? 0 : Math.max(0, last);
|
|
784
|
+
this.moveCursor(0);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (pane === "list" && this.tab === "tasks") return this.selectTask(start ? 0 : this.taskRowList().length - 1);
|
|
788
|
+
if (pane === "list" && this.tab === "quickfix") {
|
|
789
|
+
this.fixSelected = start ? 0 : Math.max(0, this.fixJobs().length - 1);
|
|
790
|
+
this.fixDetailOffset = 0;
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (pane === "table") {
|
|
794
|
+
this.metricsSelected = start ? 0 : Number.MAX_SAFE_INTEGER;
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
this.scrollPane(pane, far);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The wheel over the draft scrolls it; the cursor follows only when it would leave the screen. */
|
|
801
|
+
private wheelDraft(delta: number): void {
|
|
802
|
+
const rows = Math.max(1, this.planLayout.draftRows);
|
|
803
|
+
const max = Math.max(0, this.planLayout.draftText.length - rows);
|
|
804
|
+
this.planDraftOffset = Math.max(0, Math.min(max, this.planLayout.draftStart + delta));
|
|
805
|
+
if (this.planCursor < this.planDraftOffset) this.planCursor = this.planDraftOffset;
|
|
806
|
+
if (this.planCursor >= this.planDraftOffset + rows) this.planCursor = this.planDraftOffset + rows - 1;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Keep every offset within what its pane holds, so scrolling back the other
|
|
811
|
+
* way responds at once; a pane scrolled back from its newest line stays on
|
|
812
|
+
* the lines being read as new ones arrive. True when that moved a pane.
|
|
813
|
+
*/
|
|
814
|
+
private clampScroll(): boolean {
|
|
815
|
+
let moved = false;
|
|
816
|
+
const limit = (name: string, offset: number, anchored: boolean): number => {
|
|
817
|
+
const pane = this.panes.get(name);
|
|
818
|
+
if (!pane) return offset;
|
|
819
|
+
const seen = this.seenTotals.get(name);
|
|
820
|
+
this.seenTotals.set(name, pane.total);
|
|
821
|
+
let next = offset;
|
|
822
|
+
if (anchored && next > 0 && seen !== undefined && pane.total > seen) {
|
|
823
|
+
next += pane.total - seen;
|
|
824
|
+
moved = true;
|
|
825
|
+
}
|
|
826
|
+
return Math.min(next, Math.max(0, pane.total - pane.rows));
|
|
827
|
+
};
|
|
828
|
+
switch (this.tab) {
|
|
829
|
+
case "lobby":
|
|
830
|
+
for (const pane of HOME_PANES) this.homeOffsets[pane] = limit(pane, this.homeOffsets[pane], true);
|
|
831
|
+
break;
|
|
832
|
+
case "plan":
|
|
833
|
+
this.planOffset = limit("talk", this.planOffset, true);
|
|
834
|
+
break;
|
|
835
|
+
case "tasks":
|
|
836
|
+
this.tasksDetailOffset = limit("detail", this.tasksDetailOffset, false);
|
|
837
|
+
break;
|
|
838
|
+
case "quickfix":
|
|
839
|
+
this.fixDetailOffset = limit("detail", this.fixDetailOffset, false);
|
|
840
|
+
break;
|
|
841
|
+
default:
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
return moved;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Move the draft cursor by `delta` lines, skipping blank ones, and keep it on screen. */
|
|
848
|
+
private moveCursor(delta: number): void {
|
|
849
|
+
const lines = this.planLayout.draftText;
|
|
850
|
+
if (lines.length === 0) return;
|
|
851
|
+
const step = Math.sign(delta) || 1;
|
|
852
|
+
let target = Math.max(0, Math.min(lines.length - 1, this.planCursor + delta));
|
|
853
|
+
while (target > 0 && target < lines.length - 1 && !lines[target]!.trim()) target += step;
|
|
854
|
+
if (!lines[target]?.trim()) {
|
|
855
|
+
// Ran into a blank edge: look back the other way for the nearest line with text.
|
|
856
|
+
let back = target;
|
|
857
|
+
while (back >= 0 && back < lines.length && !lines[back]!.trim()) back -= step;
|
|
858
|
+
if (back >= 0 && back < lines.length) target = back;
|
|
859
|
+
}
|
|
860
|
+
this.planCursor = target;
|
|
861
|
+
this.keepCursorVisible();
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
private keepCursorVisible(): void {
|
|
865
|
+
const rows = Math.max(1, this.planLayout.draftRows);
|
|
866
|
+
const start = Math.min(this.planDraftOffset, Math.max(0, this.planLayout.draftText.length - rows));
|
|
867
|
+
if (this.planCursor < start) this.planDraftOffset = this.planCursor;
|
|
868
|
+
else if (this.planCursor >= start + rows) this.planDraftOffset = this.planCursor - rows + 1;
|
|
869
|
+
else this.planDraftOffset = start;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Put the draft cursor on the first line with text on screen. */
|
|
873
|
+
private cursorToView(): void {
|
|
874
|
+
const lines = this.planLayout.draftText;
|
|
875
|
+
const start = this.planLayout.draftStart;
|
|
876
|
+
const end = Math.min(lines.length, start + Math.max(1, this.planLayout.draftRows));
|
|
877
|
+
if (this.planCursor >= start && this.planCursor < end && lines[this.planCursor]?.trim()) return;
|
|
878
|
+
for (let index = start; index < end; index++) {
|
|
879
|
+
if (lines[index]?.trim()) {
|
|
880
|
+
this.planCursor = index;
|
|
381
881
|
return;
|
|
882
|
+
}
|
|
382
883
|
}
|
|
884
|
+
this.planCursor = start;
|
|
383
885
|
}
|
|
384
886
|
|
|
385
887
|
private selectTask(index: number): void {
|
|
386
|
-
const rows = this.taskRowList(
|
|
888
|
+
const rows = this.taskRowList();
|
|
387
889
|
this.tasksSelected = Math.max(0, Math.min(rows.length - 1, index));
|
|
388
890
|
this.tasksDetailOffset = 0;
|
|
389
891
|
this.refreshData(true);
|
|
@@ -399,19 +901,26 @@ export class LobbyView implements Component, Focusable {
|
|
|
399
901
|
const enter = matchesKey(data, Key.enter);
|
|
400
902
|
const escape = matchesKey(data, Key.escape);
|
|
401
903
|
switch (this.tab) {
|
|
402
|
-
case "lobby":
|
|
403
|
-
|
|
904
|
+
case "lobby": {
|
|
905
|
+
const background = this.viewedSession();
|
|
906
|
+
if (escape && background?.busy) {
|
|
907
|
+
background.abort();
|
|
908
|
+
this.say(`stopping ${background.name}'s oracle…`);
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
if (escape && this.viewing.kind === "here" && this.host.masterBusy()) {
|
|
404
912
|
this.host.abortMaster();
|
|
405
913
|
this.say("stopping the oracle…");
|
|
406
914
|
return true;
|
|
407
915
|
}
|
|
408
916
|
if (data === "c") {
|
|
409
|
-
const task = this.
|
|
410
|
-
if (task) this.commentOn(task.id);
|
|
917
|
+
const task = this.viewedEntry().task;
|
|
918
|
+
if (task && !TERMINAL_STATES.includes(task.state)) this.commentOn(task.id);
|
|
411
919
|
else this.say("no task in this session to comment on", "warning");
|
|
412
920
|
return true;
|
|
413
921
|
}
|
|
414
922
|
return enter ? (this.setMode("type"), true) : false;
|
|
923
|
+
}
|
|
415
924
|
case "tasks":
|
|
416
925
|
return this.tasksCommand(data, enter, escape);
|
|
417
926
|
case "plan":
|
|
@@ -420,10 +929,11 @@ export class LobbyView implements Component, Focusable {
|
|
|
420
929
|
if (enter) return (this.fixFocus = this.fixFocus === "list" ? "detail" : "list"), true;
|
|
421
930
|
if (escape && this.fixFocus === "detail") return (this.fixFocus = "list"), true;
|
|
422
931
|
if (data === "x") {
|
|
423
|
-
const job =
|
|
932
|
+
const job = this.fixJobs()[this.fixSelected];
|
|
424
933
|
if (job && this.host.quickfix.cancel(job.id)) this.say(`cancelling ${job.id}`);
|
|
425
934
|
return true;
|
|
426
935
|
}
|
|
936
|
+
if (data === "m") return this.openSettings("quickfix"), true;
|
|
427
937
|
return false;
|
|
428
938
|
case "issues":
|
|
429
939
|
return this.issuesCommand(data, enter, escape);
|
|
@@ -436,10 +946,11 @@ export class LobbyView implements Component, Focusable {
|
|
|
436
946
|
}
|
|
437
947
|
|
|
438
948
|
private tasksCommand(data: string, enter: boolean, escape: boolean): boolean {
|
|
439
|
-
const row = this.taskRowList(
|
|
949
|
+
const row = this.taskRowList()[this.tasksSelected];
|
|
440
950
|
if (enter) return (this.tasksFocus = this.tasksFocus === "list" ? "detail" : "list"), true;
|
|
441
951
|
if (escape) return (this.tasksFocus = "list"), true;
|
|
442
952
|
if (data === "r") return this.refreshData(true), true;
|
|
953
|
+
if (data === "n") return this.startNewSessionPrompt(), true;
|
|
443
954
|
if (!row) return false;
|
|
444
955
|
if (data === "c") {
|
|
445
956
|
const task = this.data.tasks.find((entry) => entry.id === row.id);
|
|
@@ -452,11 +963,39 @@ export class LobbyView implements Component, Focusable {
|
|
|
452
963
|
return true;
|
|
453
964
|
}
|
|
454
965
|
const plan = row.kind === "plan" ? this.data.plans.find((entry) => entry.id === row.id) : undefined;
|
|
966
|
+
// A planned task starts in a new session of its own (s), or in this window (h).
|
|
455
967
|
if (data === "s" && plan) {
|
|
968
|
+
this.startInNewSession({ plan });
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
if (data === "h" && plan) {
|
|
456
972
|
this.say(this.host.startPlanned(plan));
|
|
457
973
|
this.refreshData(true);
|
|
458
974
|
return true;
|
|
459
975
|
}
|
|
976
|
+
const task = row.kind === "task" ? this.data.tasks.find((entry) => entry.id === row.id) : undefined;
|
|
977
|
+
if (data === "o" && task) {
|
|
978
|
+
const target = this.sessionEntries().find((entry) => entry.task?.id === task.id);
|
|
979
|
+
if (!target) this.say(`${task.id} is not running in any session now`, "warning");
|
|
980
|
+
else {
|
|
981
|
+
this.view(target.view);
|
|
982
|
+
this.say(target.view.kind === "here" ? "this window's task" : `showing ${target.name}`);
|
|
983
|
+
}
|
|
984
|
+
return true;
|
|
985
|
+
}
|
|
986
|
+
if (data === "x" && task) {
|
|
987
|
+
const session = this.host.sessions().find((entry) => entry.alive && entry.sessionId === task.ownerSessionId);
|
|
988
|
+
if (!session) return this.say("only a background session this window started can be stopped from here", "warning"), true;
|
|
989
|
+
if (this.armed !== `stop:${session.key}`) {
|
|
990
|
+
this.armed = `stop:${session.key}`;
|
|
991
|
+
this.say(`press x again to stop ${session.name} (its task keeps its state)`, "warning");
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
this.armed = undefined;
|
|
995
|
+
session.stop();
|
|
996
|
+
this.say(`stopping ${session.name}`);
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
460
999
|
if (data === "d" && plan) {
|
|
461
1000
|
if (this.armed !== `discard:${plan.id}`) {
|
|
462
1001
|
this.armed = `discard:${plan.id}`;
|
|
@@ -483,11 +1022,229 @@ export class LobbyView implements Component, Focusable {
|
|
|
483
1022
|
private resetPlanScroll(): void {
|
|
484
1023
|
this.planOffset = 0;
|
|
485
1024
|
this.planDraftOffset = 0;
|
|
1025
|
+
this.planCursor = 0;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** The Lobby prompt: a new task in its own session, or a message to the session in view. */
|
|
1029
|
+
private submitLobby(body: string): void {
|
|
1030
|
+
this.homeOffsets.conversation = 0;
|
|
1031
|
+
if (this.newSession) {
|
|
1032
|
+
this.newSession = false;
|
|
1033
|
+
return this.startInNewSession({ request: body });
|
|
1034
|
+
}
|
|
1035
|
+
const view = this.viewing;
|
|
1036
|
+
if (view.kind === "background") {
|
|
1037
|
+
const session = this.viewedSession();
|
|
1038
|
+
if (!session?.alive) {
|
|
1039
|
+
this.editor.setText(body);
|
|
1040
|
+
return this.say(`${session?.name ?? "that session"} has ended — alt+n starts a new one`, "warning");
|
|
1041
|
+
}
|
|
1042
|
+
session.send(body);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (view.kind === "other") return this.say(this.host.sendToTask(view.taskId, body));
|
|
1046
|
+
const notice = this.host.toOracle(body);
|
|
1047
|
+
if (notice) this.say(notice);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Put the question the background session in view waits on to the user. */
|
|
1051
|
+
private answerSessionDialog(): void {
|
|
1052
|
+
const session = this.viewedSession();
|
|
1053
|
+
if (!session?.dialogs.length) return;
|
|
1054
|
+
void this.host.answerDialog(session).then(() => this.host.requestRender());
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/* ------------------------------------------------------------ sessions */
|
|
1058
|
+
|
|
1059
|
+
/** Every session the lobby can show: this window, the background sessions it started, then tasks other terminals drive. */
|
|
1060
|
+
sessionEntries(): SessionEntry[] {
|
|
1061
|
+
const me = this.host.sessionId();
|
|
1062
|
+
const zen = this.host.zen();
|
|
1063
|
+
const background = this.host.sessions();
|
|
1064
|
+
const started = new Set(background.map((session) => session.sessionId).filter((id): id is string => Boolean(id)));
|
|
1065
|
+
const auto = (task: Task | undefined) => Boolean(task && !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id));
|
|
1066
|
+
const entries: SessionEntry[] = [{
|
|
1067
|
+
view: { kind: "here" },
|
|
1068
|
+
name: this.host.sessionName() ?? zen.task?.title ?? "unnamed session",
|
|
1069
|
+
where: "this window",
|
|
1070
|
+
...(zen.task ? { task: zen.task } : {}),
|
|
1071
|
+
status: this.host.masterBusy() ? "working" : zen.task?.state ?? "no task",
|
|
1072
|
+
auto: auto(zen.task),
|
|
1073
|
+
waiting: 0,
|
|
1074
|
+
}];
|
|
1075
|
+
for (const session of background) {
|
|
1076
|
+
const task = session.sessionId ? this.data.tasks.find((entry) => entry.ownerSessionId === session.sessionId) : undefined;
|
|
1077
|
+
const status = session.status === "exited" ? "ended" : session.status === "starting" ? "starting" : session.busy ? "working" : task?.state ?? "idle";
|
|
1078
|
+
entries.push({ view: { kind: "background", key: session.key }, name: session.name, where: "background", ...(task ? { task } : {}), status, auto: auto(task), waiting: session.dialogs.length });
|
|
1079
|
+
}
|
|
1080
|
+
for (const task of this.data.tasks) {
|
|
1081
|
+
if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId || task.ownerSessionId === me || started.has(task.ownerSessionId)) continue;
|
|
1082
|
+
entries.push({ view: { kind: "other", taskId: task.id }, name: task.title, where: "other terminal", task, status: task.state, auto: auto(task), waiting: 0 });
|
|
1083
|
+
}
|
|
1084
|
+
return entries;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** The entry the Lobby tab shows, falling back to this window when that session is gone. */
|
|
1088
|
+
viewedEntry(): SessionEntry {
|
|
1089
|
+
const entries = this.sessionEntries();
|
|
1090
|
+
const view = this.viewing;
|
|
1091
|
+
const found = view.kind === "here" ? undefined : entries.find((entry) => (entry.view.kind === "background" && view.kind === "background" && entry.view.key === view.key) || (entry.view.kind === "other" && view.kind === "other" && entry.view.taskId === view.taskId));
|
|
1092
|
+
if (!found && view.kind !== "here") this.viewing = { kind: "here" };
|
|
1093
|
+
return found ?? entries[0]!;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
private viewedSession(): BackgroundSession | undefined {
|
|
1097
|
+
const view = this.viewing;
|
|
1098
|
+
return view.kind === "background" ? this.host.sessions().find((session) => session.key === view.key) : undefined;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** Show a session in the Lobby tab, and talk to it from there. */
|
|
1102
|
+
view(target: SessionView): void {
|
|
1103
|
+
this.viewing = target;
|
|
1104
|
+
this.picking = false;
|
|
1105
|
+
this.newSession = false;
|
|
1106
|
+
for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
|
|
1107
|
+
if (this.tab !== "lobby") this.setTab("lobby");
|
|
1108
|
+
this.setMode("type");
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
private openPicker(): void {
|
|
1112
|
+
this.help = false;
|
|
1113
|
+
this.picking = true;
|
|
1114
|
+
const entries = this.sessionEntries();
|
|
1115
|
+
const current = this.viewedEntry();
|
|
1116
|
+
this.pickIndex = Math.max(0, entries.findIndex((entry) => entry.name === current.name && entry.where === current.where));
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** Keys while the session switcher is open; true when the key was handled. */
|
|
1120
|
+
private pickerKey(data: string, action: LobbyAction | undefined): boolean {
|
|
1121
|
+
const entries = this.sessionEntries();
|
|
1122
|
+
this.pickIndex = Math.min(this.pickIndex, Math.max(0, entries.length - 1));
|
|
1123
|
+
if (matchesKey(data, Key.escape) || action === "sessions") {
|
|
1124
|
+
this.picking = false;
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
if (matchesKey(data, Key.up) || data === "k") return (this.pickIndex = Math.max(0, this.pickIndex - 1)), true;
|
|
1128
|
+
if (matchesKey(data, Key.down) || data === "j") return (this.pickIndex = Math.min(entries.length - 1, this.pickIndex + 1)), true;
|
|
1129
|
+
const entry = entries[this.pickIndex];
|
|
1130
|
+
if (matchesKey(data, Key.enter) && entry) {
|
|
1131
|
+
this.view(entry.view);
|
|
1132
|
+
this.say(entry.view.kind === "here" ? "back to this window" : `showing ${entry.name} — you talk to its oracle from here`);
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
if (data === "n" || action === "newSession") {
|
|
1136
|
+
this.picking = false;
|
|
1137
|
+
this.startNewSessionPrompt();
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
if (data === "x" && entry?.view.kind === "background") {
|
|
1141
|
+
const key = entry.view.key;
|
|
1142
|
+
if (this.armed !== `stop:${key}`) {
|
|
1143
|
+
this.armed = `stop:${key}`;
|
|
1144
|
+
this.say(`press x again to stop ${entry.name} (its task keeps its state)`, "warning");
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
this.armed = undefined;
|
|
1148
|
+
this.host.sessions().find((session) => session.key === key)?.stop();
|
|
1149
|
+
this.say(`stopping ${entry.name}`);
|
|
1150
|
+
return true;
|
|
1151
|
+
}
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Start typing a task that begins in its own new session. */
|
|
1156
|
+
startNewSessionPrompt(): void {
|
|
1157
|
+
this.picking = false;
|
|
1158
|
+
this.help = false;
|
|
1159
|
+
if (this.tab !== "lobby") this.setTab("lobby");
|
|
1160
|
+
this.newSession = true;
|
|
1161
|
+
this.setMode("type");
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Start a task in a new background session and show it. */
|
|
1165
|
+
startInNewSession(start: { request?: string; plan?: PlannedTask }): void {
|
|
1166
|
+
const session = this.host.startSession(start);
|
|
1167
|
+
if (typeof session === "string") return this.say(session, "warning");
|
|
1168
|
+
this.view({ kind: "background", key: session.key });
|
|
1169
|
+
this.say(`started ${session.name} in a new session — alt+o switches back`);
|
|
1170
|
+
this.refreshData(true);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** The task auto mode applies to: the selected row on the Tasks tab, the viewed session's task elsewhere. */
|
|
1174
|
+
private autoTarget(): Task | undefined {
|
|
1175
|
+
if (this.tab === "tasks") {
|
|
1176
|
+
const row = this.taskRowList()[this.tasksSelected];
|
|
1177
|
+
return row?.kind === "task" ? this.data.tasks.find((task) => task.id === row.id) : undefined;
|
|
1178
|
+
}
|
|
1179
|
+
return this.viewedEntry().task;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** Switch auto mode for the task in view. */
|
|
1183
|
+
toggleAuto(): void {
|
|
1184
|
+
const task = this.autoTarget();
|
|
1185
|
+
if (!task || TERMINAL_STATES.includes(task.state)) return this.say("no active task here — auto mode applies to a task", "warning");
|
|
1186
|
+
const on = !this.host.isAuto(task.id);
|
|
1187
|
+
this.host.setAuto(task.id, on);
|
|
1188
|
+
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}`);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** Save the planning session's draft to the pending tasks, from any tab and in either mode. */
|
|
1192
|
+
savePlan(): void {
|
|
1193
|
+
const session = this.host.planner();
|
|
1194
|
+
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");
|
|
1195
|
+
try {
|
|
1196
|
+
const saved = session.save();
|
|
1197
|
+
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)"}`);
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
this.say((error as Error).message, "warning");
|
|
1200
|
+
}
|
|
1201
|
+
this.refreshData(true);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** The oracle puts the round's questions to the user through the questionnaire. */
|
|
1205
|
+
answerQuestions(): void {
|
|
1206
|
+
const session = this.host.planner();
|
|
1207
|
+
if (!session?.awaitingAnswers) return this.say(session?.busy ? "the panel is still thinking" : "no open questions", "warning");
|
|
1208
|
+
void this.host.answerPanel().then((notice) => {
|
|
1209
|
+
this.say(notice);
|
|
1210
|
+
this.host.requestRender();
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Start a comment on the draft line under the cursor. */
|
|
1215
|
+
private commentOnCursor(): void {
|
|
1216
|
+
const line = this.planLayout.draftText[this.planCursor];
|
|
1217
|
+
if (!this.host.planner()?.reply?.plan) return this.say("there is no draft to comment on yet", "warning");
|
|
1218
|
+
if (!line?.trim()) return this.say("that line is blank — ↑↓ picks another", "warning");
|
|
1219
|
+
this.lineTarget = line.trim();
|
|
1220
|
+
this.setMode("type");
|
|
486
1221
|
}
|
|
487
1222
|
|
|
488
1223
|
private planCommand(data: string, enter: boolean, escape: boolean): boolean {
|
|
489
1224
|
const session = this.host.planner();
|
|
490
|
-
if (enter)
|
|
1225
|
+
if (enter) {
|
|
1226
|
+
this.planFocus = this.planFocus === "talk" ? "draft" : "talk";
|
|
1227
|
+
if (this.planFocus === "draft") this.cursorToView();
|
|
1228
|
+
return true;
|
|
1229
|
+
}
|
|
1230
|
+
if (data === "a") {
|
|
1231
|
+
this.answerQuestions();
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
if (data === "m") {
|
|
1235
|
+
this.openSettings("planner");
|
|
1236
|
+
return true;
|
|
1237
|
+
}
|
|
1238
|
+
if (data === "c") {
|
|
1239
|
+
if (this.planFocus !== "draft") {
|
|
1240
|
+
this.planFocus = "draft";
|
|
1241
|
+
this.cursorToView();
|
|
1242
|
+
this.say("↑↓ picks a draft line, c comments on it (or click the line)");
|
|
1243
|
+
return true;
|
|
1244
|
+
}
|
|
1245
|
+
this.commentOnCursor();
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
491
1248
|
const seat = /^[1-4]$/.test(data) ? PANEL_MEMBERS[Number(data) - 1] : undefined;
|
|
492
1249
|
if (seat) {
|
|
493
1250
|
let seated: boolean;
|
|
@@ -507,21 +1264,15 @@ export class LobbyView implements Component, Focusable {
|
|
|
507
1264
|
}
|
|
508
1265
|
return true;
|
|
509
1266
|
}
|
|
1267
|
+
if (escape && this.planFocus === "draft") {
|
|
1268
|
+
this.planFocus = "talk";
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
510
1271
|
if (data === "r") {
|
|
511
1272
|
if (session?.retryable) void session.retry();
|
|
512
1273
|
else this.say("nothing to retry", "warning");
|
|
513
1274
|
return true;
|
|
514
1275
|
}
|
|
515
|
-
if (data === "s") {
|
|
516
|
-
try {
|
|
517
|
-
const saved = session?.save();
|
|
518
|
-
this.say(saved ? `saved ${saved.id} — start it from Tasks (2) when you are ready` : "nothing to save yet", saved ? "info" : "warning");
|
|
519
|
-
} catch (error) {
|
|
520
|
-
this.say((error as Error).message, "warning");
|
|
521
|
-
}
|
|
522
|
-
this.refreshData(true);
|
|
523
|
-
return true;
|
|
524
|
-
}
|
|
525
1276
|
if (data === "n") {
|
|
526
1277
|
const unsaved = session && session.messages.length > 0 && !session.saved;
|
|
527
1278
|
if (unsaved && this.armed !== "new-plan") {
|
|
@@ -564,19 +1315,91 @@ export class LobbyView implements Component, Focusable {
|
|
|
564
1315
|
return false;
|
|
565
1316
|
}
|
|
566
1317
|
|
|
1318
|
+
/* --------------------------------------------------------------- mouse */
|
|
1319
|
+
|
|
1320
|
+
/** Mouse events when pi runs full screen (pi-tui dispatches them to the overlay). */
|
|
1321
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
1322
|
+
if (event.type === "wheel") this.wheel(Math.sign(event.wheelDelta ?? 0) || 1, event.x, event.y);
|
|
1323
|
+
else if (event.type === "click" && event.button === "left") this.click(event.x, event.y);
|
|
1324
|
+
else return undefined;
|
|
1325
|
+
this.host.requestRender();
|
|
1326
|
+
return { handled: true };
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** Mouse reports in pi's regular screen, where the lobby turns reporting on itself. */
|
|
1330
|
+
private mouseReport(report: MouseReport): void {
|
|
1331
|
+
if (report.kind === "wheel") this.wheel(report.delta, report.x, report.y);
|
|
1332
|
+
else if (report.kind === "press" && report.button === 0) this.click(report.x, report.y);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** The pane of the current tab under a body cell, if any. */
|
|
1336
|
+
private paneAt(x: number, y: number): string | undefined {
|
|
1337
|
+
const row = y - this.bodyTop;
|
|
1338
|
+
for (const [name, pane] of this.panes) {
|
|
1339
|
+
if (row >= pane.top && row < pane.top + pane.height && x >= pane.left && x < pane.left + pane.width) return name;
|
|
1340
|
+
}
|
|
1341
|
+
return undefined;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** The wheel scrolls the pane under the pointer (the focused one when it is over none). */
|
|
1345
|
+
private wheel(direction: number, x = -1, y = -1): void {
|
|
1346
|
+
if (this.help) return;
|
|
1347
|
+
if (this.tab === "issues") return this.scroll(direction * WHEEL_LINES);
|
|
1348
|
+
this.scrollPane(this.paneAt(x, y) ?? this.focusedPane(), direction * WHEEL_LINES, true);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** A click: a tab in the tab bar, the prompt, or a line of the draft plan to comment on. */
|
|
1352
|
+
click(x: number, y: number): void {
|
|
1353
|
+
this.help = false;
|
|
1354
|
+
if (y === 0) {
|
|
1355
|
+
const span = this.tabSpans.find((entry) => x >= entry.from && x < entry.to);
|
|
1356
|
+
if (span) this.setTab(span.tab);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (y >= this.promptTop) {
|
|
1360
|
+
if (this.searching) this.closeSearch();
|
|
1361
|
+
if (this.tab !== "metrics" && this.tab !== "tasks") this.setMode("type");
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const row = y - this.bodyTop;
|
|
1365
|
+
// A click on a pane gives it the keys.
|
|
1366
|
+
const pane = this.paneAt(x, y);
|
|
1367
|
+
if (this.tab === "lobby" && pane) this.homeFocus = pane as HomePane;
|
|
1368
|
+
if (this.tab === "tasks" && (pane === "list" || pane === "detail")) this.tasksFocus = pane;
|
|
1369
|
+
if (this.tab === "quickfix" && (pane === "list" || pane === "detail")) this.fixFocus = pane;
|
|
1370
|
+
if (this.tab !== "plan" || row < 0 || !this.host.planner()) return;
|
|
1371
|
+
const layout = this.planLayout;
|
|
1372
|
+
// The draft pane: its border sits one row above its first line and two columns left of its text.
|
|
1373
|
+
const inDraftColumns = x >= layout.draftLeft - 2 && x < layout.draftLeft + layout.draftWidth + 2;
|
|
1374
|
+
const line = row - layout.draftTop;
|
|
1375
|
+
if (inDraftColumns && line >= -1 && line <= layout.draftRows) {
|
|
1376
|
+
this.planFocus = "draft";
|
|
1377
|
+
const index = layout.draftStart + line;
|
|
1378
|
+
if (line < 0 || line >= layout.draftRows || index >= layout.draftText.length) return;
|
|
1379
|
+
this.planCursor = index;
|
|
1380
|
+
if (this.searching) this.closeSearch();
|
|
1381
|
+
this.commentOnCursor();
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
// The conversation sits left of the draft on wide terminals, above it on narrow ones.
|
|
1385
|
+
const inTalk = layout.draftLeft > 2 ? x < layout.draftLeft - 2 : line < -1;
|
|
1386
|
+
if (inTalk) this.planFocus = "talk";
|
|
1387
|
+
}
|
|
1388
|
+
|
|
567
1389
|
/* -------------------------------------------------------------- submit */
|
|
568
1390
|
|
|
569
1391
|
private submit(text: string): void {
|
|
570
1392
|
const body = text.trim();
|
|
571
|
-
if (!body)
|
|
1393
|
+
if (!body) {
|
|
1394
|
+
// An empty enter on the Plan tab opens the panel's questions; on the Lobby, a background session's question.
|
|
1395
|
+
if (this.tab === "plan" && !this.lineTarget && this.host.planner()?.awaitingAnswers) this.answerQuestions();
|
|
1396
|
+
if (this.tab === "lobby" && !this.newSession) this.answerSessionDialog();
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
572
1399
|
this.editor.addToHistory(body);
|
|
573
1400
|
switch (this.tab) {
|
|
574
|
-
case "lobby":
|
|
575
|
-
|
|
576
|
-
if (notice) this.say(notice);
|
|
577
|
-
this.chatOffset = 0;
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
1401
|
+
case "lobby":
|
|
1402
|
+
return this.submitLobby(body);
|
|
580
1403
|
case "tasks":
|
|
581
1404
|
if (!this.commentTarget) {
|
|
582
1405
|
this.editor.setText(body);
|
|
@@ -587,20 +1410,13 @@ export class LobbyView implements Component, Focusable {
|
|
|
587
1410
|
this.setMode("browse");
|
|
588
1411
|
this.refreshData(true);
|
|
589
1412
|
return;
|
|
590
|
-
case "plan":
|
|
591
|
-
|
|
592
|
-
if (session.busy) {
|
|
593
|
-
this.editor.setText(body);
|
|
594
|
-
return this.say("the panel is still thinking — x stops it", "warning");
|
|
595
|
-
}
|
|
596
|
-
this.resetPlanScroll();
|
|
597
|
-
session.send(body).catch((error: Error) => this.say(error.message, "warning"));
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
1413
|
+
case "plan":
|
|
1414
|
+
return this.submitPlan(body);
|
|
600
1415
|
case "quickfix": {
|
|
601
1416
|
const job = this.host.quickfix.submit(body);
|
|
602
1417
|
this.fixSelected = 0;
|
|
603
1418
|
this.fixDetailOffset = 0;
|
|
1419
|
+
delete this.queries.quickfix;
|
|
604
1420
|
this.say(this.host.quickfix.running?.id === job.id ? `${job.id} started` : `${job.id} queued behind the running quick fix`);
|
|
605
1421
|
return;
|
|
606
1422
|
}
|
|
@@ -619,10 +1435,44 @@ export class LobbyView implements Component, Focusable {
|
|
|
619
1435
|
}
|
|
620
1436
|
}
|
|
621
1437
|
|
|
1438
|
+
private submitPlan(body: string): void {
|
|
1439
|
+
if (this.lineTarget) {
|
|
1440
|
+
const line = this.lineTarget;
|
|
1441
|
+
this.lineTarget = undefined;
|
|
1442
|
+
const session = this.host.planner();
|
|
1443
|
+
if (!session) return this.say("there is no draft to comment on", "warning");
|
|
1444
|
+
const sent = session.commentOnLine(line, body);
|
|
1445
|
+
this.planFocus = "draft";
|
|
1446
|
+
this.setMode("browse");
|
|
1447
|
+
if (sent) this.say("comment sent — the panel revises the draft");
|
|
1448
|
+
else if (session.busy) this.say("comment kept — it goes to the panel after this round");
|
|
1449
|
+
else this.say("comment kept — it goes with your answers (a answers the questions)");
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const session = this.host.planner() ?? this.host.newPlanner(undefined, [...this.seatSet()]);
|
|
1453
|
+
if (session.busy) {
|
|
1454
|
+
this.editor.setText(body);
|
|
1455
|
+
return this.say("the panel is still thinking — x stops it", "warning");
|
|
1456
|
+
}
|
|
1457
|
+
this.planOffset = 0;
|
|
1458
|
+
this.planDraftOffset = 0;
|
|
1459
|
+
session.send(body).catch((error: Error) => this.say(error.message, "warning"));
|
|
1460
|
+
}
|
|
1461
|
+
|
|
622
1462
|
/* -------------------------------------------------------------- render */
|
|
623
1463
|
|
|
624
|
-
|
|
625
|
-
|
|
1464
|
+
/** Task rows, narrowed by the Tasks tab's search. */
|
|
1465
|
+
private taskRowList(): TaskRow[] {
|
|
1466
|
+
const names = new Map<string, string>();
|
|
1467
|
+
for (const session of this.host.sessions()) if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
|
|
1468
|
+
const auto = new Set(this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id)).map((task) => task.id));
|
|
1469
|
+
const rows = taskRows(this.data.tasks, this.data.plans, this.host.sessionId(), this.now(), { names, auto });
|
|
1470
|
+
return filterRows(rows, this.data.tasks, this.data.plans, this.queries.tasks);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Quick fix jobs as listed: newest first, narrowed by the search. */
|
|
1474
|
+
private fixJobs() {
|
|
1475
|
+
return newestFirst(filterJobs(this.host.quickfix.jobs, this.queries.quickfix));
|
|
626
1476
|
}
|
|
627
1477
|
|
|
628
1478
|
private tabBar(width: number, theme: LobbyTheme): string {
|
|
@@ -630,24 +1480,46 @@ export class LobbyView implements Component, Focusable {
|
|
|
630
1480
|
const badges: Partial<Record<TabId, string>> = {};
|
|
631
1481
|
const active = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state)).length;
|
|
632
1482
|
if (active > 0) badges.tasks = String(active);
|
|
633
|
-
|
|
1483
|
+
const session = this.host.planner();
|
|
1484
|
+
if (session?.busy) badges.plan = spinner(this.tick);
|
|
1485
|
+
else if (session?.awaitingAnswers) badges.plan = `${session.questions.length}?`;
|
|
634
1486
|
if (this.host.quickfix.running) badges.quickfix = spinner(this.tick);
|
|
635
|
-
if (this.host.issues.issues.length > 0) badges.issues = String(this.host.issues.issues.length);
|
|
636
|
-
const tabs = TAB_IDS.map((tab, index) => {
|
|
637
|
-
const label = `${index + 1} ${TAB_LABELS[tab]}${badges[tab] ? ` ${badges[tab]}` : ""}`;
|
|
638
|
-
if (tab !== this.tab) return paint(theme, "muted", ` ${label} `);
|
|
639
|
-
const text = bold(theme, paint(theme, "accent", ` ${label} `));
|
|
640
|
-
return theme.bg ? theme.bg("selectedBg", text) : `[${text}]`;
|
|
641
|
-
});
|
|
1487
|
+
if (this.issuesOn && this.host.issues.issues.length > 0) badges.issues = String(this.host.issues.issues.length);
|
|
642
1488
|
const brand = bold(theme, paint(theme, "accent", " ◆ bot-lobby "));
|
|
643
|
-
|
|
1489
|
+
let left = `${brand}${paint(theme, "borderMuted", "│")}`;
|
|
1490
|
+
const spans: Array<{ tab: TabId; from: number; to: number }> = [];
|
|
1491
|
+
for (const [index, tab] of this.tabs().entries()) {
|
|
1492
|
+
const number = paint(theme, tab === this.tab ? "accent" : "dim", String(index + 1));
|
|
1493
|
+
const name = tab === this.tab ? bold(theme, paint(theme, "text", TAB_LABELS[tab])) : paint(theme, "muted", TAB_LABELS[tab]);
|
|
1494
|
+
const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
|
|
1495
|
+
let cell = ` ${number} ${name}${badge} `;
|
|
1496
|
+
if (tab === this.tab) cell = theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`;
|
|
1497
|
+
const from = visibleWidth(left);
|
|
1498
|
+
left += cell;
|
|
1499
|
+
spans.push({ tab, from, to: visibleWidth(left) });
|
|
1500
|
+
}
|
|
1501
|
+
this.tabSpans = spans;
|
|
644
1502
|
const room = width - visibleWidth(left) - 1;
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
1503
|
+
const entry = this.viewedEntry();
|
|
1504
|
+
const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
|
|
1505
|
+
const auto = entry.auto ? ` ${bold(theme, paint(theme, "success", "⟳ AUTO"))}` : "";
|
|
1506
|
+
const waiting = this.host.sessions().reduce((count, session) => count + session.dialogs.length, 0);
|
|
1507
|
+
const asking = waiting > 0 ? ` ${paint(theme, "warning", `● ${waiting} waiting · ${keyLabel(this.keys.sessions)}`)}` : "";
|
|
1508
|
+
let choices: string[];
|
|
1509
|
+
if (entry.view.kind !== "here") {
|
|
1510
|
+
// Another session in view: its name and what it is doing lead, so it is never mistaken for this window.
|
|
1511
|
+
const working = entry.status === "working";
|
|
1512
|
+
const dot = paint(theme, working ? "accent" : "dim", working ? spinner(this.tick) : "◆");
|
|
1513
|
+
const where = paint(theme, "muted", `${entry.name} · ${entry.status}`);
|
|
1514
|
+
choices = [`${dot} ${where}${auto}${asking} ${help}`, `${dot} ${where}${auto}${asking} `, `${dot} ${where}${auto} `, `${dot}${auto} `];
|
|
1515
|
+
} else {
|
|
1516
|
+
const dot = paint(theme, this.host.masterBusy() ? "accent" : "dim", this.host.masterBusy() ? spinner(this.tick) : "●");
|
|
1517
|
+
const state = zen.task ? paint(theme, "muted", zen.task.paused ? `${zen.task.state} (paused)` : zen.task.state) : "";
|
|
1518
|
+
// Long task ids give way to the state, then to the dot alone.
|
|
1519
|
+
choices = zen.task
|
|
1520
|
+
? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
|
|
1521
|
+
: [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, help, ""];
|
|
1522
|
+
}
|
|
651
1523
|
const status = choices.find((choice) => visibleWidth(choice) <= room) ?? "";
|
|
652
1524
|
const gap = width - visibleWidth(left) - visibleWidth(status);
|
|
653
1525
|
return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
|
|
@@ -657,18 +1529,33 @@ export class LobbyView implements Component, Focusable {
|
|
|
657
1529
|
const zen = this.host.zen();
|
|
658
1530
|
const browsing = this.mode === "browse";
|
|
659
1531
|
switch (this.tab) {
|
|
660
|
-
case "lobby":
|
|
1532
|
+
case "lobby": {
|
|
1533
|
+
if (this.newSession) return "new task — it starts in its own session, named after it";
|
|
1534
|
+
if (this.viewing.kind !== "here") {
|
|
1535
|
+
const entry = this.viewedEntry();
|
|
1536
|
+
const session = this.viewedSession();
|
|
1537
|
+
if (session && !session.alive) return `${entry.name} has ended`;
|
|
1538
|
+
if (session?.dialogs.length) return `press enter to answer ${entry.name}'s question, or type a message`;
|
|
1539
|
+
return entry.where === "other terminal" ? `message ${entry.name}'s oracle (its own session delivers it)` : `message ${entry.name}'s oracle`;
|
|
1540
|
+
}
|
|
661
1541
|
if (!zen.task) return browsing ? "describe a task to start — i to type" : "describe a task to start · enter starts it";
|
|
662
1542
|
return browsing ? "message the oracle — i to type" : this.host.masterBusy() ? "message the oracle · enter steers the running turn" : "message the oracle";
|
|
1543
|
+
}
|
|
663
1544
|
case "tasks":
|
|
664
1545
|
return this.commentTarget ? `comment on ${this.commentTarget}'s plan · enter sends it to the oracle` : "c comments on the selected task's plan";
|
|
665
1546
|
case "plan": {
|
|
1547
|
+
if (this.lineTarget) return `comment on “${clip(this.lineTarget, 48)}”`;
|
|
666
1548
|
const session = this.host.planner();
|
|
667
|
-
if (session?.busy) return "the panel is thinking
|
|
668
|
-
|
|
1549
|
+
if (session?.busy) return "the panel is thinking";
|
|
1550
|
+
if (session?.awaitingAnswers) {
|
|
1551
|
+
const count = session.questions.length;
|
|
1552
|
+
return `press enter to answer ${count} question${count === 1 ? "" : "s"}, or type a reply`;
|
|
1553
|
+
}
|
|
1554
|
+
if (session?.reply?.status === "ready") return `the plan is ready — ${keyLabel(this.keys.savePlan)} saves it, or reply to refine it`;
|
|
1555
|
+
return session && session.messages.length > 0 ? "reply to the panel" : "describe the task to plan";
|
|
669
1556
|
}
|
|
670
1557
|
case "quickfix":
|
|
671
|
-
return this.host.quickfix.running ? "describe a quick change
|
|
1558
|
+
return this.host.quickfix.running ? "describe a quick change; it runs after the current one" : "describe a quick change";
|
|
672
1559
|
case "issues":
|
|
673
1560
|
return this.issueDraft ? "new issue · first line is the title" : "n files a new issue";
|
|
674
1561
|
case "metrics":
|
|
@@ -678,63 +1565,239 @@ export class LobbyView implements Component, Focusable {
|
|
|
678
1565
|
|
|
679
1566
|
private promptLines(width: number, theme: LobbyTheme): string[] {
|
|
680
1567
|
if (this.tab === "metrics") return [];
|
|
1568
|
+
const typing = this.mode === "type" && !this.searching;
|
|
681
1569
|
this.editor.label = this.promptLabel();
|
|
682
|
-
this.editor.paintLabel = (text) => paint(theme, this.
|
|
683
|
-
this.editor.borderColor = (text) => paint(theme,
|
|
1570
|
+
this.editor.paintLabel = (text) => paint(theme, typing ? (this.lineTarget ? "warning" : "accent") : "dim", text);
|
|
1571
|
+
this.editor.borderColor = (text) => paint(theme, typing ? "border" : "borderMuted", text);
|
|
684
1572
|
const lines = this.editor.render(width);
|
|
685
1573
|
// The editor always draws its block cursor; while browsing it would suggest the prompt has focus.
|
|
686
|
-
return
|
|
1574
|
+
return typing ? lines : lines.map((line) => line.replace(REVERSE_VIDEO, "$1"));
|
|
687
1575
|
}
|
|
688
1576
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
const
|
|
1577
|
+
/** The search bar: the query being typed, or the one in force. */
|
|
1578
|
+
private searchLines(width: number, theme: LobbyTheme): string[] {
|
|
1579
|
+
const query = this.query();
|
|
1580
|
+
if (!this.searching && !query) return [];
|
|
1581
|
+
const lead = `${paint(theme, "accent", " / ")}`;
|
|
1582
|
+
const tail = this.searching
|
|
1583
|
+
? paint(theme, "dim", " enter keep · esc clear · ↑↓ browse results ")
|
|
1584
|
+
: paint(theme, "dim", ` ${keyLabel(this.keys.search)} or / edits · esc clears `);
|
|
1585
|
+
const room = Math.max(4, width - visibleWidth(lead) - visibleWidth(tail));
|
|
1586
|
+
const field = this.searching ? this.search.render(room)[0] ?? "" : bold(theme, fit(query ?? "", room));
|
|
1587
|
+
return [fit(`${lead}${fit(field, room)}${tail}`, width)];
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** Every key the current tab understands while browsing. */
|
|
1591
|
+
private tabKeys(): KeyHelp[] {
|
|
695
1592
|
switch (this.tab) {
|
|
696
1593
|
case "lobby":
|
|
697
|
-
return
|
|
1594
|
+
return [
|
|
1595
|
+
{ key: "type", text: "talk to the oracle of the session in view (in this window, starts a task when none is running)" },
|
|
1596
|
+
{ key: "enter", text: "on an empty prompt: answer the question a background session waits on" },
|
|
1597
|
+
{ key: "↑ ↓", text: "scroll the focused pane (PageUp/PageDown a page)" },
|
|
1598
|
+
{ key: "← →", text: "move between the conversation, activity log and thinking" },
|
|
1599
|
+
{ key: "Home End", text: "the oldest lines, or back to the newest" },
|
|
1600
|
+
{ key: "c", text: "comment on this task's plan" },
|
|
1601
|
+
{ key: "esc", text: "stop the oracle while it works" },
|
|
1602
|
+
];
|
|
698
1603
|
case "tasks":
|
|
699
|
-
return
|
|
1604
|
+
return [
|
|
1605
|
+
{ key: "↑ ↓", text: "select a task or plan, or scroll the detail (PageUp/PageDown)" },
|
|
1606
|
+
{ key: "enter / ← →", text: "move between the list and the detail" },
|
|
1607
|
+
{ key: "c", text: "comment on the selected task's plan" },
|
|
1608
|
+
{ key: "s", text: "start the selected planned task in a new session, named after it" },
|
|
1609
|
+
{ key: "h", text: "start the selected planned task here, in this window" },
|
|
1610
|
+
{ key: "n", text: "type a new task that starts in its own session" },
|
|
1611
|
+
{ key: "o", text: "show the session driving the selected task in the Lobby tab" },
|
|
1612
|
+
{ key: "x x", text: "stop the background session driving the selected task" },
|
|
1613
|
+
{ key: "d d", text: "discard the selected planned task" },
|
|
1614
|
+
{ key: "r", text: "reread tasks from disk" },
|
|
1615
|
+
];
|
|
700
1616
|
case "plan":
|
|
701
|
-
return
|
|
1617
|
+
return [
|
|
1618
|
+
{ key: "a", text: "answer the panel's questions, one questionnaire at a time (enter on an empty prompt too)" },
|
|
1619
|
+
{ key: "enter / ← →", text: "move between the conversation and the draft" },
|
|
1620
|
+
{ key: "↑ ↓", text: "pick a draft line (draft) or scroll (conversation); PageUp/PageDown a page" },
|
|
1621
|
+
{ key: "c / click", text: "comment on the picked draft line" },
|
|
1622
|
+
{ key: "1-4", text: "seat or unseat DEV, DESIGN, QA, RESEARCH" },
|
|
1623
|
+
{ key: "n", text: "start a new plan" },
|
|
1624
|
+
{ key: "x", text: "stop the round" },
|
|
1625
|
+
{ key: "r", text: "retry a failed round or seat" },
|
|
1626
|
+
{ key: "m", text: "the oracle's model, thinking and time limit (Planner settings)" },
|
|
1627
|
+
];
|
|
702
1628
|
case "quickfix":
|
|
703
|
-
return
|
|
1629
|
+
return [
|
|
1630
|
+
{ key: "type", text: "describe a quick change" },
|
|
1631
|
+
{ key: "↑ ↓", text: "select a quick fix, or scroll the detail (PageUp/PageDown)" },
|
|
1632
|
+
{ key: "enter / ← →", text: "move between the list and the detail" },
|
|
1633
|
+
{ key: "x", text: "cancel the selected quick fix" },
|
|
1634
|
+
{ key: "m", text: "the quick fix agent's model, thinking, time limit and instructions" },
|
|
1635
|
+
];
|
|
704
1636
|
case "issues":
|
|
705
|
-
return
|
|
1637
|
+
return [
|
|
1638
|
+
{ key: "↑ ↓", text: "select an issue" },
|
|
1639
|
+
{ key: "enter", text: "read the selected issue" },
|
|
1640
|
+
{ key: "p", text: "plan the selected issue" },
|
|
1641
|
+
{ key: "n", text: "file a new issue" },
|
|
1642
|
+
{ key: "r", text: "reload issues" },
|
|
1643
|
+
];
|
|
706
1644
|
case "metrics":
|
|
707
|
-
return
|
|
1645
|
+
return [
|
|
1646
|
+
{ key: "↑ ↓", text: "select a row of the table" },
|
|
1647
|
+
{ key: "g", text: "group by model, or by model and agent" },
|
|
1648
|
+
{ key: "s", text: "change the sort" },
|
|
1649
|
+
{ key: "r", text: "reread the metrics log" },
|
|
1650
|
+
];
|
|
708
1651
|
}
|
|
709
1652
|
}
|
|
710
1653
|
|
|
1654
|
+
private chips(entries: ReadonlyArray<readonly [string, string]>, theme: LobbyTheme): string {
|
|
1655
|
+
return entries.map(([key, text]) => `${paint(theme, "accent", key)} ${paint(theme, "dim", text)}`).join(paint(theme, "dim", " "));
|
|
1656
|
+
}
|
|
1657
|
+
|
|
711
1658
|
private hintLine(width: number, theme: LobbyTheme): string {
|
|
712
1659
|
const notice = this.notice && this.now() - this.notice.at < NOTICE_MS ? this.notice : undefined;
|
|
713
1660
|
if (notice) return fit(paint(theme, notice.kind === "warning" ? "warning" : "accent", ` ${notice.text}`), width);
|
|
714
|
-
const
|
|
715
|
-
|
|
1661
|
+
const k = (action: LobbyAction) => keyLabel(this.keys[action]).toLowerCase();
|
|
1662
|
+
const badge = (text: string, color: "accent" | "muted" | "warning") => {
|
|
1663
|
+
const label = bold(theme, paint(theme, color, ` ${text} `));
|
|
1664
|
+
return theme.bg ? theme.bg("selectedBg", label) : label;
|
|
1665
|
+
};
|
|
1666
|
+
if (this.help) return fit(`${badge("KEYS", "accent")} ${this.chips([["any key", "closes"]], theme)}`, width);
|
|
1667
|
+
if (this.searching) return fit(`${badge("SEARCH", "warning")} ${this.chips([["enter", "keep"], ["esc", "clear"], ["↑↓", "results"], [k("nextTab"), "next tab"]], theme)}`, width);
|
|
1668
|
+
const mode = this.mode === "type" ? badge("TYPE", "accent") : badge("BROWSE", "muted");
|
|
1669
|
+
return fit(`${mode} ${this.chips(this.hintChips(), theme)}`, width);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* The few keys that matter right now, most useful first: typing names what
|
|
1674
|
+
* enter does here; browsing names the tab's main commands, only those that
|
|
1675
|
+
* apply. Everything else is one `?` away.
|
|
1676
|
+
*/
|
|
1677
|
+
private hintChips(): Array<[string, string]> {
|
|
1678
|
+
const k = (action: LobbyAction) => keyLabel(this.keys[action]).toLowerCase();
|
|
1679
|
+
const session = this.host.planner();
|
|
1680
|
+
if (this.mode === "type") {
|
|
1681
|
+
if (this.tab === "lobby" && this.newSession) return [["enter", "start in a new session"], ["esc", "cancel"]];
|
|
1682
|
+
if (this.tab === "lobby" && this.viewing.kind !== "here") {
|
|
1683
|
+
const background = this.viewedSession();
|
|
1684
|
+
if (background?.dialogs.length && !this.editor.getText().trim()) return [["enter", "answer its question"], [k("sessions"), "sessions"], [k("hide"), "hide"]];
|
|
1685
|
+
return [["enter", background?.busy ? "steer" : "send"], [k("sessions"), "sessions"], [k("toggleAuto"), "auto"]];
|
|
1686
|
+
}
|
|
1687
|
+
if (this.tab === "lobby" && this.host.masterBusy()) return [["enter", "steer"], ["esc", "stop the oracle"], [k("hide"), "hide"]];
|
|
1688
|
+
if (this.tab === "plan" && this.lineTarget) return [["enter", "add the comment"], ["esc", "cancel"]];
|
|
1689
|
+
const enter = this.tab === "plan" && session?.awaitingAnswers && !this.editor.getText().trim() ? "answer questions" : this.tab === "quickfix" ? "run it" : "send";
|
|
1690
|
+
return [["enter", enter], ["esc", "browse"], [k("hide"), "hide"]];
|
|
1691
|
+
}
|
|
1692
|
+
const keys: Array<[string, string]> = [];
|
|
1693
|
+
switch (this.tab) {
|
|
1694
|
+
case "lobby":
|
|
1695
|
+
keys.push(["←→", "pane"], ["↑↓", "scroll"], [k("sessions"), "sessions"], [k("newSession"), "new session"]);
|
|
1696
|
+
if (this.viewedEntry().task) keys.push([k("toggleAuto"), "auto"], ["c", "comment on plan"]);
|
|
1697
|
+
break;
|
|
1698
|
+
case "tasks": {
|
|
1699
|
+
const row = this.taskRowList()[this.tasksSelected];
|
|
1700
|
+
keys.push(["↑↓", "select"]);
|
|
1701
|
+
if (row?.kind === "task") keys.push(["c", "comment"], ["o", "open its session"], [k("toggleAuto"), "auto"]);
|
|
1702
|
+
if (row?.kind === "plan") keys.push(["s", "start in a new session"], ["h", "start here"], ["d", "discard"]);
|
|
1703
|
+
keys.push(["n", "new task"]);
|
|
1704
|
+
break;
|
|
1705
|
+
}
|
|
1706
|
+
case "plan":
|
|
1707
|
+
if (session?.awaitingAnswers) keys.push(["a", "answer"]);
|
|
1708
|
+
if (session?.reply?.plan) keys.push([k("savePlan"), "save"], ["c", "comment on a line"]);
|
|
1709
|
+
if (session?.busy) keys.push(["x", "stop"]);
|
|
1710
|
+
keys.push(["n", "new"], ["1-4", "seats"]);
|
|
1711
|
+
break;
|
|
1712
|
+
case "quickfix":
|
|
1713
|
+
if (this.host.quickfix.jobs.length > 0) keys.push(["↑↓", "select"], ["x", "cancel"]);
|
|
1714
|
+
keys.push(["m", "model"]);
|
|
1715
|
+
break;
|
|
1716
|
+
case "issues":
|
|
1717
|
+
keys.push(["↑↓", "select"], ["p", "plan it"], ["n", "new"]);
|
|
1718
|
+
break;
|
|
1719
|
+
case "metrics":
|
|
1720
|
+
keys.push(["↑↓", "select"], ["g", "group"], ["s", "sort"]);
|
|
1721
|
+
break;
|
|
1722
|
+
}
|
|
1723
|
+
return [...keys, ["?", "all keys"]];
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/** The help screen: every shortcut, the typing and browsing keys, and this tab's commands. */
|
|
1727
|
+
private helpBody(width: number, height: number, theme: LobbyTheme): string[] {
|
|
1728
|
+
const keyWidth = 13;
|
|
1729
|
+
const row = (key: string, text: string, inner: number) => wrapHanging(`${paint(theme, "accent", fit(key, keyWidth))} `, text, inner);
|
|
1730
|
+
const section = (title: string) => bold(theme, paint(theme, "mdHeading", title));
|
|
1731
|
+
const tabs = this.tabs();
|
|
1732
|
+
const everywhere = (inner: number) => [
|
|
1733
|
+
section("Everywhere"),
|
|
1734
|
+
...(Object.keys(LOBBY_ACTIONS) as LobbyAction[]).flatMap((action) => row(keyLabel(this.keys[action]), LOBBY_ACTIONS[action].help, inner)),
|
|
1735
|
+
...row(`Alt+1…${tabs.length}`, "jump to a tab", inner),
|
|
1736
|
+
...row("Ctrl+C", "clear the prompt, or hide the lobby", inner),
|
|
1737
|
+
...row("click", "a tab to open it, a pane to give it the keys, a draft plan line to comment on it", inner),
|
|
1738
|
+
...row("wheel", "scroll the pane under the pointer", inner),
|
|
1739
|
+
];
|
|
1740
|
+
const modes = (inner: number) => [
|
|
1741
|
+
section("Typing"),
|
|
1742
|
+
...row("Enter", "send", inner),
|
|
1743
|
+
...row("Shift+Enter", "new line", inner),
|
|
1744
|
+
...row("Esc", "stop typing and browse", inner),
|
|
1745
|
+
"",
|
|
1746
|
+
section("Browsing"),
|
|
1747
|
+
...row("i", "type", inner),
|
|
1748
|
+
...row("/", "search this tab (esc clears)", inner),
|
|
1749
|
+
...row("?", "these keys", inner),
|
|
1750
|
+
"",
|
|
1751
|
+
section(`${TAB_LABELS[this.tab]} tab`),
|
|
1752
|
+
...this.tabKeys().flatMap((entry) => row(entry.key, entry.text, inner)),
|
|
1753
|
+
];
|
|
1754
|
+
const footer = paint(theme, "dim", "Rebind any shortcut under lobby.keys in the bot-lobby config, e.g. { \"toggleThinking\": \"alt+t\" }. Hidden panes stay hidden next time (lobby.panels); lobby.mouse turns clicks off.");
|
|
1755
|
+
const title = { title: "Keys", right: "any key closes", focused: true, theme } as const;
|
|
1756
|
+
if (width >= 100) {
|
|
1757
|
+
const left = Math.floor((width - 1) / 2);
|
|
1758
|
+
const inner = left - 4;
|
|
1759
|
+
const columns = beside([
|
|
1760
|
+
box(left, height, [...everywhere(inner), "", ...wrapHanging("", footer, inner)], title),
|
|
1761
|
+
box(width - 1 - left, height, modes(width - 1 - left - 4), { focused: true, theme }),
|
|
1762
|
+
]);
|
|
1763
|
+
return columns;
|
|
1764
|
+
}
|
|
1765
|
+
const inner = width - 4;
|
|
1766
|
+
return box(width, height, [...everywhere(inner), "", ...modes(inner), "", ...wrapHanging("", footer, inner)], title);
|
|
716
1767
|
}
|
|
717
1768
|
|
|
718
1769
|
private body(width: number, height: number, theme: LobbyTheme): string[] {
|
|
719
1770
|
const now = this.now();
|
|
1771
|
+
const query = this.query();
|
|
1772
|
+
this.panes.clear();
|
|
720
1773
|
switch (this.tab) {
|
|
721
1774
|
case "lobby":
|
|
722
1775
|
return this.homeBody(width, height, theme, now);
|
|
723
1776
|
case "tasks":
|
|
724
1777
|
return this.tasksBody(width, height, theme, now);
|
|
725
|
-
case "plan":
|
|
726
|
-
|
|
727
|
-
|
|
1778
|
+
case "plan": {
|
|
1779
|
+
const session = this.host.planner();
|
|
1780
|
+
const lines = renderPlan({
|
|
1781
|
+
...(session ? { session: this.planView(session) } : {}),
|
|
728
1782
|
profile: this.host.profileLabel("planner"),
|
|
729
1783
|
seats: this.seatViews(undefined),
|
|
730
1784
|
offset: this.planOffset,
|
|
731
1785
|
focus: this.planFocus,
|
|
732
1786
|
draftOffset: this.planDraftOffset,
|
|
1787
|
+
cursor: this.planCursor,
|
|
733
1788
|
tick: this.tick,
|
|
1789
|
+
...(query ? { query } : {}),
|
|
1790
|
+
layout: this.planLayout,
|
|
1791
|
+
panes: this.panes,
|
|
1792
|
+
saveKey: keyLabel(this.keys.savePlan),
|
|
734
1793
|
}, width, height, theme);
|
|
1794
|
+
if (!session) Object.assign(this.planLayout, { draftRows: 0, draftStart: 0, draftText: [] });
|
|
1795
|
+
this.planCursor = Math.min(this.planCursor, Math.max(0, this.planLayout.draftText.length - 1));
|
|
1796
|
+
return lines;
|
|
1797
|
+
}
|
|
735
1798
|
case "quickfix":
|
|
736
|
-
this.fixSelected = Math.min(this.fixSelected, Math.max(0, this.
|
|
737
|
-
return renderQuickFix({ jobs: this.host.quickfix.jobs, selected: this.fixSelected, focus: this.fixFocus, detailOffset: this.fixDetailOffset, profile: this.host.profileLabel("quickfix"), tick: this.tick, now }, width, height, theme);
|
|
1799
|
+
this.fixSelected = Math.min(this.fixSelected, Math.max(0, this.fixJobs().length - 1));
|
|
1800
|
+
return renderQuickFix({ jobs: this.host.quickfix.jobs, selected: this.fixSelected, focus: this.fixFocus, detailOffset: this.fixDetailOffset, profile: this.host.profileLabel("quickfix"), tick: this.tick, now, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
|
|
738
1801
|
case "issues": {
|
|
739
1802
|
const issues = this.host.issues;
|
|
740
1803
|
this.issueSelected = Math.min(this.issueSelected, Math.max(0, issues.issues.length - 1));
|
|
@@ -746,11 +1809,11 @@ export class LobbyView implements Component, Focusable {
|
|
|
746
1809
|
}, width, height, theme);
|
|
747
1810
|
}
|
|
748
1811
|
case "metrics": {
|
|
749
|
-
const records = collectMetrics(this.data.metrics, this.data.tasks);
|
|
1812
|
+
const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
|
|
750
1813
|
const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
|
|
751
1814
|
this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
|
|
752
1815
|
const taskTimes = taskTimesByModel(this.data.tasks, records);
|
|
753
|
-
return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected }, width, height, theme);
|
|
1816
|
+
return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
|
|
754
1817
|
}
|
|
755
1818
|
}
|
|
756
1819
|
}
|
|
@@ -777,7 +1840,8 @@ export class LobbyView implements Component, Focusable {
|
|
|
777
1840
|
seated: seats.has(member),
|
|
778
1841
|
status: state ? (state.status === "thinking" ? "thinking" : state.status) : "idle",
|
|
779
1842
|
...(state?.step ? { step: state.step } : {}),
|
|
780
|
-
|
|
1843
|
+
// The questions from this seat that reached the user this round.
|
|
1844
|
+
questions: session?.questions.filter((question) => question.from === MEMBER_LABELS[member]).length ?? 0,
|
|
781
1845
|
ready: state?.reply?.status === "ready",
|
|
782
1846
|
profile: this.host.seatLabel(member),
|
|
783
1847
|
};
|
|
@@ -799,16 +1863,42 @@ export class LobbyView implements Component, Focusable {
|
|
|
799
1863
|
...(session.seed ? { seed: session.seed } : {}),
|
|
800
1864
|
...(session.saved ? { saved: session.saved } : {}),
|
|
801
1865
|
...(session.title ? { title: session.title } : {}),
|
|
1866
|
+
awaitingAnswers: session.awaitingAnswers,
|
|
1867
|
+
answeredChunks: session.answered.length,
|
|
1868
|
+
lineComments: session.lineComments,
|
|
802
1869
|
};
|
|
803
1870
|
}
|
|
804
1871
|
|
|
1872
|
+
/** The session switcher: every session this window can show, with what each is doing. */
|
|
1873
|
+
private pickerBody(width: number, height: number, theme: LobbyTheme): string[] {
|
|
1874
|
+
const entries = this.sessionEntries();
|
|
1875
|
+
this.pickIndex = Math.min(this.pickIndex, Math.max(0, entries.length - 1));
|
|
1876
|
+
const inner = width - 4;
|
|
1877
|
+
const nameWidth = Math.min(34, Math.max(14, Math.floor(inner * 0.36)));
|
|
1878
|
+
const marks = { "this window": "●", background: "◆", "other terminal": "◇" } as const;
|
|
1879
|
+
const lines = entries.map((entry, index) => {
|
|
1880
|
+
const selected = index === this.pickIndex;
|
|
1881
|
+
const mark = paint(theme, entry.where === "this window" ? "accent" : "toolTitle", marks[entry.where]);
|
|
1882
|
+
const name = fit(selected ? bold(theme, entry.name) : entry.name, nameWidth);
|
|
1883
|
+
const status = paint(theme, entry.status === "working" ? "accent" : entry.status === "ended" ? "dim" : "muted", entry.status.replace(/_/g, " "));
|
|
1884
|
+
const badges = [entry.auto ? paint(theme, "success", "⟳ auto") : "", entry.waiting > 0 ? paint(theme, "warning", `● ${entry.waiting} waiting`) : ""].filter(Boolean).join(" ");
|
|
1885
|
+
const where = paint(theme, "dim", entry.where);
|
|
1886
|
+
return selectRow(theme, `${selected ? paint(theme, "accent", "▸") : " "} ${mark} ${name} ${where} ${paint(theme, "dim", "·")} ${status}${badges ? ` ${badges}` : ""}`, inner, selected, true);
|
|
1887
|
+
});
|
|
1888
|
+
const help = paint(theme, "dim", "enter shows it here · n new task in a new session · x x stops a background session · esc closes");
|
|
1889
|
+
return box(width, height, [...lines, "", ...wrap(help, inner)], { title: "Sessions", right: `${entries.length}`, focused: true, theme });
|
|
1890
|
+
}
|
|
1891
|
+
|
|
805
1892
|
private homeBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
|
|
806
1893
|
const zen = this.host.zen();
|
|
807
1894
|
const feed = this.host.feed;
|
|
808
1895
|
const sessionId = this.host.sessionId();
|
|
809
1896
|
const others = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && task.ownerSessionId !== sessionId).length;
|
|
1897
|
+
const query = this.query();
|
|
1898
|
+
const entry = this.viewedEntry();
|
|
1899
|
+
if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
|
|
810
1900
|
return renderHome({
|
|
811
|
-
...(zen.task ? { task: zen.task, scene: (w: number, h: number) => this.host.scene(w, h) } : {}),
|
|
1901
|
+
...(zen.task ? { task: zen.task, scene: (w: number, h: number, animated: boolean) => this.host.scene(w, h, animated) } : {}),
|
|
812
1902
|
chat: feed.chat,
|
|
813
1903
|
...(feed.reply ? { liveReply: feed.reply } : {}),
|
|
814
1904
|
activity: feed.activity,
|
|
@@ -816,17 +1906,78 @@ export class LobbyView implements Component, Focusable {
|
|
|
816
1906
|
busy: this.host.masterBusy(),
|
|
817
1907
|
others,
|
|
818
1908
|
pending: this.data.plans.filter((plan) => plan.status === "pending").length,
|
|
819
|
-
|
|
1909
|
+
offsets: this.homeOffsets,
|
|
1910
|
+
...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
|
|
1911
|
+
panes: this.panes,
|
|
820
1912
|
tick: this.tick,
|
|
821
1913
|
now,
|
|
1914
|
+
panels: this.panels,
|
|
1915
|
+
...(query ? { query } : {}),
|
|
1916
|
+
keys: {
|
|
1917
|
+
animations: keyLabel(this.keys.toggleScene),
|
|
1918
|
+
conversation: keyLabel(this.keys.toggleConversation),
|
|
1919
|
+
activity: keyLabel(this.keys.toggleActivity),
|
|
1920
|
+
thinking: keyLabel(this.keys.toggleThinking),
|
|
1921
|
+
},
|
|
1922
|
+
}, width, height, theme);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* Another session on the Lobby tab: a background session's live feed, or an
|
|
1927
|
+
* other terminal's conversation read from its session file; the task's
|
|
1928
|
+
* status sits on top without animations.
|
|
1929
|
+
*/
|
|
1930
|
+
private otherSessionBody(entry: SessionEntry, width: number, height: number, theme: LobbyTheme, now: number): string[] {
|
|
1931
|
+
const session = this.viewedSession();
|
|
1932
|
+
const task = entry.task;
|
|
1933
|
+
const query = this.query();
|
|
1934
|
+
const common = {
|
|
1935
|
+
...(task ? { task, scene: (w: number, h: number) => this.host.taskScene(task, w, h), stillScene: true } : {}),
|
|
1936
|
+
others: 0,
|
|
1937
|
+
pending: 0,
|
|
1938
|
+
offsets: this.homeOffsets,
|
|
1939
|
+
...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
|
|
1940
|
+
panes: this.panes,
|
|
1941
|
+
tick: this.tick,
|
|
1942
|
+
now,
|
|
1943
|
+
panels: this.panels,
|
|
1944
|
+
...(query ? { query } : {}),
|
|
1945
|
+
title: entry.name,
|
|
1946
|
+
keys: {
|
|
1947
|
+
animations: keyLabel(this.keys.toggleScene),
|
|
1948
|
+
conversation: keyLabel(this.keys.toggleConversation),
|
|
1949
|
+
activity: keyLabel(this.keys.toggleActivity),
|
|
1950
|
+
thinking: keyLabel(this.keys.toggleThinking),
|
|
1951
|
+
},
|
|
1952
|
+
};
|
|
1953
|
+
if (session) {
|
|
1954
|
+
const emptyNote = session.status === "starting" ? `starting ${session.name}…` : !session.alive ? `${session.name} has ended.` : `${session.name} has not said anything yet.`;
|
|
1955
|
+
return renderHome({
|
|
1956
|
+
...common,
|
|
1957
|
+
chat: session.feed.chat,
|
|
1958
|
+
...(session.feed.reply ? { liveReply: session.feed.reply } : {}),
|
|
1959
|
+
activity: session.feed.activity,
|
|
1960
|
+
thoughts: session.feed.thoughts,
|
|
1961
|
+
busy: session.busy,
|
|
1962
|
+
emptyNote,
|
|
1963
|
+
}, width, height, theme);
|
|
1964
|
+
}
|
|
1965
|
+
return renderHome({
|
|
1966
|
+
...common,
|
|
1967
|
+
chat: task?.ownerSessionId ? this.host.sessionChat(task.ownerSessionId) : [],
|
|
1968
|
+
activity: [],
|
|
1969
|
+
thoughts: [],
|
|
1970
|
+
busy: false,
|
|
1971
|
+
emptyNote: "Nothing said in this session yet.",
|
|
1972
|
+
activityNote: "This session runs in another terminal: its conversation shows here and your messages reach its oracle, but live activity only streams from sessions started in this window.",
|
|
822
1973
|
}, width, height, theme);
|
|
823
1974
|
}
|
|
824
1975
|
|
|
825
1976
|
private tasksBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
|
|
826
|
-
const rows = this.taskRowList(
|
|
1977
|
+
const rows = this.taskRowList();
|
|
827
1978
|
this.tasksSelected = Math.min(this.tasksSelected, Math.max(0, rows.length - 1));
|
|
828
1979
|
const row = rows[this.tasksSelected];
|
|
829
|
-
const detailWidth =
|
|
1980
|
+
const detailWidth = tasksWidths(width).detail - 4;
|
|
830
1981
|
let detail: string[] = [];
|
|
831
1982
|
if (row?.kind === "task") {
|
|
832
1983
|
const task = this.data.tasks.find((entry) => entry.id === row.id);
|
|
@@ -835,21 +1986,30 @@ export class LobbyView implements Component, Focusable {
|
|
|
835
1986
|
const plan = this.data.plans.find((entry) => entry.id === row.id);
|
|
836
1987
|
if (plan) detail = planDetailLines(plan, detailWidth, now, theme);
|
|
837
1988
|
}
|
|
838
|
-
|
|
1989
|
+
const query = this.query();
|
|
1990
|
+
return renderTasks({ rows, selected: this.tasksSelected, detail, focus: this.tasksFocus, detailOffset: this.tasksDetailOffset, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
|
|
839
1991
|
}
|
|
840
1992
|
|
|
841
1993
|
render(width: number): string[] {
|
|
842
1994
|
const theme = this.host.theme();
|
|
843
1995
|
const rows = Math.max(1, this.host.rows());
|
|
844
|
-
const top = [this.tabBar(width, theme)
|
|
1996
|
+
const top = [this.tabBar(width, theme)];
|
|
845
1997
|
const prompt = this.promptLines(width, theme);
|
|
1998
|
+
const search = this.searchLines(width, theme);
|
|
846
1999
|
const hint = this.hintLine(width, theme);
|
|
847
|
-
const bodyHeight = Math.max(0, rows - top.length - prompt.length - 1);
|
|
848
|
-
|
|
849
|
-
|
|
2000
|
+
const bodyHeight = Math.max(0, rows - top.length - search.length - prompt.length - 1);
|
|
2001
|
+
this.bodyTop = top.length;
|
|
2002
|
+
this.promptTop = top.length + bodyHeight + search.length;
|
|
2003
|
+
let body = this.help ? this.helpBody(width, bodyHeight, theme) : this.picking ? this.pickerBody(width, bodyHeight, theme) : this.body(width, bodyHeight, theme);
|
|
2004
|
+
// A pane scrolled back that gained lines moves with them, so it keeps showing what was being read.
|
|
2005
|
+
if (!this.help && !this.picking && this.clampScroll()) body = this.body(width, bodyHeight, theme);
|
|
2006
|
+
const query = this.query();
|
|
2007
|
+
if (query && !this.help) body = body.map((line) => highlight(line, query));
|
|
2008
|
+
return [...top, ...body, ...search, ...prompt, hint].slice(0, rows).map((line) => fit(line, width));
|
|
850
2009
|
}
|
|
851
2010
|
|
|
852
2011
|
invalidate(): void {
|
|
853
2012
|
this.editor.invalidate();
|
|
2013
|
+
this.search.invalidate();
|
|
854
2014
|
}
|
|
855
2015
|
}
|