@wichayutdew/pi-workflows 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -955
- package/dist/index.js +841 -535
- package/examples/mr-comments.workflow.yaml +1 -1
- package/examples/prompts/mr-comments/plan.md +8 -1
- package/examples/starter-kit/mr-comment.workflow.yaml +1 -1
- package/examples/starter-kit/mr-review.workflow.yaml +4 -2
- package/examples/starter-kit/steps/mr-comment/plan.md +9 -2
- package/examples/starter-kit/steps/mr-review/publish.md +9 -2
- package/examples/starter-kit/steps/mr-review/review.md +9 -2
- package/examples/starter-kit/steps/mr-review/verify.md +16 -5
- package/examples/starter-kit/steps/shared/prepare-workspace.md +42 -4
- package/examples/starter-kit/steps/ticket/plan.md +31 -2
- package/examples/starter-kit/steps/work/plan.md +31 -2
- package/examples/starter-kit/ticket.workflow.yaml +14 -2
- package/examples/starter-kit/work.workflow.yaml +14 -2
- package/package.json +1 -1
- package/schemas/workflow.schema.json +1 -0
- package/src/command-names.ts +1 -0
- package/src/commands.ts +14 -0
- package/src/config/types.ts +1 -1
- package/src/config/validation/prompt.ts +3 -0
- package/src/engine/create-run.ts +4 -0
- package/src/engine/gate-transitions.ts +80 -33
- package/src/engine/run-advance.ts +36 -3
- package/src/engine/run-lifecycle.ts +69 -0
- package/src/engine/run-reconciliation.ts +2 -0
- package/src/engine/run-validation.ts +24 -1
- package/src/engine/state-types.ts +10 -0
- package/src/engine/state.ts +1 -0
- package/src/engine/transitions.ts +1 -0
- package/src/harness/action-context.ts +6 -0
- package/src/harness/core-actions.ts +2 -0
- package/src/harness/dependencies.ts +4 -0
- package/src/harness/lifecycle-actions.ts +14 -1
- package/src/harness/session-persistence.ts +66 -0
- package/src/harness/start-actions.ts +188 -1
- package/src/harness.ts +20 -0
- package/src/prompt/step-sections.ts +14 -0
- package/src/prompt/step-task.ts +3 -0
- package/src/prompt/template.ts +6 -3
- package/src/workflow-doctor.ts +1 -1
- package/src/workflow-status/render-summary.ts +3 -0
- package/src/workflow-status/view.ts +29 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { createRun } from '../engine/state.ts';
|
|
3
|
+
import { restartRun } from '../engine/transitions.ts';
|
|
4
|
+
import type { LoadedWorkflow } from '../config/types.ts';
|
|
3
5
|
import { analyzeWorkflow, formatWorkflowDoctor } from '../workflow-doctor.ts';
|
|
4
6
|
import { formatWorkflowList } from '../workflow-list.ts';
|
|
5
7
|
import type { HarnessActionContext as FullHarnessActionContext } from './action-context.ts';
|
|
@@ -36,6 +38,32 @@ function isCurrentSession(
|
|
|
36
38
|
return session.isSessionActive && session.sessionEpoch === sessionEpoch;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
type RestartWorkspaceBinding = {
|
|
42
|
+
readonly cwd: string;
|
|
43
|
+
readonly allowedRoots: ReadonlyArray<string>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function completedWorkspaceBinding(
|
|
47
|
+
run: NonNullable<HarnessActionContext['run']>,
|
|
48
|
+
workflow: LoadedWorkflow,
|
|
49
|
+
): RestartWorkspaceBinding | undefined {
|
|
50
|
+
for (let index = run.history.length - 1; index >= 0; index -= 1) {
|
|
51
|
+
const entry = run.history[index];
|
|
52
|
+
if (!entry?.workspaceCwd) continue;
|
|
53
|
+
const step = workflow.definition.steps[entry.stepId];
|
|
54
|
+
if (!step?.workspace || !step.workspace.bindOn.includes(entry.outcome)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`workspace-binding step "${entry.stepId}" no longer matches the completed iteration`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
cwd: entry.workspaceCwd,
|
|
61
|
+
allowedRoots: step.workspace.allowedRoots,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
39
67
|
export type StartActions = {
|
|
40
68
|
listWorkflows: (
|
|
41
69
|
this: HarnessActionContext,
|
|
@@ -53,6 +81,12 @@ export type StartActions = {
|
|
|
53
81
|
startContext: WorkflowStartContext,
|
|
54
82
|
sessionEpoch: number,
|
|
55
83
|
) => Promise<void>;
|
|
84
|
+
restartNow: (
|
|
85
|
+
this: HarnessActionContext,
|
|
86
|
+
input: string,
|
|
87
|
+
startContext: WorkflowStartContext,
|
|
88
|
+
sessionEpoch: number,
|
|
89
|
+
) => Promise<void>;
|
|
56
90
|
reloadNow: (
|
|
57
91
|
this: HarnessActionContext,
|
|
58
92
|
context: ExtensionCommandContext,
|
|
@@ -231,6 +265,159 @@ async function startNow(
|
|
|
231
265
|
this.launchCurrentStep(workflow);
|
|
232
266
|
}
|
|
233
267
|
|
|
268
|
+
async function restartNow(
|
|
269
|
+
this: HarnessActionContext,
|
|
270
|
+
input: string,
|
|
271
|
+
startContext: WorkflowStartContext,
|
|
272
|
+
sessionEpoch: number,
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
const { context } = startContext;
|
|
275
|
+
const completedRun = this.run;
|
|
276
|
+
if (!completedRun || completedRun.status !== 'completed') {
|
|
277
|
+
context.ui.notify('Only a completed workflow can be restarted', 'warning');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (this.activeDelegation) {
|
|
281
|
+
context.ui.notify(
|
|
282
|
+
`Cannot restart while subagent "${this.activeDelegation.agent}" is still cancelling`,
|
|
283
|
+
'warning',
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!completedRun.startCwd) {
|
|
288
|
+
context.ui.notify(
|
|
289
|
+
'Cannot restart this workflow on the same worktree because its original start directory was not captured; start a new workflow instead',
|
|
290
|
+
'error',
|
|
291
|
+
);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!context.isIdle()) {
|
|
295
|
+
context.abort();
|
|
296
|
+
await startContext.waitForIdle();
|
|
297
|
+
}
|
|
298
|
+
if (!isCurrentSession(this, sessionEpoch) || this.run !== completedRun) {
|
|
299
|
+
context.ui.notify(
|
|
300
|
+
'Workflow restart was superseded by a session or workflow change',
|
|
301
|
+
'warning',
|
|
302
|
+
);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
this.captureSkills(startContext.skills());
|
|
307
|
+
if (!(await this.reloadCatalog(context, false))) {
|
|
308
|
+
context.ui.notify(
|
|
309
|
+
'Workflow restart was superseded by a newer configuration load',
|
|
310
|
+
'warning',
|
|
311
|
+
);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (!isCurrentSession(this, sessionEpoch) || this.run !== completedRun) {
|
|
315
|
+
context.ui.notify(
|
|
316
|
+
'Workflow restart was superseded by a session or workflow change',
|
|
317
|
+
'warning',
|
|
318
|
+
);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const workflow = this.catalog.workflows.get(completedRun.workflowId);
|
|
323
|
+
if (!workflow) {
|
|
324
|
+
context.ui.notify(
|
|
325
|
+
`Workflow "${completedRun.workflowId}" is no longer loaded`,
|
|
326
|
+
'error',
|
|
327
|
+
);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const livenessErrors = analyzeWorkflow(workflow.definition).issues.filter(
|
|
331
|
+
(issue) => issue.level === 'error',
|
|
332
|
+
);
|
|
333
|
+
if (livenessErrors.length > 0) {
|
|
334
|
+
context.ui.notify(
|
|
335
|
+
`Cannot restart workflow; run /workflow-doctor ${workflow.definition.id}:\n${livenessErrors.map((issue) => issue.message).join('\n')}`,
|
|
336
|
+
'error',
|
|
337
|
+
);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const preflightErrors = this.preflight(workflow, workflow.definition.start);
|
|
341
|
+
if (preflightErrors.length > 0) {
|
|
342
|
+
context.ui.notify(
|
|
343
|
+
`Cannot restart workflow:\n${preflightErrors.join('\n')}`,
|
|
344
|
+
'error',
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let canonicalStartCwd: string;
|
|
350
|
+
let canonicalSessionCwd: string;
|
|
351
|
+
try {
|
|
352
|
+
canonicalStartCwd = this.dependencies.resolveWorkspaceDirectory({
|
|
353
|
+
candidateCwd: completedRun.startCwd,
|
|
354
|
+
startCwd: completedRun.startCwd,
|
|
355
|
+
allowedRoots: ['.'],
|
|
356
|
+
});
|
|
357
|
+
canonicalSessionCwd = this.dependencies.resolveWorkspaceDirectory({
|
|
358
|
+
candidateCwd: context.cwd,
|
|
359
|
+
startCwd: context.cwd,
|
|
360
|
+
allowedRoots: ['.'],
|
|
361
|
+
});
|
|
362
|
+
} catch (error) {
|
|
363
|
+
context.ui.notify(
|
|
364
|
+
`Cannot restart workflow on its captured worktree: ${
|
|
365
|
+
error instanceof Error ? error.message : String(error)
|
|
366
|
+
}`,
|
|
367
|
+
'error',
|
|
368
|
+
);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (
|
|
372
|
+
canonicalStartCwd !== completedRun.startCwd ||
|
|
373
|
+
canonicalSessionCwd !== canonicalStartCwd
|
|
374
|
+
) {
|
|
375
|
+
context.ui.notify(
|
|
376
|
+
'Current session cwd does not match the captured workflow start directory',
|
|
377
|
+
'error',
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
const binding = completedWorkspaceBinding(completedRun, workflow);
|
|
384
|
+
if (binding) {
|
|
385
|
+
const canonicalWorkspaceCwd = this.dependencies.resolveWorkspaceDirectory(
|
|
386
|
+
{
|
|
387
|
+
candidateCwd: binding.cwd,
|
|
388
|
+
startCwd: canonicalStartCwd,
|
|
389
|
+
allowedRoots: binding.allowedRoots,
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
if (canonicalWorkspaceCwd !== binding.cwd) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
'previous workspace no longer resolves to its captured canonical directory',
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
this.run = restartRun(
|
|
399
|
+
workflow,
|
|
400
|
+
completedRun,
|
|
401
|
+
input.trim() || completedRun.input,
|
|
402
|
+
this.pi.getActiveTools(),
|
|
403
|
+
this.dependencies.now(),
|
|
404
|
+
);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
context.ui.notify(
|
|
407
|
+
`Cannot restart workflow on the same worktree: ${
|
|
408
|
+
error instanceof Error ? error.message : String(error)
|
|
409
|
+
}`,
|
|
410
|
+
'error',
|
|
411
|
+
);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this.persist();
|
|
416
|
+
this.isolateMainSessionTools();
|
|
417
|
+
this.updateStatus();
|
|
418
|
+
this.launchCurrentStep(workflow);
|
|
419
|
+
}
|
|
420
|
+
|
|
234
421
|
async function reloadNow(
|
|
235
422
|
this: HarnessActionContext,
|
|
236
423
|
context: ExtensionCommandContext,
|
|
@@ -253,5 +440,5 @@ async function reloadNow(
|
|
|
253
440
|
* Returns workflow listing, start, and reload actions for harness composition.
|
|
254
441
|
*/
|
|
255
442
|
export function createStartActions(): StartActions {
|
|
256
|
-
return { listWorkflows, doctorWorkflows, startNow, reloadNow };
|
|
443
|
+
return { listWorkflows, doctorWorkflows, startNow, restartNow, reloadNow };
|
|
257
444
|
}
|
package/src/harness.ts
CHANGED
|
@@ -123,6 +123,11 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
123
123
|
startContext: WorkflowStartContext,
|
|
124
124
|
sessionEpoch: number,
|
|
125
125
|
) => Promise<void> = START_ACTIONS.startNow;
|
|
126
|
+
private readonly restartNow: (
|
|
127
|
+
input: string,
|
|
128
|
+
startContext: WorkflowStartContext,
|
|
129
|
+
sessionEpoch: number,
|
|
130
|
+
) => Promise<void> = START_ACTIONS.restartNow;
|
|
126
131
|
private readonly reloadNow: (
|
|
127
132
|
context: ExtensionCommandContext,
|
|
128
133
|
) => Promise<void> = START_ACTIONS.reloadNow;
|
|
@@ -345,6 +350,21 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
345
350
|
);
|
|
346
351
|
}
|
|
347
352
|
|
|
353
|
+
/** Starts another completed iteration in its existing workflow worktree. */
|
|
354
|
+
restart(input: string, context: ExtensionCommandContext): Promise<void> {
|
|
355
|
+
return this.enqueueMutation(context, (sessionEpoch) =>
|
|
356
|
+
this.restartNow(
|
|
357
|
+
input,
|
|
358
|
+
{
|
|
359
|
+
context,
|
|
360
|
+
skills: () => context.getSystemPromptOptions().skills,
|
|
361
|
+
waitForIdle: () => context.waitForIdle(),
|
|
362
|
+
},
|
|
363
|
+
sessionEpoch,
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
348
368
|
/** Pauses the active workflow while retaining its checkpoint. */
|
|
349
369
|
pause(reason: string, context: ExtensionCommandContext): Promise<void> {
|
|
350
370
|
return this.enqueueMutation(context, () => this.pauseNow(reason, context));
|
|
@@ -58,6 +58,20 @@ export function buildDelegatedHandoffSection(
|
|
|
58
58
|
];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Builds the immutable same-worktree constraint for a restarted iteration. */
|
|
62
|
+
export function buildRestartWorkspaceSection(
|
|
63
|
+
workspaceCwd: string | undefined,
|
|
64
|
+
): ReadonlyArray<string> {
|
|
65
|
+
if (!workspaceCwd) return [];
|
|
66
|
+
return [
|
|
67
|
+
'## Restart workspace constraint',
|
|
68
|
+
'',
|
|
69
|
+
`This iteration must reuse and rebind exactly this existing workspace: ${workspaceCwd}`,
|
|
70
|
+
'Do not create or substitute another workspace. If it cannot be safely reused, complete with a configured non-binding outcome that pauses the workflow.',
|
|
71
|
+
'',
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
61
75
|
/**
|
|
62
76
|
* Builds non-interactive recovery guidance specific to delegated steps.
|
|
63
77
|
*
|
package/src/prompt/step-task.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createStepContract } from './step-contract.ts';
|
|
|
4
4
|
import {
|
|
5
5
|
buildDelegatedCompletionInstructions,
|
|
6
6
|
buildDelegatedHandoffSection,
|
|
7
|
+
buildRestartWorkspaceSection,
|
|
7
8
|
buildResourceSection,
|
|
8
9
|
} from './step-sections.ts';
|
|
9
10
|
import {
|
|
@@ -119,6 +120,7 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
|
|
|
119
120
|
'',
|
|
120
121
|
`Workflow: ${workflow.definition.id}`,
|
|
121
122
|
`Run: ${run.runId}`,
|
|
123
|
+
`Iteration: ${run.iteration ?? 1}`,
|
|
122
124
|
`Step: ${run.currentStepId} (${step.title})`,
|
|
123
125
|
...(isDelegated
|
|
124
126
|
? [
|
|
@@ -132,6 +134,7 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
|
|
|
132
134
|
prompt,
|
|
133
135
|
'',
|
|
134
136
|
...(isDelegated ? buildDelegatedHandoffSection(handoff) : []),
|
|
137
|
+
...buildRestartWorkspaceSection(run.restartWorkspaceCwd),
|
|
135
138
|
...buildResumeInputSection(
|
|
136
139
|
run,
|
|
137
140
|
RESUME_INPUT_PLACEHOLDER.test(promptTemplate),
|
package/src/prompt/template.ts
CHANGED
|
@@ -7,8 +7,8 @@ import type { WorkflowRun } from '../engine/state.ts';
|
|
|
7
7
|
export type TemplateValues = Readonly<Record<string, string>>;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Combines the incoming handoff with the latest
|
|
11
|
-
*
|
|
10
|
+
* Combines the incoming handoff with the latest current-step summary without
|
|
11
|
+
* duplicating identical content.
|
|
12
12
|
*
|
|
13
13
|
* @param run - Current workflow run.
|
|
14
14
|
* @returns The handoff text for the active step.
|
|
@@ -27,7 +27,7 @@ export function currentStepHandoff(run: WorkflowRun): string {
|
|
|
27
27
|
'Incoming previous-step handoff:',
|
|
28
28
|
incomingHandoff,
|
|
29
29
|
'',
|
|
30
|
-
'Latest
|
|
30
|
+
'Latest current-step summary:',
|
|
31
31
|
run.lastSummary,
|
|
32
32
|
].join('\n');
|
|
33
33
|
}
|
|
@@ -72,13 +72,16 @@ export function createTemplateValues({
|
|
|
72
72
|
return {
|
|
73
73
|
'workflow.input': run.input,
|
|
74
74
|
'workflow.id': workflow.definition.id,
|
|
75
|
+
'workflow.iteration': String(run.iteration ?? 1),
|
|
75
76
|
'run.id': run.runId,
|
|
76
77
|
'step.id': run.currentStepId,
|
|
77
78
|
'step.title': step.title,
|
|
78
79
|
'last.summary': currentStepHandoff(run),
|
|
79
80
|
'reviewed.artifact': run.reviewedArtifact ?? '',
|
|
80
81
|
'reviewed.feedback': run.reviewedFeedback ?? '',
|
|
82
|
+
'gate.artifact': run.gateArtifact ?? '',
|
|
81
83
|
'gate.feedback': run.gateFeedback,
|
|
82
84
|
'resume.input': run.resumeInput ?? '',
|
|
85
|
+
'restart.workspace': run.restartWorkspaceCwd ?? '',
|
|
83
86
|
};
|
|
84
87
|
}
|
package/src/workflow-doctor.ts
CHANGED
|
@@ -235,7 +235,7 @@ export function formatWorkflowDoctor(
|
|
|
235
235
|
'',
|
|
236
236
|
`Result: ${errors.length > 0 ? 'ERROR' : warnings.length > 0 ? 'WARNING' : 'PASS'}`,
|
|
237
237
|
'',
|
|
238
|
-
`Runtime loop guard: each step
|
|
238
|
+
`Runtime loop guard: automatic graph advancement enters each step at most ${report.maxStepVisits} time${report.maxStepVisits === 1 ? '' : 's'} before the next attempted entry pauses the run. An explicit human rejection back to the same gated step bypasses that check for its transition because every revision awaits another decision; the visit is still recorded. This bounds unattended cycling; it does not guarantee $done or bound time spent inside a step or gate.`,
|
|
239
239
|
'',
|
|
240
240
|
);
|
|
241
241
|
if (report.issues.length === 0) {
|
|
@@ -82,6 +82,9 @@ export function renderSummaryLines(
|
|
|
82
82
|
]
|
|
83
83
|
: []),
|
|
84
84
|
...keyValueLines(theme, 'run', run.runId, width),
|
|
85
|
+
...(run.iteration && run.iteration > 1
|
|
86
|
+
? [...keyValueLines(theme, 'iteration', String(run.iteration), width)]
|
|
87
|
+
: []),
|
|
85
88
|
...keyValueLines(
|
|
86
89
|
theme,
|
|
87
90
|
'status',
|
|
@@ -135,6 +135,7 @@ function nextScrollOffset(state: ViewportState, value: number): number {
|
|
|
135
135
|
export class WorkflowStatusView implements Component {
|
|
136
136
|
private timer: RefreshTimer | undefined;
|
|
137
137
|
private state = initialViewportState();
|
|
138
|
+
private pendingDetailTopKey = false;
|
|
138
139
|
private readonly statusShortcutLabel: string;
|
|
139
140
|
private readonly dependencies: WorkflowStatusViewDependencies;
|
|
140
141
|
private readonly transcriptCache = new Map<string, StepTranscriptViewState>();
|
|
@@ -180,11 +181,15 @@ export class WorkflowStatusView implements Component {
|
|
|
180
181
|
|
|
181
182
|
/** Handle close and scrolling key input. */
|
|
182
183
|
handleInput(data: string): void {
|
|
184
|
+
const isDetailHalfPageDown =
|
|
185
|
+
this.state.mode === 'detail' && matchesKey(data, Key.ctrl('d'));
|
|
186
|
+
const isDetailHalfPageUp =
|
|
187
|
+
this.state.mode === 'detail' && matchesKey(data, Key.ctrl('u'));
|
|
183
188
|
if (
|
|
184
189
|
data === 'q' ||
|
|
185
190
|
data === 'Q' ||
|
|
186
191
|
matchesKey(data, 'ctrl+c') ||
|
|
187
|
-
matchesKey(data, 'ctrl+d') ||
|
|
192
|
+
(this.state.mode !== 'detail' && matchesKey(data, 'ctrl+d')) ||
|
|
188
193
|
matchesKey(data, this.statusShortcut)
|
|
189
194
|
) {
|
|
190
195
|
this.close();
|
|
@@ -199,13 +204,31 @@ export class WorkflowStatusView implements Component {
|
|
|
199
204
|
return;
|
|
200
205
|
}
|
|
201
206
|
const pageSize = Math.max(1, this.state.viewportRows - 2);
|
|
207
|
+
const contentHeight = Math.max(1, this.state.viewportRows - 1);
|
|
208
|
+
const halfPageSize = Math.max(1, Math.floor(contentHeight / 2));
|
|
202
209
|
if (this.state.mode === 'detail') {
|
|
203
|
-
if (
|
|
210
|
+
if (data === 'gg' || (data === 'g' && this.pendingDetailTopKey)) {
|
|
211
|
+
this.pendingDetailTopKey = false;
|
|
212
|
+
this.setScrollOffset(0);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (data === 'g') {
|
|
216
|
+
this.pendingDetailTopKey = true;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.pendingDetailTopKey = false;
|
|
220
|
+
if (data === 'G') {
|
|
221
|
+
this.setScrollOffset(Number.MAX_SAFE_INTEGER);
|
|
222
|
+
} else if (matchesKey(data, Key.left) || data === 'h') {
|
|
204
223
|
this.showBoard();
|
|
205
224
|
} else if (matchesKey(data, Key.down) || data === 'j') {
|
|
206
225
|
this.setScrollOffset(this.state.scrollOffset + 1);
|
|
207
226
|
} else if (matchesKey(data, Key.up) || data === 'k') {
|
|
208
227
|
this.setScrollOffset(this.state.scrollOffset - 1);
|
|
228
|
+
} else if (isDetailHalfPageDown) {
|
|
229
|
+
this.setScrollOffset(this.state.scrollOffset + halfPageSize);
|
|
230
|
+
} else if (isDetailHalfPageUp) {
|
|
231
|
+
this.setScrollOffset(this.state.scrollOffset - halfPageSize);
|
|
209
232
|
} else if (matchesKey(data, Key.pageDown)) {
|
|
210
233
|
this.setScrollOffset(this.state.scrollOffset + pageSize);
|
|
211
234
|
} else if (matchesKey(data, Key.pageUp)) {
|
|
@@ -217,6 +240,7 @@ export class WorkflowStatusView implements Component {
|
|
|
217
240
|
}
|
|
218
241
|
return;
|
|
219
242
|
}
|
|
243
|
+
this.pendingDetailTopKey = false;
|
|
220
244
|
if (matchesKey(data, Key.down) || data === 'j') {
|
|
221
245
|
this.moveSelection(1);
|
|
222
246
|
} else if (matchesKey(data, Key.up) || data === 'k') {
|
|
@@ -287,7 +311,7 @@ export class WorkflowStatusView implements Component {
|
|
|
287
311
|
this.statusShortcutLabel,
|
|
288
312
|
this.theme,
|
|
289
313
|
this.state.mode === 'detail'
|
|
290
|
-
? '
|
|
314
|
+
? '↑↓/jk · Ctrl+D/U half-page · gg/G top/bottom · PgUp/PgDn · ←/h/Esc'
|
|
291
315
|
: '↑/↓ or j/k select · Enter/→/l inspect · PgUp/PgDn',
|
|
292
316
|
);
|
|
293
317
|
this.state = page.state;
|
|
@@ -339,6 +363,7 @@ export class WorkflowStatusView implements Component {
|
|
|
339
363
|
if (!snapshot) return;
|
|
340
364
|
this.normalizeSelection(snapshot);
|
|
341
365
|
if (!selectedStepDetail(snapshot, this.state.selectedIndex)) return;
|
|
366
|
+
this.pendingDetailTopKey = false;
|
|
342
367
|
this.state = { ...this.state, mode: 'detail', scrollOffset: 0 };
|
|
343
368
|
this.ensureSelectedTranscripts(snapshot);
|
|
344
369
|
this.tui.requestRender(true);
|
|
@@ -346,6 +371,7 @@ export class WorkflowStatusView implements Component {
|
|
|
346
371
|
|
|
347
372
|
private showBoard(): void {
|
|
348
373
|
if (this.state.mode === 'board') return;
|
|
374
|
+
this.pendingDetailTopKey = false;
|
|
349
375
|
this.state = { ...this.state, mode: 'board', scrollOffset: 0 };
|
|
350
376
|
this.tui.requestRender(true);
|
|
351
377
|
}
|