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.
- package/CHANGELOG.md +99 -0
- package/dist/index.mjs +34850 -34889
- package/package.json +170 -169
- package/scripts/watch-agent-transcript.mjs +120 -0
- package/src/extension/crew-input-router.ts +2 -0
- package/src/extension/crew-vibes/config.ts +1 -56
- package/src/extension/crew-vibes/figures.ts +5 -84
- package/src/extension/crew-vibes/font-detect.ts +1 -1
- package/src/extension/crew-vibes/index.ts +36 -328
- package/src/extension/crew-vibes/render.ts +2 -58
- package/src/extension/help.ts +1 -0
- package/src/extension/registration/command-registration.ts +5 -1
- package/src/extension/registration/commands/schedules.ts +212 -0
- package/src/extension/registration/commands/shared.ts +56 -1
- package/src/extension/registration/lifecycle-handlers.ts +16 -2
- package/src/extension/registration/schedule-toast-bridge.ts +109 -0
- package/src/extension/registration/viewers.ts +66 -1
- package/src/extension/team-tool/handle-schedule.ts +120 -1
- package/src/runtime/scheduling/scheduler.ts +120 -6
- package/src/runtime/settings-store.ts +28 -2
- package/src/state/coordination/locks.ts +17 -2
- package/src/ui/agents-jobs-browser.ts +785 -0
- package/src/ui/dashboard-panes/agents-pane.ts +7 -4
- package/src/ui/dashboard-panes/schedules-pane.ts +154 -0
- package/src/ui/inline-panel/crew-editor.ts +19 -6
- package/src/ui/inline-panel/index.ts +6 -0
- package/src/ui/key-utils.ts +3 -0
- package/src/ui/keybinding-map.ts +56 -2
- package/src/ui/mascot.ts +2 -1
- package/src/ui/overlays/agent-picker-overlay.ts +3 -3
- package/src/ui/overlays/confirm-overlay.ts +3 -2
- package/src/ui/overlays/mailbox-compose-overlay.ts +4 -3
- package/src/ui/overlays/mailbox-detail-overlay.ts +3 -3
- package/src/ui/run-dashboard.ts +257 -6
- package/src/ui/settings-overlay.ts +3 -3
- package/src/ui/transcript-viewer.ts +3 -2
- package/src/ui/widget/index.ts +61 -88
- package/src/ui/widget/widget-formatters.ts +7 -4
- package/src/ui/widget/widget-renderer.ts +134 -174
- package/src/ui/widget/widget-types.ts +0 -7
- package/src/utils/relative-time.ts +33 -0
- package/src/extension/crew-vibes/footer.ts +0 -313
- package/src/extension/crew-vibes/speed.ts +0 -286
- 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 =
|
|
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
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
const result = dispatchPanelKey(this.panelKeys(data),
|
|
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
|
-
|
|
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;
|
package/src/ui/key-utils.ts
CHANGED
|
@@ -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",
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
171
|
+
if (matchesKey(data, "backspace")) {
|
|
171
172
|
this.backspace();
|
|
172
173
|
return;
|
|
173
174
|
}
|
|
174
|
-
if (data
|
|
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
|
|
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
|
|
133
|
+
if (matchesKey(data, "return")) {
|
|
134
134
|
this.expanded = !this.expanded;
|
|
135
135
|
return;
|
|
136
136
|
}
|