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
@@ -1,10 +1,17 @@
1
1
  import * as fs from "node:fs";
2
+ // Tier A (schedules pane): job reads go through the extension-layer provider
3
+ // (G17 single source of truth) — this module NEVER touches the scheduler or
4
+ // the scheduledJobs settings key directly. handle-schedule.ts is a light
5
+ // module (crypto + scheduler + settings-store); the heavy team-tool.ts chain
6
+ // is NOT pulled in.
7
+ import { getScheduledJobs, getScheduledJobsHiddenCountView } from "../extension/team-tool/handle-schedule.ts";
2
8
  import type { MetricRegistry } from "../observability/metric-registry.ts";
3
9
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
4
10
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
5
11
  import { getLiveAgentContextPercent } from "../runtime/live-session/live-agent-manager.ts";
6
12
  import { isPlanApprovalPending } from "../runtime/plan-approval.ts";
7
13
  import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
14
+ import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
8
15
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
9
16
  import { aggregateUsage } from "../state/usage.ts";
10
17
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
@@ -19,9 +26,10 @@ import { renderMailboxPane } from "./dashboard-panes/mailbox-pane.ts";
19
26
  import { renderMetricsPane } from "./dashboard-panes/metrics-pane.ts";
20
27
  import { renderPlanPane } from "./dashboard-panes/plan-pane.ts";
21
28
  import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
29
+ import { renderScheduleDetails, renderSchedulesPane } from "./dashboard-panes/schedules-pane.ts";
22
30
  import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
23
31
  import { DynamicCrewBorder } from "./dynamic-border.ts";
24
- import { dashboardActionForKey } from "./keybinding-map.ts";
32
+ import { type DashboardKeyAction, dashboardActionForKey } from "./keybinding-map.ts";
25
33
  import { HelpOverlay } from "./overlays/help-overlay.ts";
26
34
  import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
27
35
  import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
@@ -54,6 +62,8 @@ export interface RunDashboardOptions {
54
62
  showTools?: boolean;
55
63
  snapshotCache?: RunSnapshotCache;
56
64
  runProvider?: () => TeamRunManifest[];
65
+ /** Pinned render clock (D6-T4) for pane renderers (agents pane durations). */
66
+ nowMs?: number;
57
67
  registry?: MetricRegistry;
58
68
  /**
59
69
  * Workspace/session ID for filtering runs and live agents. When provided,
@@ -61,6 +71,11 @@ export interface RunDashboardOptions {
61
71
  * workspaceId are shown. This ensures session isolation in the UI.
62
72
  */
63
73
  workspaceId?: string;
74
+ /** Tier A (schedules pane): injectable clock source (D6-T4). Render paths
75
+ * receive `now: Date` as a parameter and never read the wall clock
76
+ * themselves; tests inject a fixed Date. When omitted the clock is read
77
+ * ONCE per schedules-pane render pass, at this injection point only. */
78
+ now?: () => Date;
64
79
  /**
65
80
  * Poke the host TUI to repaint after a state change. Must be wired from
66
81
  * `commands.ts` (`() => requestRenderTarget(tui)`) so keypresses and event-bus
@@ -77,7 +92,7 @@ export interface RunDashboardOptions {
77
92
  * looking at. Resetting to "agents" on every `new RunDashboard(...)` was a
78
93
  * UX regression.
79
94
  */
80
- let lastActivePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" = "agents";
95
+ let lastActivePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" | "schedules" = "agents";
81
96
 
82
97
  export type RunDashboardAction =
83
98
  | "status"
@@ -90,6 +105,7 @@ export type RunDashboardAction =
90
105
  | "agent-output"
91
106
  | "agent-transcript"
92
107
  | "agent-live"
108
+ | "browser-open"
93
109
  | "mailbox"
94
110
  | "reload"
95
111
  | "mailbox-detail"
@@ -98,14 +114,57 @@ export type RunDashboardAction =
98
114
  | "health-diagnostic-export"
99
115
  | "plan-approve"
100
116
  | "plan-deny"
117
+ | "schedule-enable"
118
+ | "schedule-disable"
119
+ | "schedule-run-now"
120
+ | "schedule-remove"
101
121
  | "notifications-dismiss";
102
122
  export interface RunDashboardSelection {
103
123
  runId: string;
104
124
  action: RunDashboardAction;
125
+ /** Tier A: target scheduled-job id. Present only for schedule-* actions
126
+ * (the extension layer routes them to handle-schedule.ts subActions). */
127
+ jobId?: string;
128
+ }
129
+
130
+ /**
131
+ * Tier A: map dashboard schedule actions onto handle-schedule.ts subActions —
132
+ * the ONLY mutation channel (the UI never calls the scheduler directly).
133
+ * The extension layer consumes `done()` selections carrying schedule-*
134
+ * actions and dispatches them as
135
+ * `handleSchedule({ action: 'schedule', subAction, jobId })`.
136
+ * Kept next to the action union so the two can never drift.
137
+ */
138
+ export function scheduleDashboardActionToSubAction(action: RunDashboardAction): "enable" | "disable" | "run-now" | "remove" | undefined {
139
+ switch (action) {
140
+ case "schedule-enable":
141
+ return "enable";
142
+ case "schedule-disable":
143
+ return "disable";
144
+ case "schedule-run-now":
145
+ return "run-now";
146
+ case "schedule-remove":
147
+ return "remove";
148
+ default:
149
+ return undefined;
150
+ }
105
151
  }
106
152
 
107
153
  const TASK_READ_TTL_MS = 1000;
108
154
 
155
+ /** Tier A: short TTL for the scheduled-jobs provider cache. getScheduledJobs()
156
+ * is cheap when the scheduler singleton is registered (in-memory list) but
157
+ * falls back to a settings-store disk read otherwise — render ticks must not
158
+ * hit disk per frame (P0-6 discipline). Key `R` (schedule-refresh) drops the
159
+ * cache for an immediate re-read. */
160
+ const SCHEDULE_JOBS_TTL_MS = 1000;
161
+
162
+ /** Tier A: max schedules-pane body lines per render. Existing run-scoped
163
+ * panes slice to 8; the schedules table spends 2 lines per job (main + sub),
164
+ * so pane 8 gets a taller budget (6 jobs + header + key hint) — still well
165
+ * inside the stable targetHeight clamp (12–36). */
166
+ const SCHEDULES_PANE_MAX_LINES = 14;
167
+
109
168
  /** Max run rows rendered in the dashboard run-list block (L-1 window budget). */
110
169
  const RUN_LIST_MAX = 8;
111
170
 
@@ -435,9 +494,15 @@ export class RunDashboard implements DashboardComponent {
435
494
  private runScrollOffset = 0;
436
495
  private showFullProgress = false;
437
496
  private showHelp = false;
438
- private activePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" = lastActivePane;
497
+ private activePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" | "schedules" = lastActivePane;
439
498
  /** WP-7 (R7): pane-scoped revision-diff toggle (X). */
440
499
  private planDiff = false;
500
+ /** Tier A (schedules pane): job-cursor, details toggle, 2-step delete
501
+ * confirm-gate state, and the TTL'd provider cache (G17 reads only). */
502
+ private scheduleSelected = 0;
503
+ private scheduleDetails = false;
504
+ private scheduleDeleteArmed = false;
505
+ private scheduleJobsCache: { at: number; jobs: ScheduledJob[]; hiddenCount: number } | undefined;
441
506
  private runs: TeamRunManifest[];
442
507
  private readonly done: (selection: RunDashboardSelection | undefined) => void;
443
508
  private readonly theme: CrewTheme;
@@ -617,7 +682,8 @@ export class RunDashboard implements DashboardComponent {
617
682
  .join("|");
618
683
  const metricsSig =
619
684
  this.activePane === "metrics" ? `:metrics=${this.options.registry?.snapshot().length ?? 0}:${spinnerBucket()}` : "";
620
- const sig = `${this.selected}:${this.showHelp ? 1 : 0}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}`;
685
+ const schedulesSig = this.activePane === "schedules" ? `:sched=${this.schedulesSignatureFragment()}` : "";
686
+ const sig = `${this.selected}:${this.showHelp ? 1 : 0}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}${schedulesSig}`;
621
687
  this.cachedSignature = sig;
622
688
  this.cachedSignatureAt = now;
623
689
  return sig;
@@ -642,6 +708,78 @@ export class RunDashboard implements DashboardComponent {
642
708
  return selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache, resolve)?.runId;
643
709
  }
644
710
 
711
+ /** Tier A: scheduled jobs via the SINGLE source of truth (G17) — the
712
+ * extension-layer provider, TTL-cached so render ticks never hit disk
713
+ * per frame. `R` (schedule-refresh) drops the cache. The TTL timestamp
714
+ * reads the SAME injected clock as the pane render (D6-T4 — no direct
715
+ * Date.now() anywhere on the render path; review round 1 minor-1).
716
+ * P2-1: the hidden project-tier count rides the SAME cached provider
717
+ * read (jobs + count populate ONE cache entry) — no second uncached
718
+ * disk-read path exists on render ticks. */
719
+ private scheduleData(): { jobs: ScheduledJob[]; hiddenCount: number } {
720
+ const at = this.scheduleNow().getTime();
721
+ if (this.scheduleJobsCache && at - this.scheduleJobsCache.at < SCHEDULE_JOBS_TTL_MS) {
722
+ return this.scheduleJobsCache;
723
+ }
724
+ let jobs: ScheduledJob[] = [];
725
+ let hiddenCount = 0;
726
+ try {
727
+ jobs = getScheduledJobs();
728
+ } catch {
729
+ jobs = [];
730
+ }
731
+ try {
732
+ // Stash-first: in-memory while the scheduler singleton is registered;
733
+ // a tiers read only in the no-scheduler fallback (TTL-bounded here).
734
+ hiddenCount = getScheduledJobsHiddenCountView();
735
+ } catch {
736
+ hiddenCount = 0;
737
+ }
738
+ this.scheduleJobsCache = { at, jobs, hiddenCount };
739
+ if (this.scheduleSelected > jobs.length - 1) this.scheduleSelected = Math.max(0, jobs.length - 1);
740
+ return this.scheduleJobsCache;
741
+ }
742
+
743
+ private scheduleJobs(): ScheduledJob[] {
744
+ return this.scheduleData().jobs;
745
+ }
746
+
747
+ /** P2-1: hidden project-tier jobs count — same cache entry as the jobs
748
+ * read above (single provider read per TTL window). */
749
+ private scheduleHiddenCount(): number {
750
+ return this.scheduleData().hiddenCount;
751
+ }
752
+
753
+ /** Job under the pane-8 cursor (up/down move it); undefined when the list
754
+ * is empty — schedule actions are silent no-ops then (plan-approve
755
+ * precedent for a gated action). */
756
+ private selectedScheduleJob(): ScheduledJob | undefined {
757
+ const jobs = this.scheduleJobs();
758
+ return jobs[Math.min(this.scheduleSelected, jobs.length - 1)];
759
+ }
760
+
761
+ /** Clock for the schedules pane (D6-T4): the render path receives the
762
+ * resulting `now: Date` as a parameter and never reads the clock itself.
763
+ * The SINGLE clock read point for everything pane-8: render, signature
764
+ * pass, and the provider TTL timestamp (scheduleJobs) — so no second
765
+ * wall-clock read exists on the render path. */
766
+ private scheduleNow(): Date {
767
+ return this.options.now ? this.options.now() : new Date();
768
+ }
769
+
770
+ /** Signature fragment for the schedules pane so job data, cursor, details,
771
+ * confirm-gate state, and relative-time minute buckets invalidate the
772
+ * layout cache (mirrors metricsSig). P2-1: the hidden count is part of the
773
+ * fragment so a changed stash repaints the pane after the TTL. */
774
+ private schedulesSignatureFragment(): string {
775
+ const { jobs, hiddenCount } = this.scheduleData();
776
+ const minuteBucket = Math.floor(this.scheduleNow().getTime() / 60_000);
777
+ const jobSig = jobs
778
+ .map((job) => `${job.enabled ? 1 : 0}:${job.runCount}:${job.nextRun ?? ""}:${job.lastStatus ?? ""}:${job.lastRun ?? ""}`)
779
+ .join(",");
780
+ return `${jobs.length}:${jobSig}:${hiddenCount}:${this.scheduleSelected}:${this.scheduleDetails ? 1 : 0}:${this.scheduleDeleteArmed ? 1 : 0}:${minuteBucket}`;
781
+ }
782
+
645
783
  render(width: number): string[] {
646
784
  try {
647
785
  return this.renderUnsafe(width);
@@ -683,7 +821,7 @@ export class RunDashboard implements DashboardComponent {
683
821
  lines.push(
684
822
  border("╭", "╮"),
685
823
  row(
686
- `${fg("accent", "▐")} ${this.theme.bold("pi-crew")} · ${this.runs.length} runs ${fg("dim", "1-7 pane · ↑↓ · Enter · ? help · Esc")}`,
824
+ `${fg("accent", "▐")} ${this.theme.bold("pi-crew")} · ${this.runs.length} runs ${fg("dim", "1-8 pane · ↑↓ · Enter · ? help · Esc")}`,
687
825
  ),
688
826
  sep(),
689
827
  );
@@ -736,8 +874,10 @@ export class RunDashboard implements DashboardComponent {
736
874
  // `selectable` rows already derived from the single groupedRuns()
737
875
  // computation above instead of recomputing grouping a third time
738
876
  // (`selectedRunFromGrouped` is exactly `selectable[selected].run`).
877
+ // Tier A: the schedules pane REPLACES this run-scoped detail block —
878
+ // it renders as a root-level section below (non-run-scoped).
739
879
  const selectedRun = selectable[Math.min(this.selected, selectable.length - 1)]?.run;
740
- if (selectedRun) {
880
+ if (selectedRun && this.activePane !== "schedules") {
741
881
  const snap = snapshotOnce(selectedRun);
742
882
  const r = snap?.manifest ?? selectedRun;
743
883
  const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache, snapshotOnce);
@@ -828,6 +968,33 @@ export class RunDashboard implements DashboardComponent {
828
968
  if (footerFields.length) lines.push(row(fg("dim", footerFields.join(" · "))));
829
969
  }
830
970
  }
971
+
972
+ // Tier A (schedules pane, key 8) — NON-RUN-SCOPED section: renders
973
+ // with zero runs (below the empty-runs state) AND alongside the
974
+ // run list, replacing the selected-run detail block (guard above).
975
+ // Data via the G17 provider only; clock injected per D6-T4.
976
+ if (this.activePane === "schedules") {
977
+ const jobs = this.scheduleJobs();
978
+ const now = this.scheduleNow();
979
+ lines.push(sep());
980
+ lines.push(row(fg("dim", "── schedules ──")));
981
+ const paneLines = safeRenderPane("schedules", () =>
982
+ this.scheduleDetails && jobs.length > 0
983
+ ? renderScheduleDetails(jobs[Math.min(this.scheduleSelected, jobs.length - 1)], now)
984
+ : renderSchedulesPane(jobs, now, {
985
+ selectedIndex: this.scheduleSelected,
986
+ hiddenCount: this.scheduleHiddenCount(),
987
+ }),
988
+ );
989
+ for (const line of paneLines.filter((l) => l && l.trim() !== "").slice(0, SCHEDULES_PANE_MAX_LINES)) {
990
+ lines.push(colorizeStatusGlyphs(row(truncate(sanitizeLine(line), innerWidth - 2)), this.theme));
991
+ }
992
+ if (this.scheduleDeleteArmed) {
993
+ const victim = jobs[Math.min(this.scheduleSelected, jobs.length - 1)];
994
+ const name = victim ? truncate(sanitizeLine(victim.name), 24) : "?";
995
+ lines.push(row(fg("warning", `⚠ X again to DELETE '${name}' · any other key cancels`)));
996
+ }
997
+ }
831
998
  lines.push(border("╰", "╯"));
832
999
  }
833
1000
 
@@ -876,6 +1043,13 @@ export class RunDashboard implements DashboardComponent {
876
1043
  this.scheduleRender();
877
1044
  return;
878
1045
  }
1046
+ // Tier A (schedules pane): 2-step confirm-gate — once X has armed a
1047
+ // pending delete, ANY other key (bound or not) disarms it before that
1048
+ // key's own processing continues.
1049
+ if (this.activePane === "schedules" && this.scheduleDeleteArmed && action !== "schedule-delete") {
1050
+ this.scheduleDeleteArmed = false;
1051
+ this.invalidate();
1052
+ }
879
1053
  const selectedRunId = this.selectedRunId();
880
1054
  if (action === "close") {
881
1055
  this.done(undefined);
@@ -898,6 +1072,32 @@ export class RunDashboard implements DashboardComponent {
898
1072
  }
899
1073
  return;
900
1074
  }
1075
+ // Tier A: schedules-pane actions (T/N/V/X/R — pane-scoped bindings) and
1076
+ // job-cursor navigation. Deliberately a DEDICATED branch (plan-approve
1077
+ // precedent): with no job under the cursor the mutation keys are silent
1078
+ // no-ops (dashboard stays open) instead of closing it via done(undefined).
1079
+ // Mutations ONLY ever leave via done() selections carrying schedule-*
1080
+ // actions — the extension layer routes them through handle-schedule.ts
1081
+ // subActions (see scheduleDashboardActionToSubAction); the UI never
1082
+ // touches the scheduler directly.
1083
+ if (
1084
+ action === "schedule-toggle" ||
1085
+ action === "schedule-run-now" ||
1086
+ action === "schedule-delete" ||
1087
+ action === "schedule-details" ||
1088
+ action === "schedule-refresh"
1089
+ ) {
1090
+ this.handleSchedulePaneAction(action);
1091
+ return;
1092
+ }
1093
+ if (this.activePane === "schedules" && (action === "up" || action === "down")) {
1094
+ // Job cursor moves instead of the run-list selection while pane 8 owns input.
1095
+ const count = this.scheduleJobs().length;
1096
+ this.scheduleSelected =
1097
+ action === "up" ? Math.max(0, this.scheduleSelected - 1) : Math.min(Math.max(0, count - 1), this.scheduleSelected + 1);
1098
+ this.invalidate();
1099
+ return;
1100
+ }
901
1101
  if (
902
1102
  action === "summary" ||
903
1103
  action === "artifacts" ||
@@ -930,6 +1130,15 @@ export class RunDashboard implements DashboardComponent {
930
1130
  this.done(selectedRunId ? { runId: selectedRunId, action: "agent-live" } : undefined);
931
1131
  return;
932
1132
  }
1133
+ // Agents & Jobs browser (feat/agents-browser): one-keypress overlay,
1134
+ // mirrors the live-conversation branch. Unlike it, a missing run
1135
+ // selection does NOT dead-end — the browser lists live agents and
1136
+ // scheduled jobs independent of any single run, so runId "" keeps it
1137
+ // reachable with zero runs (the reload-action precedent).
1138
+ if (action === "browser") {
1139
+ this.done(selectedRunId ? { runId: selectedRunId, action: "browser-open" } : { runId: "", action: "browser-open" });
1140
+ return;
1141
+ }
933
1142
  if (action === "progressToggle") {
934
1143
  this.showFullProgress = !this.showFullProgress;
935
1144
  this.invalidate();
@@ -942,6 +1151,7 @@ export class RunDashboard implements DashboardComponent {
942
1151
  else if (action === "pane-health") this.activePane = "health";
943
1152
  else if (action === "pane-metrics") this.activePane = "metrics";
944
1153
  else if (action === "pane-plan") this.activePane = "plan";
1154
+ else if (action === "pane-schedules") this.activePane = "schedules";
945
1155
  else if (action === "plan-diff") {
946
1156
  this.planDiff = !this.planDiff;
947
1157
  this.invalidate();
@@ -956,4 +1166,45 @@ export class RunDashboard implements DashboardComponent {
956
1166
  this.scheduleRender();
957
1167
  }
958
1168
  }
1169
+
1170
+ /** Tier A: dispatch a pane-8 action. Local state toggles (V details, R
1171
+ * refresh) mutate in place; mutations (T toggle, N run-now, X delete)
1172
+ * leave the component ONLY as done() selections carrying schedule-*
1173
+ * actions + jobId for the extension layer to route through
1174
+ * handle-schedule.ts subActions — the UI layer never mutates jobs itself. */
1175
+ private handleSchedulePaneAction(action: DashboardKeyAction): void {
1176
+ // Pane-local state toggles follow the progressToggle/plan-diff precedent:
1177
+ // invalidate() so the next render (sync in tests, host-tick in the TUI)
1178
+ // recomputes immediately.
1179
+ if (action === "schedule-details") {
1180
+ this.scheduleDetails = !this.scheduleDetails;
1181
+ this.invalidate();
1182
+ return;
1183
+ }
1184
+ if (action === "schedule-refresh") {
1185
+ this.scheduleJobsCache = undefined;
1186
+ this.invalidate();
1187
+ return;
1188
+ }
1189
+ const job = this.selectedScheduleJob();
1190
+ if (!job) return; // silent no-op — plan-approve precedent for gated actions
1191
+ if (action === "schedule-delete") {
1192
+ // 2-step destructive confirm-gate: first X arms (render shows the
1193
+ // warning line; any other key disarms — see handleInput), second X
1194
+ // confirms and emits the remove selection.
1195
+ if (this.scheduleDeleteArmed) {
1196
+ this.scheduleDeleteArmed = false;
1197
+ this.done({ runId: "", action: "schedule-remove", jobId: job.id });
1198
+ } else {
1199
+ this.scheduleDeleteArmed = true;
1200
+ this.invalidate();
1201
+ }
1202
+ return;
1203
+ }
1204
+ this.done({
1205
+ runId: "",
1206
+ action: action === "schedule-toggle" ? (job.enabled ? "schedule-disable" : "schedule-enable") : "schedule-run-now",
1207
+ jobId: job.id,
1208
+ });
1209
+ }
959
1210
  }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { truncateToWidth, visibleWidth } from "../utils/visual.ts";
8
8
  import { DynamicCrewBorder } from "./dynamic-border.ts";
9
- import { keyOf } from "./key-utils.ts";
9
+ import { keyOf, matchesKey } from "./key-utils.ts";
10
10
  import type { CrewTheme } from "./theme-adapter.ts";
11
11
  import { discoverPiThemes, getActivePiTheme } from "./theme-discovery.ts";
12
12
 
@@ -608,7 +608,7 @@ class TextinputSubmenu {
608
608
  return;
609
609
  }
610
610
  // Backspace
611
- if (data === "\x7f" || data === "\b" || k === "backspace") {
611
+ if (matchesKey(data, "backspace") || k === "backspace") {
612
612
  this.buffer = this.buffer.slice(0, -1);
613
613
  return;
614
614
  }
@@ -761,7 +761,7 @@ class AgentOverridesSubmenu {
761
761
  this.editField = null;
762
762
  return;
763
763
  }
764
- if (data === "\x7f" || data === "\b" || k === "backspace") {
764
+ if (matchesKey(data, "backspace") || k === "backspace") {
765
765
  this.editBuffer = this.editBuffer.slice(0, -1);
766
766
  return;
767
767
  }
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import { matchesKey } from "@earendil-works/pi-tui";
2
3
  import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
3
4
  import type { TeamRunManifest } from "../state/types.ts";
4
5
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
@@ -340,7 +341,7 @@ export class DurableTextViewer implements Component {
340
341
  }
341
342
 
342
343
  handleInput(data: string): void {
343
- if (data === "q" || data === "\u001b") {
344
+ if (matchesKey(data, "q") || matchesKey(data, "escape")) {
344
345
  this.done(undefined);
345
346
  return;
346
347
  }
@@ -441,7 +442,7 @@ export class DurableTranscriptViewer implements Component {
441
442
  }
442
443
 
443
444
  handleInput(data: string): void {
444
- if (data === "q" || data === "\u001b") {
445
+ if (matchesKey(data, "q") || matchesKey(data, "escape")) {
445
446
  this.done(undefined);
446
447
  return;
447
448
  }