pi-crew 0.10.6 → 0.11.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/index.mjs +34850 -34889
  3. package/package.json +170 -169
  4. package/scripts/watch-agent-transcript.mjs +120 -0
  5. package/src/extension/crew-input-router.ts +2 -0
  6. package/src/extension/crew-vibes/config.ts +1 -56
  7. package/src/extension/crew-vibes/figures.ts +5 -84
  8. package/src/extension/crew-vibes/font-detect.ts +1 -1
  9. package/src/extension/crew-vibes/index.ts +36 -328
  10. package/src/extension/crew-vibes/render.ts +2 -58
  11. package/src/extension/help.ts +1 -0
  12. package/src/extension/registration/command-registration.ts +5 -1
  13. package/src/extension/registration/commands/schedules.ts +212 -0
  14. package/src/extension/registration/commands/shared.ts +56 -1
  15. package/src/extension/registration/lifecycle-handlers.ts +16 -2
  16. package/src/extension/registration/schedule-toast-bridge.ts +109 -0
  17. package/src/extension/registration/viewers.ts +66 -1
  18. package/src/extension/team-tool/handle-schedule.ts +120 -1
  19. package/src/runtime/scheduling/scheduler.ts +120 -6
  20. package/src/runtime/settings-store.ts +28 -2
  21. package/src/state/coordination/locks.ts +17 -2
  22. package/src/ui/agents-jobs-browser.ts +785 -0
  23. package/src/ui/dashboard-panes/agents-pane.ts +7 -4
  24. package/src/ui/dashboard-panes/schedules-pane.ts +154 -0
  25. package/src/ui/inline-panel/crew-editor.ts +19 -6
  26. package/src/ui/inline-panel/index.ts +6 -0
  27. package/src/ui/key-utils.ts +3 -0
  28. package/src/ui/keybinding-map.ts +56 -2
  29. package/src/ui/mascot.ts +2 -1
  30. package/src/ui/overlays/agent-picker-overlay.ts +3 -3
  31. package/src/ui/overlays/confirm-overlay.ts +3 -2
  32. package/src/ui/overlays/mailbox-compose-overlay.ts +4 -3
  33. package/src/ui/overlays/mailbox-detail-overlay.ts +3 -3
  34. package/src/ui/run-dashboard.ts +257 -6
  35. package/src/ui/settings-overlay.ts +3 -3
  36. package/src/ui/transcript-viewer.ts +3 -2
  37. package/src/ui/widget/index.ts +61 -88
  38. package/src/ui/widget/widget-formatters.ts +7 -4
  39. package/src/ui/widget/widget-renderer.ts +134 -174
  40. package/src/ui/widget/widget-types.ts +0 -7
  41. package/src/utils/relative-time.ts +33 -0
  42. package/src/extension/crew-vibes/footer.ts +0 -313
  43. package/src/extension/crew-vibes/speed.ts +0 -286
  44. package/src/ui/dock-footer.ts +0 -49
@@ -34,7 +34,7 @@ function alignMetric(value: string, width: number): string {
34
34
  * Scaffold-only agents (no tokens, no tools, no turns) are skipped in the agents pane —
35
35
  * they represent pipeline infrastructure steps, not actual agent execution.
36
36
  */
37
- function isRealAgent(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle): boolean {
37
+ function isRealAgent(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle, nowMs?: number): boolean {
38
38
  if (agent.runtime === "live-session" || agent.runtime === "child-process") return true;
39
39
  // Scaffold agents with real work done are still worth showing
40
40
  const tokens = (agent.usage?.input ?? 0) + (agent.usage?.output ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0);
@@ -44,7 +44,7 @@ function isRealAgent(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle): bool
44
44
  if ((agent.progress?.toolCount ?? 0) > 0) return true;
45
45
  // If it's still running and has been alive for > 30s, it might be real
46
46
  if (liveHandle) {
47
- const ms = Date.now() - liveHandle.activity.startedAtMs;
47
+ const ms = (nowMs ?? Date.now()) - liveHandle.activity.startedAtMs;
48
48
  if (ms > 30_000) return true;
49
49
  }
50
50
  return false;
@@ -86,11 +86,14 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
86
86
  const { completed, total } = snapshot.progress;
87
87
 
88
88
  const lines: string[] = [];
89
+ // One pinned clock (D6-T4) for the whole pane render.
90
+ const nowMs = options.nowMs ?? Date.now();
89
91
 
90
92
  const realAgents = snapshot.agents.filter((a) =>
91
93
  isRealAgent(
92
94
  a,
93
95
  liveForRun.find((h) => h.taskId === a.taskId),
96
+ nowMs,
94
97
  ),
95
98
  );
96
99
  const lineCount = Math.min(realAgents.length, 12);
@@ -141,13 +144,13 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
141
144
  // 0/undefined/bad, or a race set completedAtMs < startedAtMs. This
142
145
  // fired for EVERY running live agent in the dashboard. Use the shared,
143
146
  // validated computeLiveDurationMs (mirrors widget-formatters.ts).
144
- const ms = computeLiveDurationMs(liveHandle.activity);
147
+ const ms = computeLiveDurationMs(liveHandle.activity, nowMs);
145
148
  stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
146
149
  if (options.showModel !== false && liveHandle.modelName && liveHandle.modelName !== "default") {
147
150
  stats.push(liveHandle.modelName);
148
151
  }
149
152
  } else if (agent.startedAt) {
150
- const ms = Date.now() - new Date(agent.startedAt).getTime();
153
+ const ms = nowMs - new Date(agent.startedAt).getTime();
151
154
  if (Number.isFinite(ms)) stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
152
155
  }
153
156
 
@@ -0,0 +1,154 @@
1
+ import { humanizeSchedule, type ScheduledJob } from "../../runtime/scheduling/scheduler.ts";
2
+ import { formatRelativeTime } from "../../utils/relative-time.ts";
3
+ import { sanitizeLine, truncate } from "../../utils/visual.ts";
4
+
5
+ /**
6
+ * Schedules pane (tier A foundation) — pure string[] renderer following
7
+ * health-pane.ts conventions: no clock reads (D6-T4 — `now` is an injected,
8
+ * REQUIRED parameter), no color (the dashboard colorizes/truncates lines at
9
+ * the emit pipeline), no provider import (callers pass jobs read via
10
+ * getScheduledJobs() — the single source of truth, G17).
11
+ */
12
+ export interface SchedulesPaneOptions {
13
+ /** Max visual width of the job-name column before truncation. */
14
+ nameWidth?: number;
15
+ /** Show the interactive key hint (T/N/V/X/R). Dashboard-only; the headless
16
+ * text block and non-foreground contexts pass false. Default true. */
17
+ foreground?: boolean;
18
+ /** Include the full job id in the sub line. The headless `/schedules`
19
+ * text block needs ids so users can address jobs in `subAction=...` and
20
+ * `/schedules log`. Default false. */
21
+ includeIds?: boolean;
22
+ /** Index of the cursor-selected job (dashboard pane 8). Prefixes the main
23
+ * line with `›` exactly like the run-list selection marker; undefined
24
+ * (headless / text-block callers) renders no marker. */
25
+ selectedIndex?: number;
26
+ /** P2-1 (B2 gate visibility): how many project-tier scheduledJobs the opt-in
27
+ * gate is hiding. > 0 renders EXACTLY ONE dim hint line (why + count + the
28
+ * opt-in path: BOTH flags in the USER-tier ~/.pi/crew-settings.json).
29
+ * Omitted/0 renders nothing — existing layouts stay byte-identical. */
30
+ hiddenCount?: number;
31
+ }
32
+
33
+ export const SCHEDULES_EMPTY_STATE = "No scheduled jobs — create via team tool action='schedule'";
34
+
35
+ /** P2-1 hint line (single source of truth — pane table, empty state, and the
36
+ * headless /schedules text block all render THIS text). Empty string when
37
+ * there is nothing to hint (callers render no line). Pure: no clock, no I/O;
38
+ * the count comes from the caller's provider read. */
39
+ export function schedulesHiddenJobsHintLine(hiddenCount: number): string {
40
+ if (!Number.isFinite(hiddenCount) || hiddenCount <= 0) return "";
41
+ const n = Math.floor(hiddenCount);
42
+ return `⚠ ${n} project-tier job${n === 1 ? "" : "s"} hidden — opt in via ~/.pi/crew-settings.json: schedulingEnabled + allowProjectScheduledJobs`;
43
+ }
44
+
45
+ /**
46
+ * Render the schedules table: one main line per job
47
+ * (●/○ enabled · name · humanizeSchedule · next-run RELATIVE · last status
48
+ * ✓/✗/⟳ · runCount) plus a sub line (subagentType · lastRun · id when
49
+ * includeIds). Empty state is a single shared line.
50
+ */
51
+ export function renderSchedulesPane(jobs: ScheduledJob[], now: Date, opts: SchedulesPaneOptions = {}): string[] {
52
+ const hint = schedulesHiddenJobsHintLine(opts.hiddenCount ?? 0);
53
+ if (jobs.length === 0) {
54
+ // P2-1: the all-hidden case is exactly when a bare empty state lied —
55
+ // the hint below it is the only signal project-tier jobs exist.
56
+ return hint ? [SCHEDULES_EMPTY_STATE, hint] : [SCHEDULES_EMPTY_STATE];
57
+ }
58
+ const lines = [`Scheduled jobs (${jobs.length}):`];
59
+ for (const [index, job] of jobs.entries()) {
60
+ lines.push(...renderJobLines(job, now, opts, index === opts.selectedIndex));
61
+ }
62
+ if (hint) lines.push(hint);
63
+ if (opts.foreground !== false) {
64
+ lines.push("Actions: T toggle · N run now · V details · X delete · R refresh");
65
+ }
66
+ return lines;
67
+ }
68
+
69
+ /**
70
+ * Plain text-block variant for the headless `/schedules` command (tier E).
71
+ * Reuses the SAME renderer (no copied table logic): identical table + empty
72
+ * state, interactive keys swapped for the non-interactive manage hint, and
73
+ * job ids included so headless users can address them.
74
+ */
75
+ export function renderSchedulesTextBlock(jobs: ScheduledJob[], now: Date, opts: SchedulesPaneOptions = {}): string[] {
76
+ const lines = renderSchedulesPane(jobs, now, { ...opts, foreground: false, includeIds: true });
77
+ if (jobs.length > 0) {
78
+ lines.push("Manage: team action='schedule' subAction='enable|disable|remove|run-now' jobId='<id>'");
79
+ }
80
+ return lines;
81
+ }
82
+
83
+ function renderJobLines(job: ScheduledJob, now: Date, opts: SchedulesPaneOptions, selected: boolean): string[] {
84
+ const glyph = job.enabled ? "●" : "○";
85
+ const name = truncate(sanitizeLine(job.name), opts.nameWidth ?? 28);
86
+ // The humanized schedule embeds the RAW job.schedule ("cron …"/"once at …")
87
+ // — same untrusted persisted field the details view sanitizes, so the
88
+ // main line gets the identical treatment (headless /schedules notify path
89
+ // applies NO downstream sanitization — security review F-1).
90
+ const schedule = sanitizeLine(humanizeSchedule({ kind: job.scheduleType, spec: job.schedule }));
91
+ const next = job.nextRun ? formatRelativeTime(now, new Date(job.nextRun)) : "—";
92
+ // Cursor marker (dashboard pane 8): `›` on the selected job, two spaces on
93
+ // the others — mirroring runLabel's `›`/space marker. Callers that pass NO
94
+ // selectedIndex (headless text block, foundation tests) get the marker-free
95
+ // legacy layout byte-for-byte.
96
+ const marker = opts.selectedIndex === undefined ? "" : selected ? "› " : " ";
97
+ const main = `${marker}${glyph} ${name} ${schedule} · ${next} · ${statusGlyph(job.lastStatus)} · ${job.runCount} runs`;
98
+ return [main, renderSubLine(job, now, opts)];
99
+ }
100
+
101
+ function statusGlyph(status: ScheduledJob["lastStatus"]): string {
102
+ if (status === "success") return "✓";
103
+ if (status === "error") return "✗";
104
+ if (status === "running") return "⟳";
105
+ return "·";
106
+ }
107
+
108
+ function renderSubLine(job: ScheduledJob, now: Date, opts: SchedulesPaneOptions): string {
109
+ // Security review F-1: every persisted-field interpolation is sanitized —
110
+ // the dashboard emit pipeline sanitizes whole lines, but the headless
111
+ // /schedules text block goes to ui.notify verbatim, and subagentType/id
112
+ // from a crafted settings entry must not inject line breaks there.
113
+ const parts = [sanitizeLine(job.subagentType)];
114
+ parts.push(job.lastRun ? `last: ${formatRelativeTime(now, new Date(job.lastRun))}` : "last: never");
115
+ // Duration: rendered only when the job model carries one ("duration if
116
+ // available" in the approved design). ScheduledJob has no duration field
117
+ // today, so nothing is invented here — a future field slots in below.
118
+ if (opts.includeIds) parts.push(`id: ${sanitizeLine(job.id)}`);
119
+ return ` ◦ ${parts.join(" · ")}`;
120
+ }
121
+
122
+ /** Extract the full scheduling goal from a job's prompt payload. The create
123
+ * path stores `{ action: 'run', team, goal }` as JSON; older/foreign jobs may
124
+ * carry free text — fall back to description, then name. */
125
+ function scheduleGoalOf(job: ScheduledJob): string {
126
+ try {
127
+ const parsed = JSON.parse(job.prompt) as { goal?: unknown };
128
+ if (parsed && typeof parsed === "object" && typeof parsed.goal === "string" && parsed.goal.length > 0) {
129
+ return parsed.goal;
130
+ }
131
+ } catch {
132
+ /* prompt is not JSON — fall through */
133
+ }
134
+ return job.description || job.name;
135
+ }
136
+
137
+ /**
138
+ * Details view for ONE job (dashboard pane 8, key V): full goal + schedule
139
+ * spec + spawnedRunIds — everything the approved design lists for "details".
140
+ * Pure, clock-injected (D6-T4), plain string[] like the table renderer; the
141
+ * dashboard emit pipeline truncates/sanitizes per line.
142
+ */
143
+ export function renderScheduleDetails(job: ScheduledJob, now: Date): string[] {
144
+ const schedule = humanizeSchedule({ kind: job.scheduleType, spec: job.schedule });
145
+ const next = job.nextRun ? formatRelativeTime(now, new Date(job.nextRun)) : "—";
146
+ const last = job.lastRun ? formatRelativeTime(now, new Date(job.lastRun)) : "never";
147
+ return [
148
+ `▸ ${sanitizeLine(job.name)} — id ${sanitizeLine(job.id)}`,
149
+ ` goal: ${sanitizeLine(scheduleGoalOf(job))}`,
150
+ ` schedule: ${sanitizeLine(job.schedule)} (${sanitizeLine(job.scheduleType)}) · humanized: ${sanitizeLine(schedule)} · next: ${next}`,
151
+ ` agent: ${sanitizeLine(job.subagentType)} · runs: ${job.runCount} · last: ${last} · status: ${statusGlyph(job.lastStatus)}`,
152
+ ` spawned runs: ${job.spawnedRunIds?.length ? job.spawnedRunIds.map(sanitizeLine).join(", ") : "none"}`,
153
+ ];
154
+ }
@@ -42,6 +42,11 @@ export interface CrewEditorOptions {
42
42
  onSteer: (target: PanelTarget, message: string) => void;
43
43
  /** `x`: cancel a running agent's run, or dismiss a finished one. */
44
44
  onAct: (target: PanelTarget, finished: boolean) => void;
45
+ /** Idle `↓`+`enter` at the MAIN row opens the Agents & Jobs browser —
46
+ * the one-keystroke entry from the widget's status line (maintainer
47
+ * design 2026-09-14: counts row → ↓ → enter → browser popup). When
48
+ * absent, main-row enter stays a no-op (legacy behavior). */
49
+ onOpenBrowser?: () => void;
45
50
  }
46
51
 
47
52
  export class CrewInlineEditor extends CustomEditor {
@@ -86,6 +91,9 @@ export class CrewInlineEditor extends CustomEditor {
86
91
  const target = result.action.target;
87
92
  if (target) this.options.onOpenPane(target);
88
93
  else if (closePaneOnMain) this.options.onClosePane();
94
+ // Idle `enter` at the MAIN row = the browser entry (↓ + enter from
95
+ // the status line). Legacy no-op preserved when unwired.
96
+ else this.options.onOpenBrowser?.();
89
97
  return;
90
98
  }
91
99
  case "act": {
@@ -152,12 +160,15 @@ export class CrewInlineEditor extends CustomEditor {
152
160
  }
153
161
 
154
162
  // ── Idle: `↓` on an empty prompt enters the panel ──────────────────
163
+ // The browser extends the entry to the jobs-only state: `↓` works even
164
+ // with zero agent rows when the browser is wired (the state machine
165
+ // already lands at `main`; `enter` there opens the browser).
155
166
  if (getPanelSelection() === null) {
156
- if (matchesKey(data, "down") && this.getText() === "" && rows.length > 0) {
157
- // dispatch enters at the MAIN row; the widget renders that row
158
- // with its own ❯ marker so the very first press is visible
159
- // (pi-subtask's selectRow(rows, 0)).
160
- const result = dispatchPanelKey(this.panelKeys(data), rows, null);
167
+ if (matchesKey(data, "down") && this.getText() === "" && (rows.length > 0 || this.options.onOpenBrowser)) {
168
+ // SINGLE-LINE WIDGET: dispatch over EMPTY rows — the cursor
169
+ // lands on the ONE status line (rendered with its own ❯
170
+ // prefix by the widget), never on invisible agent rows.
171
+ const result = dispatchPanelKey(this.panelKeys(data), [], null);
161
172
  setPanelSelection(result.selection);
162
173
  return;
163
174
  }
@@ -166,7 +177,9 @@ export class CrewInlineEditor extends CustomEditor {
166
177
  }
167
178
 
168
179
  // ── Navigating: consume or fall through ────────────────────────────
169
- this.applyDispatch(data, rows, this.panelKeys(data), false);
180
+ // SINGLE-LINE WIDGET: navigating over [] keeps the cursor on the one
181
+ // status line (main); enter opens the browser, escape/up returns.
182
+ this.applyDispatch(data, [], this.panelKeys(data), false);
170
183
  }
171
184
 
172
185
  /**
@@ -237,6 +237,12 @@ export function installInlinePanel(pi: ExtensionAPI, ctx: ExtensionContext, uiCo
237
237
  onScrollPane: (delta) => livePane?.scrollBy(delta),
238
238
  onSteer: (target, message) => void steerAgent(ctx, target, message),
239
239
  onAct: (target, finished) => void actOnAgent(ctx, target, finished),
240
+ // ↓ + enter at the main row = the Agents & Jobs browser
241
+ // (lazy import: viewers.ts pulls the state-store chain and must stay
242
+ // off this module's startup path — AGENTS.md lazy boundary).
243
+ onOpenBrowser: () => {
244
+ void import("../../extension/registration/viewers.ts").then((m) => m.openAgentsJobsBrowser(ctx));
245
+ },
240
246
  });
241
247
  });
242
248
  editorInstalled = true;
@@ -17,6 +17,9 @@ import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
17
17
 
18
18
  export type PiKeyName = KeyId | string;
19
19
 
20
+ /** Re-exported so overlays can import the normalizer from one place. */
21
+ export { matchesKey };
22
+
20
23
  const COMMON_IDS: readonly KeyId[] = [
21
24
  "up",
22
25
  "down",
@@ -45,6 +45,7 @@ export const DASHBOARD_KEYS = {
45
45
  liveConversation: ["V"],
46
46
  reload: ["r"],
47
47
  progressToggle: ["p"],
48
+ browser: ["b"],
48
49
  },
49
50
  pane: {
50
51
  agents: ["1"],
@@ -54,6 +55,7 @@ export const DASHBOARD_KEYS = {
54
55
  health: ["5"],
55
56
  metrics: ["6"],
56
57
  plan: ["7"],
58
+ schedules: ["8"],
57
59
  },
58
60
  navigation: { up: ["k", "up"], down: ["j", "down"] },
59
61
  mailbox: {
@@ -66,6 +68,8 @@ export const DASHBOARD_KEYS = {
66
68
  },
67
69
  health: { recovery: ["R"], killStale: ["K"], diagnosticExport: ["D"] },
68
70
  plan: { approve: ["A"], deny: ["n"], diff: ["X"] },
71
+ /** Tier A (schedules pane, pane 8): pane-scoped action keys. */
72
+ schedules: { toggle: ["T"], runNow: ["N"], details: ["V"], delete: ["X"], refresh: ["R"] },
69
73
  notification: { dismissAll: ["H"] },
70
74
  } as const;
71
75
 
@@ -73,7 +77,7 @@ export const DASHBOARD_KEYS = {
73
77
  * Pane identifiers that can scope a binding. `undefined` means the binding
74
78
  * fires in every pane.
75
79
  */
76
- export type ActivePane = "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan";
80
+ export type ActivePane = "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" | "schedules";
77
81
 
78
82
  /**
79
83
  * A single keybinding: the keys that trigger it, the action it produces, and
@@ -103,6 +107,7 @@ export type DashboardKeyAction =
103
107
  | "live-conversation"
104
108
  | "reload"
105
109
  | "progressToggle"
110
+ | "browser"
106
111
  | "pane-agents"
107
112
  | "pane-progress"
108
113
  | "pane-mailbox"
@@ -110,7 +115,13 @@ export type DashboardKeyAction =
110
115
  | "pane-health"
111
116
  | "pane-metrics"
112
117
  | "pane-plan"
118
+ | "pane-schedules"
113
119
  | "plan-diff"
120
+ | "schedule-toggle"
121
+ | "schedule-run-now"
122
+ | "schedule-details"
123
+ | "schedule-delete"
124
+ | "schedule-refresh"
114
125
  | "up"
115
126
  | "down"
116
127
  | "mailbox-detail"
@@ -132,9 +143,12 @@ export type DashboardKeyAction =
132
143
  * 2. `mailbox-detail` (\r, \n) is pane-scoped to mailbox and MUST precede
133
144
  * `select` (which also binds \r, \n) so Enter opens the detail instead of
134
145
  * triggering select while in the mailbox pane.
135
- * 3. `health-*` and `plan-*` are pane-scoped (health / progress).
146
+ * 3. `health-*`, `schedule-*`, and `plan-*` are pane-scoped (health /
147
+ * schedules / progress+plan).
136
148
  * 4. `notifications-dismiss` (H) is global.
137
149
  * 5. `select`, then the root actions, pane switches, and navigation.
150
+ * 6. `schedule-*` V precedes the unscoped root `liveConversation` (V) so the
151
+ * schedules-scoped binding wins first-match-wins in pane 8.
138
152
  *
139
153
  * NOTE: mailbox action keys A/N/C/P/X (ack/nudge/compose/preview/ackAll) are
140
154
  * intentionally NOT dispatched for the mailbox pane by this table. They live
@@ -167,6 +181,39 @@ const DEFAULT_BINDINGS: readonly KeyBinding[] = [
167
181
  action: "health-diagnostic-export",
168
182
  pane: "health",
169
183
  },
184
+ // Tier A: schedules-pane action keys (pane 8). ALL pane-scoped so they
185
+ // never leak into other panes. Collision analysis (explorer-verified):
186
+ // T — unbound elsewhere; N — mailbox.nudge is overlay-owned, NOT dispatched
187
+ // here; X — plan.diff is plan-scoped, mailbox.ackAll overlay-owned; R —
188
+ // health.recovery is health-scoped; V — COLLIDES with the unscoped root
189
+ // liveConversation ["V"]: this entry MUST stay above it (first-match-wins
190
+ // pass-1 honors table order after paneScopeMatches), so V = details in
191
+ // the schedules pane and live-conversation everywhere else.
192
+ {
193
+ keys: DASHBOARD_KEYS.schedules.toggle,
194
+ action: "schedule-toggle",
195
+ pane: "schedules",
196
+ },
197
+ {
198
+ keys: DASHBOARD_KEYS.schedules.runNow,
199
+ action: "schedule-run-now",
200
+ pane: "schedules",
201
+ },
202
+ {
203
+ keys: DASHBOARD_KEYS.schedules.details,
204
+ action: "schedule-details",
205
+ pane: "schedules",
206
+ },
207
+ {
208
+ keys: DASHBOARD_KEYS.schedules.delete,
209
+ action: "schedule-delete",
210
+ pane: "schedules",
211
+ },
212
+ {
213
+ keys: DASHBOARD_KEYS.schedules.refresh,
214
+ action: "schedule-refresh",
215
+ pane: "schedules",
216
+ },
170
217
  {
171
218
  keys: DASHBOARD_KEYS.plan.approve,
172
219
  action: "plan-approve",
@@ -199,6 +246,11 @@ const DEFAULT_BINDINGS: readonly KeyBinding[] = [
199
246
  { keys: DASHBOARD_KEYS.root.liveConversation, action: "live-conversation" },
200
247
  { keys: DASHBOARD_KEYS.root.reload, action: "reload" },
201
248
  { keys: DASHBOARD_KEYS.root.progressToggle, action: "progressToggle" },
249
+ // Agents & Jobs browser (one-keypress overlay, mirrors live-conversation).
250
+ // Collision analysis: "b" is unbound everywhere else — root-unscoped is
251
+ // safe; inside the browser overlay itself "p" is free because overlays
252
+ // are mutually exclusive (see keybinding-map.ts header note).
253
+ { keys: DASHBOARD_KEYS.root.browser, action: "browser" },
202
254
  { keys: DASHBOARD_KEYS.pane.agents, action: "pane-agents" },
203
255
  { keys: DASHBOARD_KEYS.pane.progress, action: "pane-progress" },
204
256
  { keys: DASHBOARD_KEYS.pane.mailbox, action: "pane-mailbox" },
@@ -206,6 +258,7 @@ const DEFAULT_BINDINGS: readonly KeyBinding[] = [
206
258
  { keys: DASHBOARD_KEYS.pane.health, action: "pane-health" },
207
259
  { keys: DASHBOARD_KEYS.pane.metrics, action: "pane-metrics" },
208
260
  { keys: DASHBOARD_KEYS.pane.plan, action: "pane-plan" },
261
+ { keys: DASHBOARD_KEYS.pane.schedules, action: "pane-schedules" },
209
262
  { keys: DASHBOARD_KEYS.navigation.up, action: "up" },
210
263
  { keys: DASHBOARD_KEYS.navigation.down, action: "down" },
211
264
  ];
@@ -230,6 +283,7 @@ const KEY_RESERVED = new Set<string>([
230
283
  ...Object.values(DASHBOARD_KEYS.mailbox).flat(),
231
284
  ...Object.values(DASHBOARD_KEYS.health).flat(),
232
285
  ...Object.values(DASHBOARD_KEYS.plan).flat(),
286
+ ...Object.values(DASHBOARD_KEYS.schedules).flat(),
233
287
  ...Object.values(DASHBOARD_KEYS.notification).flat(),
234
288
  ]);
235
289
 
package/src/ui/mascot.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { matchesKey } from "@earendil-works/pi-tui";
1
2
  import { pad } from "../utils/visual.ts";
2
3
  import { DynamicCrewBorder } from "./dynamic-border.ts";
3
4
  import type { CrewTheme } from "./theme-adapter.ts";
@@ -423,7 +424,7 @@ export class AnimatedMascot {
423
424
  }
424
425
 
425
426
  handleInput(data: string): void {
426
- if (data === "q" || data === "\u001b" || data === "\u0003") {
427
+ if (matchesKey(data, "q") || matchesKey(data, "escape") || data === "\u0003") {
427
428
  this.close();
428
429
  }
429
430
  }
@@ -2,7 +2,7 @@ import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
2
2
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
3
3
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
4
4
  import { pad, truncate } from "../../utils/visual.ts";
5
- import { keyOf } from "../key-utils.ts";
5
+ import { keyOf, matchesKey } from "../key-utils.ts";
6
6
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
7
7
 
8
8
  export interface AgentPickerSelection {
@@ -46,7 +46,7 @@ export class AgentPickerOverlay {
46
46
  }
47
47
 
48
48
  handleInput(data: string): void {
49
- if (data === "\u001b" || data === "q") {
49
+ if (matchesKey(data, "escape") || data === "q") {
50
50
  this.done(undefined);
51
51
  return;
52
52
  }
@@ -58,7 +58,7 @@ export class AgentPickerOverlay {
58
58
  this.selected = Math.min(Math.max(0, this.agents.length - 1), this.selected + 1);
59
59
  return;
60
60
  }
61
- if (data === "\r" || data === "\n") {
61
+ if (matchesKey(data, "return")) {
62
62
  const agent = this.agents[this.selected];
63
63
  this.done(agent ? { agentId: agent.taskId } : undefined);
64
64
  }
@@ -1,4 +1,5 @@
1
1
  import { pad, truncate } from "../../utils/visual.ts";
2
+ import { matchesKey } from "../key-utils.ts";
2
3
  import { Box, Text } from "../layout-primitives.ts";
3
4
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
4
5
 
@@ -51,10 +52,10 @@ export class ConfirmOverlay {
51
52
  this.done(true);
52
53
  return;
53
54
  }
54
- if ((data === "\r" || data === "\n") && this.opts.defaultAction === "confirm") {
55
+ if (matchesKey(data, "return") && this.opts.defaultAction === "confirm") {
55
56
  this.done(true);
56
57
  return;
57
58
  }
58
- if (data === "n" || data === "N" || data === "\u001b" || data === "q" || data === "\r" || data === "\n") this.done(false);
59
+ if (data === "n" || data === "N" || matchesKey(data, "escape") || data === "q" || matchesKey(data, "return")) this.done(false);
59
60
  }
60
61
  }
@@ -1,5 +1,6 @@
1
1
  import type { MailboxDirection } from "../../state/coordination/mailbox.ts";
2
2
  import { pad, truncate } from "../../utils/visual.ts";
3
+ import { matchesKey } from "../key-utils.ts";
3
4
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
4
5
  import { ConfirmOverlay } from "./confirm-overlay.ts";
5
6
  import { renderComposePreview } from "./mailbox-compose-preview.ts";
@@ -150,7 +151,7 @@ export class MailboxComposeOverlay {
150
151
  this.confirm.handleInput(data);
151
152
  return;
152
153
  }
153
- if (data === "\u001b") {
154
+ if (matchesKey(data, "escape")) {
154
155
  this.cancel();
155
156
  return;
156
157
  }
@@ -167,11 +168,11 @@ export class MailboxComposeOverlay {
167
168
  else this.appendText(data);
168
169
  return;
169
170
  }
170
- if (data === "\b" || data === "\u007f") {
171
+ if (matchesKey(data, "backspace")) {
171
172
  this.backspace();
172
173
  return;
173
174
  }
174
- if (data === "\r" || data === "\n") {
175
+ if (matchesKey(data, "return")) {
175
176
  if (this.activeName() === "body" || this.fields.body.trim()) this.submit();
176
177
  else this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
177
178
  return;
@@ -1,7 +1,7 @@
1
1
  import { type MailboxMessage, readDeliveryState, readMailbox } from "../../state/coordination/mailbox.ts";
2
2
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
3
3
  import { pad, truncate } from "../../utils/visual.ts";
4
- import { keyOf } from "../key-utils.ts";
4
+ import { keyOf, matchesKey } from "../key-utils.ts";
5
5
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
6
6
 
7
7
  export type MailboxAction =
@@ -113,7 +113,7 @@ export class MailboxDetailOverlay {
113
113
  }
114
114
 
115
115
  handleInput(data: string): void {
116
- if (data === "\u001b" || data === "q") {
116
+ if (matchesKey(data, "escape") || data === "q") {
117
117
  this.done({ type: "close" });
118
118
  return;
119
119
  }
@@ -130,7 +130,7 @@ export class MailboxDetailOverlay {
130
130
  this.selected = Math.min(Math.max(0, this.current().length - 1), this.selected + 1);
131
131
  return;
132
132
  }
133
- if (data === "\r" || data === "\n") {
133
+ if (matchesKey(data, "return")) {
134
134
  this.expanded = !this.expanded;
135
135
  return;
136
136
  }