pi-crew 0.1.9 → 0.1.11
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 +17 -9
- package/docs/architecture.md +2 -2
- package/docs/usage.md +19 -9
- package/package.json +1 -1
- package/schema.json +10 -0
- package/src/config/config.ts +25 -0
- package/src/extension/help.ts +1 -1
- package/src/extension/register.ts +16 -5
- package/src/extension/team-tool.ts +8 -2
- package/src/runtime/process-status.ts +18 -0
- package/src/runtime/runtime-resolver.ts +7 -8
- package/src/ui/crew-widget.ts +99 -29
- package/src/ui/powerbar-publisher.ts +55 -0
- package/src/ui/run-dashboard.ts +39 -17
- package/src/ui/transcript-viewer.ts +219 -204
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Current highlights:
|
|
|
32
32
|
- detached async/background runner
|
|
33
33
|
- stale async PID detection
|
|
34
34
|
- active run summary and async completion notifications in Pi sessions
|
|
35
|
-
-
|
|
35
|
+
- real child Pi worker execution by default, with explicit scaffold/dry-run opt-out
|
|
36
36
|
- child Pi JSON output parsing for final text, usage, and event counts
|
|
37
37
|
- retryable model fallback attempts per task
|
|
38
38
|
- aggregate usage totals in status/summary
|
|
@@ -89,19 +89,21 @@ npm run ci
|
|
|
89
89
|
|
|
90
90
|
## Runtime safety model
|
|
91
91
|
|
|
92
|
-
By default, `run`
|
|
92
|
+
By default, `run` launches each crew task as a separate child Pi process. This matches the subagent model from `pi-subagents`: the parent session orchestrates while worker sessions execute independently and stream durable output back to run state.
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
Use scaffold/dry-run mode only when you explicitly want prompts/artifacts without launching workers:
|
|
95
95
|
|
|
96
|
-
```
|
|
97
|
-
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"runtime": { "mode": "scaffold" }
|
|
99
|
+
}
|
|
98
100
|
```
|
|
99
101
|
|
|
100
|
-
or
|
|
102
|
+
or disable workers globally:
|
|
101
103
|
|
|
102
104
|
```json
|
|
103
105
|
{
|
|
104
|
-
"executeWorkers":
|
|
106
|
+
"executeWorkers": false
|
|
105
107
|
}
|
|
106
108
|
```
|
|
107
109
|
|
|
@@ -143,7 +145,7 @@ Supported config:
|
|
|
143
145
|
```json
|
|
144
146
|
{
|
|
145
147
|
"asyncByDefault": false,
|
|
146
|
-
"executeWorkers":
|
|
148
|
+
"executeWorkers": true,
|
|
147
149
|
"notifierIntervalMs": 5000,
|
|
148
150
|
"requireCleanWorktreeLeader": true,
|
|
149
151
|
"autonomous": {
|
|
@@ -163,6 +165,11 @@ Supported config:
|
|
|
163
165
|
"maxRunMinutes": 60,
|
|
164
166
|
"maxRetriesPerTask": 1,
|
|
165
167
|
"heartbeatStaleMs": 60000
|
|
168
|
+
},
|
|
169
|
+
"ui": {
|
|
170
|
+
"widgetPlacement": "aboveEditor",
|
|
171
|
+
"widgetMaxLines": 8,
|
|
172
|
+
"powerbar": true
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
```
|
|
@@ -619,7 +626,8 @@ Optional child Pi smoke check is explicit only:
|
|
|
619
626
|
## Environment variables
|
|
620
627
|
|
|
621
628
|
```text
|
|
622
|
-
|
|
629
|
+
PI_CREW_EXECUTE_WORKERS=0 disable child workers and use scaffold/dry-run mode
|
|
630
|
+
PI_TEAMS_EXECUTE_WORKERS=0 legacy disable flag
|
|
623
631
|
PI_TEAMS_MOCK_CHILD_PI=success test/mock child worker success
|
|
624
632
|
PI_TEAMS_MOCK_CHILD_PI=json-success
|
|
625
633
|
PI_TEAMS_MOCK_CHILD_PI=retryable-failure
|
package/docs/architecture.md
CHANGED
|
@@ -32,8 +32,8 @@ Implemented now:
|
|
|
32
32
|
- durable run manifests and task files
|
|
33
33
|
- workflow validation
|
|
34
34
|
- foreground workflow scheduler
|
|
35
|
-
-
|
|
36
|
-
-
|
|
35
|
+
- child Pi task execution by default
|
|
36
|
+
- explicit scaffold/dry-run task execution via `runtime.mode=scaffold` or `executeWorkers=false`
|
|
37
37
|
- safety-first create/update/delete for agents, teams, and workflows
|
|
38
38
|
- backups for update/delete mutations
|
|
39
39
|
- dry-run support for management mutations
|
package/docs/usage.md
CHANGED
|
@@ -19,7 +19,7 @@ Supported fields:
|
|
|
19
19
|
```json
|
|
20
20
|
{
|
|
21
21
|
"asyncByDefault": false,
|
|
22
|
-
"executeWorkers":
|
|
22
|
+
"executeWorkers": true,
|
|
23
23
|
"notifierIntervalMs": 5000,
|
|
24
24
|
"requireCleanWorktreeLeader": true,
|
|
25
25
|
"autonomous": {
|
|
@@ -28,6 +28,11 @@ Supported fields:
|
|
|
28
28
|
"injectPolicy": true,
|
|
29
29
|
"preferAsyncForLongTasks": false,
|
|
30
30
|
"allowWorktreeSuggestion": true
|
|
31
|
+
},
|
|
32
|
+
"ui": {
|
|
33
|
+
"widgetPlacement": "aboveEditor",
|
|
34
|
+
"widgetMaxLines": 8,
|
|
35
|
+
"powerbar": true
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
```
|
|
@@ -47,9 +52,9 @@ Then open Pi and run:
|
|
|
47
52
|
/team-autonomy status
|
|
48
53
|
```
|
|
49
54
|
|
|
50
|
-
##
|
|
55
|
+
## Default run: real worker execution
|
|
51
56
|
|
|
52
|
-
By default, `pi-crew`
|
|
57
|
+
By default, `pi-crew` launches each task as a separate child Pi worker process. The parent Pi session orchestrates; workers execute independently and stream output to durable run state.
|
|
53
58
|
|
|
54
59
|
```json
|
|
55
60
|
{
|
|
@@ -59,16 +64,21 @@ By default, `pi-crew` does not launch child workers. It creates a durable run, p
|
|
|
59
64
|
}
|
|
60
65
|
```
|
|
61
66
|
|
|
62
|
-
##
|
|
67
|
+
## Scaffold / dry run
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
Use scaffold mode only when you want durable prompts/artifacts without launching child workers.
|
|
65
70
|
|
|
66
|
-
```
|
|
67
|
-
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"action": "run",
|
|
74
|
+
"team": "default",
|
|
75
|
+
"goal": "Plan only",
|
|
76
|
+
"config": {
|
|
77
|
+
"runtime": { "mode": "scaffold" }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
68
80
|
```
|
|
69
81
|
|
|
70
|
-
Then run normally. Each task can spawn a child Pi worker.
|
|
71
|
-
|
|
72
82
|
## Async run
|
|
73
83
|
|
|
74
84
|
```json
|
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -78,6 +78,16 @@
|
|
|
78
78
|
"enabled": { "type": "boolean" },
|
|
79
79
|
"needsAttentionAfterMs": { "type": "integer", "minimum": 1 }
|
|
80
80
|
}
|
|
81
|
+
},
|
|
82
|
+
"ui": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"additionalProperties": false,
|
|
85
|
+
"description": "Pi UI settings for the crew widget, dashboard, and optional powerbar segments.",
|
|
86
|
+
"properties": {
|
|
87
|
+
"widgetPlacement": { "type": "string", "enum": ["aboveEditor", "belowEditor"] },
|
|
88
|
+
"widgetMaxLines": { "type": "integer", "minimum": 1, "maximum": 50 },
|
|
89
|
+
"powerbar": { "type": "boolean" }
|
|
90
|
+
}
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
93
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -47,6 +47,12 @@ export interface CrewWorktreeConfig {
|
|
|
47
47
|
linkNodeModules?: boolean;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export interface CrewUiConfig {
|
|
51
|
+
widgetPlacement?: "aboveEditor" | "belowEditor";
|
|
52
|
+
widgetMaxLines?: number;
|
|
53
|
+
powerbar?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
50
56
|
export interface AgentOverrideConfig {
|
|
51
57
|
disabled?: boolean;
|
|
52
58
|
model?: string | false;
|
|
@@ -71,6 +77,7 @@ export interface PiTeamsConfig {
|
|
|
71
77
|
control?: CrewControlConfig;
|
|
72
78
|
worktree?: CrewWorktreeConfig;
|
|
73
79
|
agents?: CrewAgentsConfig;
|
|
80
|
+
ui?: CrewUiConfig;
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
export interface LoadedPiTeamsConfig {
|
|
@@ -136,6 +143,12 @@ function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfi
|
|
|
136
143
|
...withoutUndefined((override.worktree ?? {}) as Record<string, unknown>),
|
|
137
144
|
};
|
|
138
145
|
}
|
|
146
|
+
if (base.ui || override.ui) {
|
|
147
|
+
merged.ui = {
|
|
148
|
+
...(base.ui ?? {}),
|
|
149
|
+
...withoutUndefined((override.ui ?? {}) as Record<string, unknown>),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
139
152
|
if (base.agents || override.agents) {
|
|
140
153
|
merged.agents = {
|
|
141
154
|
...(base.agents ?? {}),
|
|
@@ -289,6 +302,17 @@ function parseAgentOverride(value: unknown): AgentOverrideConfig | undefined {
|
|
|
289
302
|
return Object.values(override).some((entry) => entry !== undefined) ? override : undefined;
|
|
290
303
|
}
|
|
291
304
|
|
|
305
|
+
function parseUiConfig(value: unknown): CrewUiConfig | undefined {
|
|
306
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
307
|
+
const obj = value as Record<string, unknown>;
|
|
308
|
+
const ui: CrewUiConfig = {
|
|
309
|
+
widgetPlacement: obj.widgetPlacement === "aboveEditor" || obj.widgetPlacement === "belowEditor" ? obj.widgetPlacement : undefined,
|
|
310
|
+
widgetMaxLines: parsePositiveInteger(obj.widgetMaxLines, 50),
|
|
311
|
+
powerbar: typeof obj.powerbar === "boolean" ? obj.powerbar : undefined,
|
|
312
|
+
};
|
|
313
|
+
return Object.values(ui).some((entry) => entry !== undefined) ? ui : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
292
316
|
function parseAgentsConfig(value: unknown): CrewAgentsConfig | undefined {
|
|
293
317
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
294
318
|
const obj = value as Record<string, unknown>;
|
|
@@ -320,6 +344,7 @@ function parseConfig(raw: unknown): PiTeamsConfig {
|
|
|
320
344
|
control: parseControlConfig(obj.control),
|
|
321
345
|
worktree: parseWorktreeConfig(obj.worktree),
|
|
322
346
|
agents: parseAgentsConfig(obj.agents),
|
|
347
|
+
ui: parseUiConfig(obj.ui),
|
|
323
348
|
};
|
|
324
349
|
}
|
|
325
350
|
|
package/src/extension/help.ts
CHANGED
|
@@ -40,6 +40,6 @@ export function piTeamsHelp(): string {
|
|
|
40
40
|
"- /team-validate",
|
|
41
41
|
"- /team-help",
|
|
42
42
|
"",
|
|
43
|
-
"Real child workers are
|
|
43
|
+
"Real child workers are enabled by default. Use runtime.mode=scaffold or executeWorkers=false only for dry runs.",
|
|
44
44
|
].join("\n");
|
|
45
45
|
}
|
|
@@ -11,6 +11,7 @@ import { listRuns } from "./run-index.ts";
|
|
|
11
11
|
import { RunDashboard, type RunDashboardSelection } from "../ui/run-dashboard.ts";
|
|
12
12
|
import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.ts";
|
|
13
13
|
import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
|
|
14
|
+
import { clearPiCrewPowerbar, registerPiCrewPowerbarSegments, updatePiCrewPowerbar } from "../ui/powerbar-publisher.ts";
|
|
14
15
|
import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
|
|
15
16
|
import { loadRunManifestById } from "../state/state-store.ts";
|
|
16
17
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
@@ -109,14 +110,22 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
109
110
|
currentCtx = ctx;
|
|
110
111
|
notifyActiveRuns(ctx);
|
|
111
112
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
113
|
+
registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
|
|
112
114
|
startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? 5000);
|
|
113
|
-
updateCrewWidget(ctx, widgetState);
|
|
114
|
-
|
|
115
|
+
updateCrewWidget(ctx, widgetState, loadedConfig.config.ui);
|
|
116
|
+
updatePiCrewPowerbar(pi.events, ctx.cwd, loadedConfig.config.ui);
|
|
117
|
+
widgetState.interval = setInterval(() => {
|
|
118
|
+
if (!currentCtx) return;
|
|
119
|
+
const config = loadConfig(currentCtx.cwd).config.ui;
|
|
120
|
+
updateCrewWidget(currentCtx, widgetState, config);
|
|
121
|
+
updatePiCrewPowerbar(pi.events, currentCtx.cwd, config);
|
|
122
|
+
}, 1000);
|
|
115
123
|
widgetState.interval.unref?.();
|
|
116
124
|
});
|
|
117
125
|
pi.on("session_shutdown", () => {
|
|
118
126
|
stopAsyncRunNotifier(notifierState);
|
|
119
|
-
stopCrewWidget(currentCtx, widgetState);
|
|
127
|
+
stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
|
|
128
|
+
clearPiCrewPowerbar(pi.events);
|
|
120
129
|
currentCtx = undefined;
|
|
121
130
|
rpcHandle?.unsubscribe();
|
|
122
131
|
rpcHandle = undefined;
|
|
@@ -130,7 +139,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
130
139
|
parameters: TeamToolParams as never,
|
|
131
140
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
132
141
|
const output = await handleTeamTool(params as TeamToolParamsValue, ctx);
|
|
133
|
-
|
|
142
|
+
const config = loadConfig(ctx.cwd).config.ui;
|
|
143
|
+
updateCrewWidget(ctx, widgetState, config);
|
|
144
|
+
updatePiCrewPowerbar(pi.events, ctx.cwd, config);
|
|
134
145
|
return output;
|
|
135
146
|
},
|
|
136
147
|
};
|
|
@@ -323,7 +334,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
323
334
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
324
335
|
for (;;) {
|
|
325
336
|
const runs = listRuns(ctx.cwd).slice(0, 50);
|
|
326
|
-
const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui,
|
|
337
|
+
const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme), {
|
|
327
338
|
overlay: true,
|
|
328
339
|
overlayOptions: { width: "90%", maxHeight: "80%", anchor: "center" },
|
|
329
340
|
});
|
|
@@ -318,10 +318,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
318
318
|
"",
|
|
319
319
|
`Runtime: ${runtime.kind}${runtime.fallback ? ` (fallback from ${runtime.requestedMode})` : ""}${runtime.reason ? ` - ${runtime.reason}` : ""}`,
|
|
320
320
|
runtime.kind === "child-process"
|
|
321
|
-
? "Child Pi worker execution
|
|
321
|
+
? "Child Pi worker execution is enabled by default; each task is launched as a separate Pi process. Set runtime.mode=scaffold or executeWorkers=false only for dry runs."
|
|
322
322
|
: runtime.kind === "live-session"
|
|
323
323
|
? "Experimental live-session worker execution was enabled."
|
|
324
|
-
: "Safe scaffold mode: child Pi workers were not launched
|
|
324
|
+
: "Safe scaffold mode: child Pi workers were not launched because runtime.mode=scaffold or executeWorkers=false was configured.",
|
|
325
325
|
].join("\n");
|
|
326
326
|
return result(text, { action: "run", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
|
|
327
327
|
}
|
|
@@ -508,6 +508,7 @@ function configPatchFromConfig(config: unknown): PiTeamsConfig {
|
|
|
508
508
|
const runtime = configRecord(cfg.runtime);
|
|
509
509
|
const limits = configRecord(cfg.limits);
|
|
510
510
|
const worktree = configRecord(cfg.worktree);
|
|
511
|
+
const ui = configRecord(cfg.ui);
|
|
511
512
|
return {
|
|
512
513
|
asyncByDefault: typeof cfg.asyncByDefault === "boolean" ? cfg.asyncByDefault : undefined,
|
|
513
514
|
executeWorkers: typeof cfg.executeWorkers === "boolean" ? cfg.executeWorkers : undefined,
|
|
@@ -542,6 +543,11 @@ function configPatchFromConfig(config: unknown): PiTeamsConfig {
|
|
|
542
543
|
enabled: typeof control.enabled === "boolean" ? control.enabled : undefined,
|
|
543
544
|
needsAttentionAfterMs: typeof control.needsAttentionAfterMs === "number" && Number.isInteger(control.needsAttentionAfterMs) && control.needsAttentionAfterMs > 0 ? control.needsAttentionAfterMs : undefined,
|
|
544
545
|
} : undefined,
|
|
546
|
+
ui: Object.keys(ui).length > 0 ? {
|
|
547
|
+
widgetPlacement: ui.widgetPlacement === "aboveEditor" || ui.widgetPlacement === "belowEditor" ? ui.widgetPlacement : undefined,
|
|
548
|
+
widgetMaxLines: typeof ui.widgetMaxLines === "number" && Number.isInteger(ui.widgetMaxLines) && ui.widgetMaxLines > 0 ? ui.widgetMaxLines : undefined,
|
|
549
|
+
powerbar: typeof ui.powerbar === "boolean" ? ui.powerbar : undefined,
|
|
550
|
+
} : undefined,
|
|
545
551
|
};
|
|
546
552
|
}
|
|
547
553
|
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
|
|
2
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
3
|
+
|
|
1
4
|
export interface ProcessLiveness {
|
|
2
5
|
pid?: number;
|
|
3
6
|
alive: boolean;
|
|
4
7
|
detail: string;
|
|
5
8
|
}
|
|
6
9
|
|
|
10
|
+
const ORPHANED_ACTIVE_RUN_MS = 10 * 60 * 1000;
|
|
11
|
+
|
|
7
12
|
export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
|
|
8
13
|
if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
|
|
9
14
|
return { pid, alive: false, detail: "no pid recorded" };
|
|
@@ -23,3 +28,16 @@ export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
|
|
|
23
28
|
export function isActiveRunStatus(status: string): boolean {
|
|
24
29
|
return status === "queued" || status === "planning" || status === "running";
|
|
25
30
|
}
|
|
31
|
+
|
|
32
|
+
export function isLikelyOrphanedActiveRun(run: TeamRunManifest, agents: CrewAgentRecord[] = [], now = Date.now(), staleMs = ORPHANED_ACTIVE_RUN_MS): boolean {
|
|
33
|
+
if (!isActiveRunStatus(run.status)) return false;
|
|
34
|
+
if (run.async?.pid !== undefined) return false;
|
|
35
|
+
const updatedAt = new Date(run.updatedAt).getTime();
|
|
36
|
+
if (!Number.isFinite(updatedAt) || now - updatedAt < staleMs) return false;
|
|
37
|
+
if (agents.length === 0) return run.summary === "Creating workflow prompts and placeholder results.";
|
|
38
|
+
return agents.every((agent) => agent.status === "queued" && !agent.completedAt && !agent.progress);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord[] = [], now = Date.now()): boolean {
|
|
42
|
+
return isActiveRunStatus(run.status) && !isLikelyOrphanedActiveRun(run, agents, now);
|
|
43
|
+
}
|
|
@@ -48,22 +48,21 @@ export async function isLiveSessionRuntimeAvailable(timeoutMs = 1500, env: NodeJ
|
|
|
48
48
|
|
|
49
49
|
export async function resolveCrewRuntime(config: PiTeamsConfig, env: NodeJS.ProcessEnv = process.env): Promise<CrewRuntimeCapabilities> {
|
|
50
50
|
const requestedMode = config.runtime?.mode ?? "auto";
|
|
51
|
-
const
|
|
51
|
+
const workersDisabled = config.executeWorkers === false || env.PI_CREW_EXECUTE_WORKERS === "0" || env.PI_TEAMS_EXECUTE_WORKERS === "0";
|
|
52
52
|
if (requestedMode === "scaffold") return scaffoldCaps(requestedMode);
|
|
53
|
-
if (
|
|
53
|
+
if (workersDisabled) return scaffoldCaps(requestedMode, "Child worker execution disabled by config/env. Set runtime.mode=scaffold or executeWorkers=false only for dry runs.");
|
|
54
|
+
if (requestedMode === "child-process") return childCaps(requestedMode);
|
|
54
55
|
if (requestedMode === "live-session" || (requestedMode === "auto" && config.runtime?.preferLiveSession === true)) {
|
|
55
56
|
const live = await isLiveSessionRuntimeAvailable(1500, env);
|
|
56
57
|
if (live.available) return liveCaps(requestedMode);
|
|
57
58
|
if (requestedMode === "live-session" && config.runtime?.allowChildProcessFallback === false) return { ...scaffoldCaps(requestedMode), available: false, reason: live.reason };
|
|
58
|
-
|
|
59
|
-
return { ...scaffoldCaps(requestedMode), fallback: "scaffold", reason: live.reason };
|
|
59
|
+
return { ...childCaps(requestedMode), fallback: "child-process", reason: live.reason };
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
return scaffoldCaps(requestedMode);
|
|
61
|
+
return childCaps(requestedMode);
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
function scaffoldCaps(requestedMode: CrewRuntimeMode): CrewRuntimeCapabilities {
|
|
66
|
-
return { kind: "scaffold", requestedMode, available: true, steer: false, resume: false, liveToolActivity: false, transcript: false };
|
|
64
|
+
function scaffoldCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {
|
|
65
|
+
return { kind: "scaffold", requestedMode, available: true, steer: false, resume: false, liveToolActivity: false, transcript: false, ...(reason ? { reason } : {}) };
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
function childCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {
|
package/src/ui/crew-widget.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { CrewUiConfig } from "../config/config.ts";
|
|
2
3
|
import { listRuns } from "../extension/run-index.ts";
|
|
3
|
-
import { isActiveRunStatus } from "../runtime/process-status.ts";
|
|
4
4
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
5
5
|
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
6
|
+
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
6
7
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
7
8
|
|
|
8
9
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -15,12 +16,48 @@ const TOOL_LABELS: Record<string, string> = {
|
|
|
15
16
|
find: "finding files",
|
|
16
17
|
ls: "listing",
|
|
17
18
|
};
|
|
19
|
+
const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
20
|
+
|
|
21
|
+
type ThemeLike = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
22
|
+
type WidgetComponent = { render(width: number): string[]; invalidate(): void };
|
|
18
23
|
|
|
19
24
|
export interface CrewWidgetState {
|
|
20
25
|
frame: number;
|
|
21
26
|
interval?: ReturnType<typeof setInterval>;
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
interface WidgetRun {
|
|
30
|
+
run: TeamRunManifest;
|
|
31
|
+
agents: CrewAgentRecord[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function visibleWidth(value: string): number {
|
|
35
|
+
return value.replace(ANSI_PATTERN, "").length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function truncate(value: string, width: number): string {
|
|
39
|
+
if (width <= 0) return "";
|
|
40
|
+
if (visibleWidth(value) <= width) return value;
|
|
41
|
+
if (width <= 1) return "…";
|
|
42
|
+
let output = "";
|
|
43
|
+
let visible = 0;
|
|
44
|
+
for (let index = 0; index < value.length;) {
|
|
45
|
+
const slice = value.slice(index);
|
|
46
|
+
const ansi = slice.match(/^\u001b\[[0-?]*[ -/]*[@-~]/);
|
|
47
|
+
if (ansi?.[0]) {
|
|
48
|
+
output += ansi[0];
|
|
49
|
+
index += ansi[0].length;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const char = value[index]!;
|
|
53
|
+
if (visible >= width - 1) break;
|
|
54
|
+
output += char;
|
|
55
|
+
visible += 1;
|
|
56
|
+
index += char.length;
|
|
57
|
+
}
|
|
58
|
+
return `${output}\u001b[0m…`;
|
|
59
|
+
}
|
|
60
|
+
|
|
24
61
|
function elapsed(iso: string | undefined, now = Date.now()): string | undefined {
|
|
25
62
|
if (!iso) return undefined;
|
|
26
63
|
const ms = Math.max(0, now - new Date(iso).getTime());
|
|
@@ -45,7 +82,7 @@ function agentActivity(agent: CrewAgentRecord): string {
|
|
|
45
82
|
const recent = agent.progress?.recentOutput?.at(-1);
|
|
46
83
|
if (recent) return recent.replace(/\s+/g, " ").trim();
|
|
47
84
|
if (agent.progress?.activityState === "needs_attention") return "needs attention";
|
|
48
|
-
if (agent.status === "queued") return "queued
|
|
85
|
+
if (agent.status === "queued") return "queued";
|
|
49
86
|
if (agent.status === "running") return "thinking…";
|
|
50
87
|
if (agent.status === "failed") return agent.error ?? "failed";
|
|
51
88
|
return "done";
|
|
@@ -61,53 +98,86 @@ function agentStats(agent: CrewAgentRecord): string {
|
|
|
61
98
|
return parts.join(" · ");
|
|
62
99
|
}
|
|
63
100
|
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
if (running) return running.taskId;
|
|
67
|
-
const queued = agents.find((agent) => agent.status === "queued");
|
|
68
|
-
if (queued) return queued.taskId;
|
|
69
|
-
return run.status;
|
|
101
|
+
function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
|
|
102
|
+
try { return readCrewAgents(run); } catch { return []; }
|
|
70
103
|
}
|
|
71
104
|
|
|
72
|
-
|
|
105
|
+
function activeWidgetRuns(cwd: string): WidgetRun[] {
|
|
73
106
|
const runs = listRuns(cwd).slice(0, 20);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
107
|
+
return runs.map((run) => ({ run, agents: agentsFor(run) })).filter((item) => isDisplayActiveRun(item.run, item.agents));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function statusSummary(runs: WidgetRun[]): string {
|
|
111
|
+
const runningAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "running").length;
|
|
112
|
+
const queuedAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "queued").length;
|
|
113
|
+
return `● pi-crew · runs=${runs.length} agents=${runningAgents} running${queuedAgents ? ` · ${queuedAgents} queued` : ""} · /team-dashboard`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
|
|
117
|
+
const runs = activeWidgetRuns(cwd);
|
|
118
|
+
if (!runs.length) return [];
|
|
78
119
|
const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
|
|
79
|
-
const lines: string[] = [];
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
lines.push(`${glyph(run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${runStep(run, agents)} · ${countText}`);
|
|
89
|
-
for (const agent of agents.filter((item) => item.status === "running" || item.status === "queued").slice(0, 2)) {
|
|
120
|
+
const lines: string[] = [statusSummary(runs)];
|
|
121
|
+
for (const { run, agents } of runs) {
|
|
122
|
+
const running = agents.find((agent) => agent.status === "running");
|
|
123
|
+
const queued = agents.find((agent) => agent.status === "queued");
|
|
124
|
+
const step = running?.taskId ?? queued?.taskId ?? run.status;
|
|
125
|
+
const completed = agents.filter((agent) => agent.status === "completed").length;
|
|
126
|
+
lines.push(`${glyph(run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${step} · ${completed}/${agents.length} done`);
|
|
127
|
+
const activeAgents = agents.filter((item) => item.status === "running" || item.status === "queued");
|
|
128
|
+
for (const agent of activeAgents.slice(0, 3)) {
|
|
90
129
|
const stats = agentStats(agent);
|
|
91
130
|
lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
|
|
92
131
|
}
|
|
132
|
+
if (activeAgents.length > 3) lines.push(` … +${activeAgents.length - 3} more agents`);
|
|
93
133
|
if (lines.length >= maxLines) break;
|
|
94
134
|
}
|
|
95
135
|
return lines.slice(0, maxLines);
|
|
96
136
|
}
|
|
97
137
|
|
|
98
|
-
|
|
138
|
+
class CrewWidgetComponent implements WidgetComponent {
|
|
139
|
+
private cwd: string;
|
|
140
|
+
private frame: number;
|
|
141
|
+
private maxLines: number;
|
|
142
|
+
private theme: ThemeLike;
|
|
143
|
+
|
|
144
|
+
constructor(cwd: string, frame: number, maxLines: number, theme: ThemeLike) {
|
|
145
|
+
this.cwd = cwd;
|
|
146
|
+
this.frame = frame;
|
|
147
|
+
this.maxLines = maxLines;
|
|
148
|
+
this.theme = theme;
|
|
149
|
+
}
|
|
150
|
+
invalidate(): void {}
|
|
151
|
+
render(width: number): string[] {
|
|
152
|
+
const fg = this.theme.fg?.bind(this.theme) ?? ((_color: string, text: string) => text);
|
|
153
|
+
const bold = this.theme.bold?.bind(this.theme) ?? ((text: string) => text);
|
|
154
|
+
return buildCrewWidgetLines(this.cwd, this.frame, this.maxLines).map((line, index) => {
|
|
155
|
+
const colored = index === 0
|
|
156
|
+
? line.replace("● pi-crew", `${fg("accent", "●")} ${bold("pi-crew")}`)
|
|
157
|
+
: line.replace(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏▶◦✓✗■·])/, (match, icon: string) => match.replace(icon, fg(icon === "✓" ? "success" : icon === "✗" ? "error" : icon === "◦" ? "dim" : "accent", icon)));
|
|
158
|
+
return truncate(colored, width);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState, config?: CrewUiConfig): void {
|
|
99
164
|
if (!ctx.hasUI) return;
|
|
100
165
|
state.frame += 1;
|
|
101
|
-
const
|
|
166
|
+
const maxLines = config?.widgetMaxLines ?? 8;
|
|
167
|
+
const lines = buildCrewWidgetLines(ctx.cwd, state.frame, maxLines);
|
|
102
168
|
ctx.ui.setStatus("pi-crew", lines.length ? lines[0] : undefined);
|
|
103
|
-
|
|
169
|
+
if (!lines.length) {
|
|
170
|
+
ctx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
ctx.ui.setWidget("pi-crew", ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(ctx.cwd, state.frame, maxLines, theme as ThemeLike)) as never, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
104
174
|
}
|
|
105
175
|
|
|
106
|
-
export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState): void {
|
|
176
|
+
export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState, config?: CrewUiConfig): void {
|
|
107
177
|
if (state.interval) clearInterval(state.interval);
|
|
108
178
|
state.interval = undefined;
|
|
109
179
|
if (ctx?.hasUI) {
|
|
110
180
|
ctx.ui.setStatus("pi-crew", undefined);
|
|
111
|
-
ctx.ui.setWidget("pi-crew", undefined, { placement: "aboveEditor" });
|
|
181
|
+
ctx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
112
182
|
}
|
|
113
183
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CrewUiConfig } from "../config/config.ts";
|
|
2
|
+
import { listRuns } from "../extension/run-index.ts";
|
|
3
|
+
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
4
|
+
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
5
|
+
|
|
6
|
+
type EventBus = { emit?: (event: string, data: unknown) => void } | undefined;
|
|
7
|
+
|
|
8
|
+
function safeEmit(events: EventBus, event: string, data: unknown): void {
|
|
9
|
+
try { events?.emit?.(event, data); } catch {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerPiCrewPowerbarSegments(events: EventBus, config?: CrewUiConfig): void {
|
|
13
|
+
if (config?.powerbar === false) return;
|
|
14
|
+
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-active", label: "pi-crew active agents" });
|
|
15
|
+
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-progress", label: "pi-crew run progress" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: CrewUiConfig): void {
|
|
19
|
+
if (config?.powerbar === false) return;
|
|
20
|
+
const active = listRuns(cwd).slice(0, 20).map((run) => {
|
|
21
|
+
let agents = [] as ReturnType<typeof readCrewAgents>;
|
|
22
|
+
try { agents = readCrewAgents(run); } catch {}
|
|
23
|
+
return { run, agents };
|
|
24
|
+
}).filter((item) => isDisplayActiveRun(item.run, item.agents));
|
|
25
|
+
if (!active.length) {
|
|
26
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
|
|
27
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const agents = active.flatMap((item) => item.agents);
|
|
31
|
+
const running = agents.filter((agent) => agent.status === "running").length;
|
|
32
|
+
const queued = agents.filter((agent) => agent.status === "queued").length;
|
|
33
|
+
const completed = agents.filter((agent) => agent.status === "completed").length;
|
|
34
|
+
const total = Math.max(1, agents.length);
|
|
35
|
+
safeEmit(events, "powerbar:update", {
|
|
36
|
+
id: "pi-crew-active",
|
|
37
|
+
icon: "⚙",
|
|
38
|
+
text: `crew ${running || active.length}`,
|
|
39
|
+
suffix: queued ? `${queued}q` : undefined,
|
|
40
|
+
color: running ? "accent" : "warning",
|
|
41
|
+
});
|
|
42
|
+
safeEmit(events, "powerbar:update", {
|
|
43
|
+
id: "pi-crew-progress",
|
|
44
|
+
text: active[0]?.run.team ?? "crew",
|
|
45
|
+
bar: Math.round((completed / total) * 100),
|
|
46
|
+
suffix: `${completed}/${total}`,
|
|
47
|
+
color: completed === total ? "success" : "accent",
|
|
48
|
+
barSegments: 8,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearPiCrewPowerbar(events: EventBus): void {
|
|
53
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
|
|
54
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
|
|
55
|
+
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
3
3
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
4
4
|
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
|
|
6
6
|
|
|
7
7
|
interface DashboardComponent {
|
|
8
8
|
invalidate(): void;
|
|
@@ -10,6 +10,8 @@ interface DashboardComponent {
|
|
|
10
10
|
handleInput(data: string): void;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
type DashboardTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
14
|
+
|
|
13
15
|
export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "reload";
|
|
14
16
|
export interface RunDashboardSelection {
|
|
15
17
|
runId: string;
|
|
@@ -49,9 +51,17 @@ function padVisible(value: string, width: number): string {
|
|
|
49
51
|
return `${value}${" ".repeat(Math.max(0, width - visibleLength(value)))}`;
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function colorForStatus(status: string): string {
|
|
55
|
+
if (status === "completed") return "success";
|
|
56
|
+
if (status === "failed" || status === "stale") return "error";
|
|
57
|
+
if (status === "cancelled" || status === "blocked") return "warning";
|
|
58
|
+
if (status === "running") return "accent";
|
|
59
|
+
return "dim";
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
function statusIcon(status: string): string {
|
|
53
63
|
if (status === "completed") return "✓";
|
|
54
|
-
if (status === "failed") return "✗";
|
|
64
|
+
if (status === "failed" || status === "stale") return "✗";
|
|
55
65
|
if (status === "cancelled") return "!";
|
|
56
66
|
if (status === "running") return "▶";
|
|
57
67
|
if (status === "blocked") return "■";
|
|
@@ -104,19 +114,24 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5): string[] {
|
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
|
|
118
|
+
try { return readCrewAgents(run); } catch { return []; }
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
function runLabel(run: TeamRunManifest, selected: boolean): string {
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
const agents = agentsFor(run);
|
|
123
|
+
const stale = isLikelyOrphanedActiveRun(run, agents);
|
|
110
124
|
const running = agents.find((agent) => agent.status === "running");
|
|
111
125
|
const queued = agents.find((agent) => agent.status === "queued");
|
|
112
|
-
const step = running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
|
|
126
|
+
const step = stale ? "orphaned queued run" : running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
|
|
127
|
+
const status = stale ? "stale" : run.status;
|
|
113
128
|
const marker = selected ? "›" : " ";
|
|
114
|
-
return `${marker} ${statusIcon(
|
|
129
|
+
return `${marker} ${statusIcon(status)} ${run.runId.slice(-8)} ${status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
|
|
115
130
|
}
|
|
116
131
|
|
|
117
132
|
function groupedRuns(runs: TeamRunManifest[]): Array<{ label: string; run?: TeamRunManifest }> {
|
|
118
|
-
const active = runs.filter((run) =>
|
|
119
|
-
const recent = runs.filter((run) => !
|
|
133
|
+
const active = runs.filter((run) => isDisplayActiveRun(run, agentsFor(run)));
|
|
134
|
+
const recent = runs.filter((run) => !isDisplayActiveRun(run, agentsFor(run)));
|
|
120
135
|
const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
|
|
121
136
|
if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
|
|
122
137
|
if (recent.length) rows.push({ label: "Recent" }, ...recent.map((run) => ({ label: run.runId, run })));
|
|
@@ -138,23 +153,28 @@ export class RunDashboard implements DashboardComponent {
|
|
|
138
153
|
private showFullProgress = false;
|
|
139
154
|
private readonly runs: TeamRunManifest[];
|
|
140
155
|
private readonly done: (selection: RunDashboardSelection | undefined) => void;
|
|
156
|
+
private readonly theme: DashboardTheme;
|
|
141
157
|
|
|
142
|
-
constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void) {
|
|
158
|
+
constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void, theme: unknown = {}) {
|
|
143
159
|
this.runs = runs;
|
|
144
160
|
this.done = done;
|
|
161
|
+
this.theme = theme as DashboardTheme;
|
|
145
162
|
}
|
|
146
163
|
|
|
147
164
|
invalidate(): void {}
|
|
148
165
|
|
|
149
166
|
render(width: number): string[] {
|
|
167
|
+
const fg = this.theme.fg?.bind(this.theme) ?? ((_color: string, text: string) => text);
|
|
168
|
+
const bold = this.theme.bold?.bind(this.theme) ?? ((text: string) => text);
|
|
150
169
|
const innerWidth = Math.max(20, width - 4);
|
|
151
170
|
const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
|
|
171
|
+
const border = (text: string) => fg("border", text);
|
|
152
172
|
const lines = [
|
|
153
|
-
`╭${"─".repeat(borderWidth)}
|
|
154
|
-
`│ ${padVisible(truncate("pi-crew dashboard"
|
|
155
|
-
`│ ${padVisible(truncate("↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close", innerWidth - 1), innerWidth - 1)}│`,
|
|
173
|
+
border(`╭${"─".repeat(borderWidth)}╮`),
|
|
174
|
+
`│ ${padVisible(truncate(`${fg("accent", "●")} ${bold("pi-crew dashboard")}`, innerWidth - 1), innerWidth - 1)}│`,
|
|
175
|
+
`│ ${padVisible(truncate(fg("dim", "↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close"), innerWidth - 1), innerWidth - 1)}│`,
|
|
156
176
|
`│ ${padVisible(truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1), innerWidth - 1)}│`,
|
|
157
|
-
`├${"─".repeat(borderWidth)}
|
|
177
|
+
border(`├${"─".repeat(borderWidth)}┤`),
|
|
158
178
|
];
|
|
159
179
|
if (this.runs.length === 0) {
|
|
160
180
|
lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
|
|
@@ -163,15 +183,17 @@ export class RunDashboard implements DashboardComponent {
|
|
|
163
183
|
const runRows = rows.filter((row) => row.run);
|
|
164
184
|
for (const row of rows) {
|
|
165
185
|
if (!row.run) {
|
|
166
|
-
lines.push(`│ ${padVisible(truncate(row.label, innerWidth - 1), innerWidth - 1)}│`);
|
|
186
|
+
lines.push(`│ ${padVisible(truncate(fg("accent", row.label), innerWidth - 1), innerWidth - 1)}│`);
|
|
167
187
|
continue;
|
|
168
188
|
}
|
|
169
189
|
const index = runRows.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
|
|
170
|
-
|
|
190
|
+
const label = runLabel(row.run, index === this.selected);
|
|
191
|
+
const status = isLikelyOrphanedActiveRun(row.run, agentsFor(row.run)) ? "stale" : row.run.status;
|
|
192
|
+
lines.push(`│ ${padVisible(truncate(fg(colorForStatus(status), label), innerWidth - 1), innerWidth - 1)}│`);
|
|
171
193
|
}
|
|
172
194
|
const selectedRun = selectedRunFromGrouped(this.runs, this.selected);
|
|
173
195
|
if (selectedRun) {
|
|
174
|
-
lines.push(`├${"─".repeat(borderWidth)}┤`);
|
|
196
|
+
lines.push(border(`├${"─".repeat(borderWidth)}┤`));
|
|
175
197
|
const details = [
|
|
176
198
|
`Selected: ${selectedRun.runId}`,
|
|
177
199
|
`Status: ${selectedRun.status} | Team: ${selectedRun.team} | Workflow: ${selectedRun.workflow ?? "none"}`,
|
|
@@ -186,7 +208,7 @@ export class RunDashboard implements DashboardComponent {
|
|
|
186
208
|
}
|
|
187
209
|
}
|
|
188
210
|
}
|
|
189
|
-
lines.push(`╰${"─".repeat(borderWidth)}╯`);
|
|
211
|
+
lines.push(border(`╰${"─".repeat(borderWidth)}╯`));
|
|
190
212
|
return lines.map((line) => truncate(line, width));
|
|
191
213
|
}
|
|
192
214
|
|
|
@@ -1,204 +1,219 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
type Component = { invalidate(): void; render(width: number): string[]; handleInput(data: string): void };
|
|
3
|
-
type TranscriptTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
4
|
-
import type { TeamRunManifest } from "../state/types.ts";
|
|
5
|
-
import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
6
|
-
|
|
7
|
-
function visibleWidth(text: string): number {
|
|
8
|
-
return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").length;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function truncate(text: string, width: number): string {
|
|
12
|
-
if (width <= 0) return "";
|
|
13
|
-
if (visibleWidth(text) <= width) return text;
|
|
14
|
-
return width <= 1 ? "…" : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function wrap(text: string, width: number): string[] {
|
|
18
|
-
const source = text.split(/\r?\n/);
|
|
19
|
-
const lines: string[] = [];
|
|
20
|
-
for (const raw of source) {
|
|
21
|
-
const line = raw || " ";
|
|
22
|
-
if (line.length <= width) {
|
|
23
|
-
lines.push(line);
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
for (let index = 0; index < line.length; index += width) lines.push(line.slice(index, index + width));
|
|
27
|
-
}
|
|
28
|
-
return lines;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
32
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function textFromContent(content: unknown): string {
|
|
36
|
-
if (typeof content === "string") return content;
|
|
37
|
-
if (!Array.isArray(content)) return "";
|
|
38
|
-
return content.map((part) => {
|
|
39
|
-
const obj = asRecord(part);
|
|
40
|
-
if (!obj) return "";
|
|
41
|
-
if (typeof obj.text === "string") return obj.text;
|
|
42
|
-
if (typeof obj.content === "string") return obj.content;
|
|
43
|
-
if (typeof obj.name === "string") return `[tool:${obj.name}]`;
|
|
44
|
-
return "";
|
|
45
|
-
}).filter(Boolean).join("\n");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const text = textFromContent(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
private
|
|
98
|
-
private
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
this.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
this.lastHeight =
|
|
185
|
-
|
|
186
|
-
this.scroll =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
type Component = { invalidate(): void; render(width: number): string[]; handleInput(data: string): void };
|
|
3
|
+
type TranscriptTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
4
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
5
|
+
import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
6
|
+
|
|
7
|
+
function visibleWidth(text: string): number {
|
|
8
|
+
return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function truncate(text: string, width: number): string {
|
|
12
|
+
if (width <= 0) return "";
|
|
13
|
+
if (visibleWidth(text) <= width) return text;
|
|
14
|
+
return width <= 1 ? "…" : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function wrap(text: string, width: number): string[] {
|
|
18
|
+
const source = text.split(/\r?\n/);
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
for (const raw of source) {
|
|
21
|
+
const line = raw || " ";
|
|
22
|
+
if (line.length <= width) {
|
|
23
|
+
lines.push(line);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
for (let index = 0; index < line.length; index += width) lines.push(line.slice(index, index + width));
|
|
27
|
+
}
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
32
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function textFromContent(content: unknown): string {
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
if (!Array.isArray(content)) return "";
|
|
38
|
+
return content.map((part) => {
|
|
39
|
+
const obj = asRecord(part);
|
|
40
|
+
if (!obj) return "";
|
|
41
|
+
if (typeof obj.text === "string") return obj.text;
|
|
42
|
+
if (typeof obj.content === "string") return obj.content;
|
|
43
|
+
if (typeof obj.name === "string") return `[tool:${obj.name}]`;
|
|
44
|
+
return "";
|
|
45
|
+
}).filter(Boolean).join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function truncateBody(text: string, max = 1000): string {
|
|
49
|
+
return text.length > max ? `${text.slice(0, max)}… (truncated ${text.length - max} chars)` : text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatTranscriptEvent(event: unknown): string[] {
|
|
53
|
+
const obj = asRecord(event);
|
|
54
|
+
if (!obj) return [String(event)];
|
|
55
|
+
const type = typeof obj.type === "string" ? obj.type : undefined;
|
|
56
|
+
const toolName = typeof obj.toolName === "string" ? obj.toolName : typeof obj.name === "string" ? obj.name : undefined;
|
|
57
|
+
if (type && /tool/i.test(type)) {
|
|
58
|
+
const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : typeof obj.result === "string" ? obj.result : "");
|
|
59
|
+
return [`[Tool${toolName ? `: ${toolName}` : ""}] ${type}`, truncateBody(text.trim() || "(no output)")];
|
|
60
|
+
}
|
|
61
|
+
const message = asRecord(obj.message);
|
|
62
|
+
if (message) {
|
|
63
|
+
const role = typeof message.role === "string" ? message.role : "message";
|
|
64
|
+
const text = textFromContent(message.content);
|
|
65
|
+
const label = role === "assistant" ? "Assistant" : role === "user" ? "User" : role;
|
|
66
|
+
if (text.trim()) return [`[${label}]:`, text.trim()];
|
|
67
|
+
}
|
|
68
|
+
if (type) {
|
|
69
|
+
const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : "");
|
|
70
|
+
return text.trim() ? [`[${type}]: ${text.trim()}`] : [`[${type}]`];
|
|
71
|
+
}
|
|
72
|
+
return [JSON.stringify(event)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatTranscriptText(text: string): string[] {
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
for (const raw of text.split(/\r?\n/).filter(Boolean)) {
|
|
78
|
+
try {
|
|
79
|
+
lines.push(...formatTranscriptEvent(JSON.parse(raw)));
|
|
80
|
+
} catch {
|
|
81
|
+
lines.push(raw);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return lines.length ? lines : ["(no transcript content)"];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readRunTranscript(manifest: TeamRunManifest, taskId?: string): { title: string; path: string; lines: string[] } {
|
|
88
|
+
const agents = readCrewAgents(manifest);
|
|
89
|
+
const agent = taskId ? agents.find((item) => item.taskId === taskId || item.id === taskId) : agents.find((item) => item.transcriptPath) ?? agents[0];
|
|
90
|
+
const selectedTaskId = agent?.taskId ?? taskId ?? "unknown";
|
|
91
|
+
const transcriptPath = agent?.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(manifest, selectedTaskId);
|
|
92
|
+
const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
|
|
93
|
+
return { title: `${manifest.runId}:${selectedTaskId}`, path: transcriptPath, lines: formatTranscriptText(text) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class DurableTextViewer implements Component {
|
|
97
|
+
private scroll = 0;
|
|
98
|
+
private lastHeight = 10;
|
|
99
|
+
private autoScroll = true;
|
|
100
|
+
private title: string;
|
|
101
|
+
private subtitle: string;
|
|
102
|
+
private lines: string[];
|
|
103
|
+
private theme: unknown;
|
|
104
|
+
private done: (result: undefined) => void;
|
|
105
|
+
|
|
106
|
+
constructor(title: string, subtitle: string, lines: string[], theme: unknown, done: (result: undefined) => void) {
|
|
107
|
+
this.title = title;
|
|
108
|
+
this.subtitle = subtitle;
|
|
109
|
+
this.lines = lines.length ? lines : ["(empty)"];
|
|
110
|
+
this.theme = theme;
|
|
111
|
+
this.done = done;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
invalidate(): void {}
|
|
115
|
+
|
|
116
|
+
handleInput(data: string): void {
|
|
117
|
+
if (data === "q" || data === "\u001b") {
|
|
118
|
+
this.done(undefined);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const maxScroll = Math.max(0, this.lines.length - this.lastHeight);
|
|
122
|
+
if (data === "k" || data === "\u001b[A") { this.scroll = Math.max(0, this.scroll - 1); this.autoScroll = false; }
|
|
123
|
+
else if (data === "j" || data === "\u001b[B") { this.scroll = Math.min(maxScroll, this.scroll + 1); this.autoScroll = this.scroll >= maxScroll; }
|
|
124
|
+
else if (data === "\u001b[5~") { this.scroll = Math.max(0, this.scroll - this.lastHeight); this.autoScroll = false; }
|
|
125
|
+
else if (data === "\u001b[6~") { this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight); this.autoScroll = this.scroll >= maxScroll; }
|
|
126
|
+
else if (data === "g" || data === "\u001b[H") { this.scroll = 0; this.autoScroll = false; }
|
|
127
|
+
else if (data === "G" || data === "\u001b[F") { this.scroll = maxScroll; this.autoScroll = true; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
render(width: number): string[] {
|
|
131
|
+
const th = this.theme as TranscriptTheme;
|
|
132
|
+
const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
|
|
133
|
+
const bold = th.bold?.bind(th) ?? ((text: string) => text);
|
|
134
|
+
const inner = Math.max(20, width - 4);
|
|
135
|
+
this.lastHeight = 16;
|
|
136
|
+
const body = this.lines.flatMap((line) => wrap(line, inner));
|
|
137
|
+
const maxScroll = Math.max(0, body.length - this.lastHeight);
|
|
138
|
+
if (this.autoScroll) this.scroll = maxScroll;
|
|
139
|
+
this.scroll = Math.min(this.scroll, maxScroll);
|
|
140
|
+
const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
|
|
141
|
+
const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
|
|
142
|
+
const colorLine = (line: string) => line.startsWith("[User]") ? fg("accent", line) : line.startsWith("[Assistant]") ? bold(line) : line.startsWith("[Tool") ? fg("muted", line) : line.startsWith("[Result]") ? fg("dim", line) : line;
|
|
143
|
+
const row = (text: string) => `${fg("border", "│")} ${truncate(pad(colorLine(text)), inner)} ${fg("border", "│")}`;
|
|
144
|
+
return [
|
|
145
|
+
fg("border", `╭${"─".repeat(inner + 2)}╮`),
|
|
146
|
+
row(`${bold(this.title)} ${fg("dim", this.subtitle)}`),
|
|
147
|
+
row(fg("dim", "j/k scroll · PgUp/PgDn · g/G top/bottom · q close")),
|
|
148
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
149
|
+
...visible.map(row),
|
|
150
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
151
|
+
row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}% · auto-scroll ${this.autoScroll ? "on" : "off"}`)),
|
|
152
|
+
fg("border", `╰${"─".repeat(inner + 2)}╯`),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class DurableTranscriptViewer implements Component {
|
|
158
|
+
private scroll = 0;
|
|
159
|
+
private lastHeight = 10;
|
|
160
|
+
private autoScroll = true;
|
|
161
|
+
private manifest: TeamRunManifest;
|
|
162
|
+
private theme: unknown;
|
|
163
|
+
private done: (result: undefined) => void;
|
|
164
|
+
private taskId?: string;
|
|
165
|
+
|
|
166
|
+
constructor(manifest: TeamRunManifest, theme: unknown, done: (result: undefined) => void, taskId?: string) {
|
|
167
|
+
this.manifest = manifest;
|
|
168
|
+
this.theme = theme;
|
|
169
|
+
this.done = done;
|
|
170
|
+
this.taskId = taskId;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
invalidate(): void {}
|
|
174
|
+
|
|
175
|
+
handleInput(data: string): void {
|
|
176
|
+
if (data === "q" || data === "\u001b") {
|
|
177
|
+
this.done(undefined);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const content = readRunTranscript(this.manifest, this.taskId).lines;
|
|
181
|
+
const maxScroll = Math.max(0, content.length - this.lastHeight);
|
|
182
|
+
if (data === "k" || data === "\u001b[A") { this.scroll = Math.max(0, this.scroll - 1); this.autoScroll = false; }
|
|
183
|
+
else if (data === "j" || data === "\u001b[B") { this.scroll = Math.min(maxScroll, this.scroll + 1); this.autoScroll = this.scroll >= maxScroll; }
|
|
184
|
+
else if (data === "\u001b[5~") { this.scroll = Math.max(0, this.scroll - this.lastHeight); this.autoScroll = false; }
|
|
185
|
+
else if (data === "\u001b[6~") { this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight); this.autoScroll = this.scroll >= maxScroll; }
|
|
186
|
+
else if (data === "g" || data === "\u001b[H") { this.scroll = 0; this.autoScroll = false; }
|
|
187
|
+
else if (data === "G" || data === "\u001b[F") { this.scroll = maxScroll; this.autoScroll = true; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
render(width: number): string[] {
|
|
191
|
+
const th = this.theme as TranscriptTheme;
|
|
192
|
+
const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
|
|
193
|
+
const bold = th.bold?.bind(th) ?? ((text: string) => text);
|
|
194
|
+
const inner = Math.max(20, width - 4);
|
|
195
|
+
const data = readRunTranscript(this.manifest, this.taskId);
|
|
196
|
+
const body = data.lines.flatMap((line) => wrap(line, inner));
|
|
197
|
+
this.lastHeight = 16;
|
|
198
|
+
const maxScroll = Math.max(0, body.length - this.lastHeight);
|
|
199
|
+
if (this.autoScroll) this.scroll = maxScroll;
|
|
200
|
+
this.scroll = Math.min(this.scroll, maxScroll);
|
|
201
|
+
const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
|
|
202
|
+
const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
|
|
203
|
+
const colorLine = (line: string) => line.startsWith("[User]") ? fg("accent", line) : line.startsWith("[Assistant]") ? bold(line) : line.startsWith("[Tool") ? fg("muted", line) : line.startsWith("[Result]") ? fg("dim", line) : line;
|
|
204
|
+
const row = (text: string) => `${fg("border", "│")} ${truncate(pad(colorLine(text)), inner)} ${fg("border", "│")}`;
|
|
205
|
+
const lines = [
|
|
206
|
+
fg("border", `╭${"─".repeat(inner + 2)}╮`),
|
|
207
|
+
row(`${bold("pi-crew transcript")} ${fg("dim", data.title)}`),
|
|
208
|
+
row(fg("dim", data.path)),
|
|
209
|
+
row(fg("dim", "j/k scroll · PgUp/PgDn · g/G top/bottom · q close")),
|
|
210
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
211
|
+
...visible.map(row),
|
|
212
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
213
|
+
row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}% · auto-scroll ${this.autoScroll ? "on" : "off"}`)),
|
|
214
|
+
fg("border", `╰${"─".repeat(inner + 2)}╯`),
|
|
215
|
+
];
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|