@workflow-manager/runner 0.6.0 → 0.7.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 CHANGED
@@ -78,7 +78,7 @@ Use `wfm doctor` to inspect host adapter setup and `wfm doctor <workflow>` to va
78
78
 
79
79
  During `wfm run`, the CLI starts a local attach API on `127.0.0.1`. Use `--port <n>` to bind a fixed port or omit it to let the OS choose one. The CLI prints the attach base URL and bearer token before execution starts.
80
80
 
81
- By default, `wfm run` now prints live workflow progress to stderr with the current step, elapsed workflow time, and remaining step count. Pass `--verbose` to stream per-step agent output chunks and execution status updates while the workflow is running. When a human approval is required in an interactive terminal, the CLI now prints an approval summary and prompts for approve or cancel inline.
81
+ By default, `wfm run` now prints live workflow progress to stderr with the current step, elapsed workflow time, and remaining step count. Pass `--verbose` to stream per-step agent output chunks and execution status updates while the workflow is running. When a human approval is required in an interactive terminal, the CLI now prints an approval summary and prompts for approve or cancel inline. Pass `--ui` for a full-screen terminal UI with a live step list and per-step activity pane instead (falls back to standard output when stdin/stdout aren't an interactive terminal); see `doc/guide/terminal-ui.md`.
82
82
 
83
83
  Runner API endpoints include:
84
84
 
@@ -1,56 +1,4 @@
1
- function isTerminalStatus(status) {
2
- return status === "succeeded" || status === "failed" || status === "cancelled";
3
- }
4
- function formatCount(value, singular, plural) {
5
- return `${value} ${value === 1 ? singular : plural}`;
6
- }
7
- function formatDurationMs(value) {
8
- if (!Number.isFinite(value) || value < 0) {
9
- return "0s";
10
- }
11
- if (value < 1000) {
12
- return `${Math.max(0, Math.round(value))}ms`;
13
- }
14
- const seconds = value / 1000;
15
- if (seconds < 60) {
16
- return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
17
- }
18
- const minutes = Math.floor(seconds / 60);
19
- const remainingSeconds = Math.round(seconds % 60);
20
- return `${minutes}m ${remainingSeconds}s`;
21
- }
22
- function elapsedFrom(iso) {
23
- if (!iso) {
24
- return "0s";
25
- }
26
- const startedAt = Date.parse(iso);
27
- if (Number.isNaN(startedAt)) {
28
- return "0s";
29
- }
30
- return formatDurationMs(Date.now() - startedAt);
31
- }
32
- function splitLines(value) {
33
- const normalized = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
34
- const parts = normalized.split("\n");
35
- const remainder = parts.pop() ?? "";
36
- return { lines: parts, remainder };
37
- }
38
- function describeAgentStatus(status) {
39
- switch (status) {
40
- case "running":
41
- return "started";
42
- case "succeeded":
43
- return "succeeded";
44
- case "failed":
45
- return "failed";
46
- case "waiting_for_approval":
47
- return "waiting for approval";
48
- case "cancelled":
49
- return "cancelled";
50
- default:
51
- return status.replace(/_/g, " ");
52
- }
53
- }
1
+ import { describeAgentStatus, elapsedFrom, formatCount, isTerminalStatus, splitLines, stepLabel, } from "./runFormat.js";
54
2
  export class CliRunRenderer {
55
3
  verbose;
56
4
  stream;
@@ -281,6 +229,3 @@ export class CliRunRenderer {
281
229
  this.stream.write(`${line}\n`);
282
230
  }
283
231
  }
284
- function stepLabel(step) {
285
- return step.title ?? step.objective ?? step.key;
286
- }
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import matter from "gray-matter";
9
9
  import { CliRunRenderer } from "./cliRunRenderer.js";
10
+ import { TuiRunRenderer } from "./tui/tuiRunRenderer.js";
10
11
  import { startRunnerApiServer } from "./runnerApi.js";
11
12
  import { RunnerSessionStore } from "./runnerSession.js";
12
13
  import { parseWorkflowFile, validateWorkflow } from "./parser.js";
@@ -55,7 +56,7 @@ function usage() {
55
56
  row("scaffold [path]", "Write a starter workflow file"),
56
57
  row("validate <file>", "Check a workflow for errors"),
57
58
  row("doctor [file]", "Check host setup; with a file, preflight it"),
58
- row("run <file>", "Run a workflow with live progress"),
59
+ row("run <file>", "Run a workflow with live progress (--ui for full-screen)"),
59
60
  "",
60
61
  "Control a running workflow",
61
62
  row("approve", "Approve a step waiting for review"),
@@ -688,6 +689,7 @@ async function cmdRun(filePath) {
688
689
  let runnerServer;
689
690
  let sessionStore;
690
691
  let liveRenderer;
692
+ let tuiRenderer;
691
693
  try {
692
694
  workflow = parseWorkflowFile(resolvedPath);
693
695
  const errors = validateWorkflow(workflow);
@@ -729,57 +731,78 @@ async function cmdRun(filePath) {
729
731
  console.error("--port must be an integer between 0 and 65535");
730
732
  return 1;
731
733
  }
734
+ const wantUi = hasFlag("--ui");
735
+ const useTui = wantUi && process.stdout.isTTY === true && process.stdin.isTTY === true;
736
+ if (wantUi && !useTui) {
737
+ process.stderr.write("⚠ --ui requires an interactive terminal; falling back to standard output\n");
738
+ }
732
739
  sessionStore = new RunnerSessionStore({
733
740
  runId,
734
741
  workflow,
735
742
  objective: objective ?? workflow.title,
736
743
  objectives: workflow.objectives ?? [],
737
744
  });
738
- liveRenderer = new CliRunRenderer({
739
- workflow,
740
- verbose: hasFlag("--verbose"),
741
- });
745
+ if (!useTui) {
746
+ liveRenderer = new CliRunRenderer({
747
+ workflow,
748
+ verbose: hasFlag("--verbose"),
749
+ });
750
+ }
742
751
  runnerServer = await startRunnerApiServer(sessionStore, requestedPort);
743
752
  const session = sessionStore.sessionInfo();
744
- process.stderr.write(`Attach API: ${session.baseUrl} (token ${session.attachToken})\n`);
753
+ if (!useTui) {
754
+ process.stderr.write(`Attach API: ${session.baseUrl} (token ${session.attachToken})\n`);
755
+ }
756
+ if (useTui) {
757
+ tuiRenderer = new TuiRunRenderer({
758
+ workflow,
759
+ session: sessionStore,
760
+ attachUrl: session.baseUrl,
761
+ attachToken: session.attachToken,
762
+ });
763
+ tuiRenderer.start();
764
+ }
745
765
  const result = await runWorkflow(workflow, {
746
766
  runId,
747
767
  objective,
748
768
  input,
749
769
  confirmations,
750
770
  autoConfirmAll: hasFlag("--auto-confirm-all"),
751
- interactive: process.stdin.isTTY,
771
+ interactive: useTui ? false : process.stdin.isTTY,
752
772
  workflowFilePath: resolvedPath,
753
- approvalPrompt: async (request) => {
754
- liveRenderer?.pauseHeartbeat();
755
- try {
756
- const decision = await promptForApprovalDecision(request.stepKey, request.reason, request.validation ?? "external", request.preview ?? null, "cli", request.signal);
757
- if (!decision) {
773
+ approvalPrompt: useTui
774
+ ? undefined
775
+ : async (request) => {
776
+ liveRenderer?.pauseHeartbeat();
777
+ try {
778
+ const decision = await promptForApprovalDecision(request.stepKey, request.reason, request.validation ?? "external", request.preview ?? null, "cli", request.signal);
779
+ if (!decision) {
780
+ return null;
781
+ }
782
+ const metadata = {
783
+ actor: decision.actor,
784
+ note: decision.note,
785
+ source: decision.source,
786
+ };
787
+ const outcome = decision.decision === "cancelled"
788
+ ? sessionStore?.cancel(request.stepKey, metadata)
789
+ : request.validation === "external"
790
+ ? sessionStore?.resume(request.stepKey, metadata)
791
+ : sessionStore?.approve(request.stepKey, metadata);
792
+ if (outcome && !outcome.ok) {
793
+ process.stderr.write(`Could not apply terminal decision for ${request.stepKey}: ${outcome.reason ?? "unknown error"}\n`);
794
+ }
758
795
  return null;
759
796
  }
760
- const metadata = {
761
- actor: decision.actor,
762
- note: decision.note,
763
- source: decision.source,
764
- };
765
- const outcome = decision.decision === "cancelled"
766
- ? sessionStore?.cancel(request.stepKey, metadata)
767
- : request.validation === "external"
768
- ? sessionStore?.resume(request.stepKey, metadata)
769
- : sessionStore?.approve(request.stepKey, metadata);
770
- if (outcome && !outcome.ok) {
771
- process.stderr.write(`Could not apply terminal decision for ${request.stepKey}: ${outcome.reason ?? "unknown error"}\n`);
797
+ finally {
798
+ liveRenderer?.resumeHeartbeat();
772
799
  }
773
- return null;
774
- }
775
- finally {
776
- liveRenderer?.resumeHeartbeat();
777
- }
778
- },
779
- observer: combineRunObservers(sessionStore, liveRenderer),
800
+ },
801
+ observer: combineRunObservers(sessionStore, useTui ? tuiRenderer : liveRenderer),
780
802
  controller: sessionStore,
781
803
  });
782
- liveRenderer.close();
804
+ tuiRenderer?.stop();
805
+ liveRenderer?.close();
783
806
  if (hasFlag("--json")) {
784
807
  console.log(JSON.stringify({ session: sessionStore.sessionInfo(), ...result }, null, 2));
785
808
  }
@@ -807,6 +830,7 @@ async function cmdRun(filePath) {
807
830
  return result.status === "succeeded" ? 0 : 2;
808
831
  }
809
832
  catch (err) {
833
+ tuiRenderer?.stop();
810
834
  liveRenderer?.close();
811
835
  console.error(`Run error: ${err.message}`);
812
836
  if (workflow) {
@@ -820,6 +844,7 @@ async function cmdRun(filePath) {
820
844
  return 1;
821
845
  }
822
846
  finally {
847
+ tuiRenderer?.stop();
823
848
  liveRenderer?.close();
824
849
  await runnerServer?.close().catch(() => undefined);
825
850
  }
package/dist/manPage.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const MAN_PAGE_SOURCE = ".TH WFM 1 \"April 2026\" \"@workflow-manager/runner\" \"User Commands\"\n.SH NAME\nwfm \\- run markdown or json workflows from the CLI\n.SH SYNOPSIS\n.B wfm\n.I command\n[options]\n.SH DESCRIPTION\nwfm parses a workflow definition file, validates it, and executes\nit with deterministic in-memory orchestration.\n\nWorkflow files can be Markdown with YAML frontmatter or JSON.\n.SH COMMANDS\n.TP\n.B doctor [workflow.md|workflow.json] [--json]\nInspect host adapter setup, LLM access keys, and current adapter implementation status. When a workflow path is provided, also validate schema and runtime requirements without executing steps.\n.TP\n.B skill list\nList the agent skills bundled with the npm package.\n.TP\n.B skill install [name ...] [--agent claude|opencode] [--global] [--dir path] [--all] [--force]\nInstall bundled agent skills into an agent skill directory. Defaults to the workflow-manager-cli skill and the project-level Claude Code directory (./.claude/skills). Existing skills are not overwritten unless --force is passed.\n.TP\n.B scaffold [path] [--format markdown|json]\nCreate a starter workflow file. Format defaults to markdown unless the output\npath ends in .json.\n.TP\n.B validate <workflow.md|workflow.json>\nValidate workflow structure and report schema errors.\n.TP\n.B run <workflow.md|workflow.json> [--input input.json] [--objective text] [--confirm list] [--auto-confirm-all] [--port number] [--verbose] [--json]\nRun the workflow with live CLI progress and optional JSON output.\n.TP\n.B approve [--url value] [--token value] [--run-id value] [--step value] [--actor value] [--note text]\nApprove the current waiting runner step through the local attach API.\n.TP\n.B resume [--url value] [--token value] [--run-id value] [--step value] [--actor value] [--note text]\nAlias for approve, intended for external resume flows.\n.TP\n.B cancel [--url value] [--token value] [--run-id value] [--step value] [--actor value] [--note text]\nCancel the current waiting runner step through the local attach API.\n.TP\n.B auth <login|whoami|logout> [--token value]\nManage remote registry authentication for CLI publish and pull flows.\n.TP\n.B publish <workflow.md|workflow.json> [--slug slug] [--title text] [--description text] [--visibility public|private] [--version label] [--tag a,b] [--draft]\nPublish a validated local workflow to the remote registry.\n.TP\n.B pull <owner/slug> [--version label] [--output path]\nDownload a remote workflow and write it to a local file.\n.TP\n.B search [query]\nSearch public workflows from the remote registry.\n.TP\n.B remote info <owner/slug>\nShow metadata and source information for a remote workflow.\n.TP\n.B man\nOpen this man page.\n.TP\n.B --version, -v, version\nPrint the installed wfm version and exit.\n.SH RUN OPTIONS\n.TP\n.B --input <path>\nJSON file merged into global workflow input state.\n.TP\n.B --objective <text>\nOverride the default run objective.\n.TP\n.B --confirm <stepA,stepB:human,...>\nProvide explicit confirmations for steps that require validation.\n.TP\n.B --auto-confirm-all\nBypass confirmation gating for all steps.\n.TP\n.B --port <number>\nBind the local attach API to a specific port. If omitted, the OS assigns a free port on 127.0.0.1.\n.TP\n.B --verbose\nStream per-step agent output and execution updates to stderr while the workflow runs.\n.TP\n.B --json\nPrint the final run result as JSON on stdout while keeping live progress on stderr.\n.TP\nHuman approval steps in an interactive terminal show an inline review prompt so they can be approved or cancelled without a separate HTTP client.\n.TP\n.B --url <value>\nRunner attach API base URL for approve, resume, or cancel commands.\n.TP\n.B --token <value>\nRunner attach API bearer token for approve, resume, or cancel commands.\n.TP\n.B --run-id <value>\nRunner id to control. If omitted, the CLI reads it from /session.\n.TP\n.B --step <value>\nOptional step key when controlling a specific waiting step.\n.TP\n.B --actor <value>\nActor name recorded in approval audit events.\n.TP\n.B --note <text>\nOptional approval or cancellation note recorded in the event payload.\n.SH EXAMPLES\n.TP\nValidate markdown workflow:\n.B wfm validate ./example-workflow.md\n.TP\nValidate json workflow:\n.B wfm validate ./example-workflow.json\n.TP\nScaffold json workflow file:\n.B wfm scaffold ./new-workflow.json --format json\n.TP\nAuthenticate with a CLI token:\n.B wfm auth login --token wm_exampletoken\n.TP\nPublish a workflow:\n.B wfm publish ./example-workflow.json --visibility public --tag example,automation\n.TP\nPull a remote workflow:\n.B wfm pull alice/remote-bunny --output ./remote-bunny.json\n.TP\nSearch the remote registry:\n.B wfm search bunny\n.TP\nRun with explicit confirmations:\n.B wfm run ./example-workflow.json --confirm discover:human,qa_gate:human\n.TP\nInspect host setup:\n.B wfm doctor\n.TP\nCheck a workflow before running it:\n.B wfm doctor ./example-workflow.json\n.TP\nInstall the bundled CLI skill for Claude Code:\n.B wfm skill install\n.TP\nInstall every bundled skill globally:\n.B wfm skill install --all --global\n.SH FILES\n.TP\n.B man/wfm.1\nThe manual page source shipped with this repository.\n.SH EXIT STATUS\n.TP\n.B 0\nSuccessful command execution.\n.TP\n.B 1\nValidation or runtime error.\n.TP\n.B 2\nRun completed in non-success terminal status.\n";
1
+ export declare const MAN_PAGE_SOURCE = ".TH WFM 1 \"April 2026\" \"@workflow-manager/runner\" \"User Commands\"\n.SH NAME\nwfm \\- run markdown or json workflows from the CLI\n.SH SYNOPSIS\n.B wfm\n.I command\n[options]\n.SH DESCRIPTION\nwfm parses a workflow definition file, validates it, and executes\nit with deterministic in-memory orchestration.\n\nWorkflow files can be Markdown with YAML frontmatter or JSON.\n.SH COMMANDS\n.TP\n.B doctor [workflow.md|workflow.json] [--json]\nInspect host adapter setup, LLM access keys, and current adapter implementation status. When a workflow path is provided, also validate schema and runtime requirements without executing steps.\n.TP\n.B skill list\nList the agent skills bundled with the npm package.\n.TP\n.B skill install [name ...] [--agent claude|opencode] [--global] [--dir path] [--all] [--force]\nInstall bundled agent skills into an agent skill directory. Defaults to the workflow-manager-cli skill and the project-level Claude Code directory (./.claude/skills). Existing skills are not overwritten unless --force is passed.\n.TP\n.B scaffold [path] [--format markdown|json]\nCreate a starter workflow file. Format defaults to markdown unless the output\npath ends in .json.\n.TP\n.B validate <workflow.md|workflow.json>\nValidate workflow structure and report schema errors.\n.TP\n.B run <workflow.md|workflow.json> [--input input.json] [--objective text] [--confirm list] [--auto-confirm-all] [--port number] [--verbose] [--json] [--ui]\nRun the workflow with live CLI progress and optional JSON output.\n.TP\n.B approve [--url value] [--token value] [--run-id value] [--step value] [--actor value] [--note text]\nApprove the current waiting runner step through the local attach API.\n.TP\n.B resume [--url value] [--token value] [--run-id value] [--step value] [--actor value] [--note text]\nAlias for approve, intended for external resume flows.\n.TP\n.B cancel [--url value] [--token value] [--run-id value] [--step value] [--actor value] [--note text]\nCancel the current waiting runner step through the local attach API.\n.TP\n.B auth <login|whoami|logout> [--token value]\nManage remote registry authentication for CLI publish and pull flows.\n.TP\n.B publish <workflow.md|workflow.json> [--slug slug] [--title text] [--description text] [--visibility public|private] [--version label] [--tag a,b] [--draft]\nPublish a validated local workflow to the remote registry.\n.TP\n.B pull <owner/slug> [--version label] [--output path]\nDownload a remote workflow and write it to a local file.\n.TP\n.B search [query]\nSearch public workflows from the remote registry.\n.TP\n.B remote info <owner/slug>\nShow metadata and source information for a remote workflow.\n.TP\n.B man\nOpen this man page.\n.TP\n.B --version, -v, version\nPrint the installed wfm version and exit.\n.SH RUN OPTIONS\n.TP\n.B --input <path>\nJSON file merged into global workflow input state.\n.TP\n.B --objective <text>\nOverride the default run objective.\n.TP\n.B --confirm <stepA,stepB:human,...>\nProvide explicit confirmations for steps that require validation.\n.TP\n.B --auto-confirm-all\nBypass confirmation gating for all steps.\n.TP\n.B --port <number>\nBind the local attach API to a specific port. If omitted, the OS assigns a free port on 127.0.0.1.\n.TP\n.B --verbose\nStream per-step agent output and execution updates to stderr while the workflow runs.\n.TP\n.B --json\nPrint the final run result as JSON on stdout while keeping live progress on stderr.\n.TP\n.B --ui\nFull-screen terminal UI (requires a TTY; falls back to standard output).\n.TP\nHuman approval steps in an interactive terminal show an inline review prompt so they can be approved or cancelled without a separate HTTP client.\n.TP\n.B --url <value>\nRunner attach API base URL for approve, resume, or cancel commands.\n.TP\n.B --token <value>\nRunner attach API bearer token for approve, resume, or cancel commands.\n.TP\n.B --run-id <value>\nRunner id to control. If omitted, the CLI reads it from /session.\n.TP\n.B --step <value>\nOptional step key when controlling a specific waiting step.\n.TP\n.B --actor <value>\nActor name recorded in approval audit events.\n.TP\n.B --note <text>\nOptional approval or cancellation note recorded in the event payload.\n.SH EXAMPLES\n.TP\nValidate markdown workflow:\n.B wfm validate ./example-workflow.md\n.TP\nValidate json workflow:\n.B wfm validate ./example-workflow.json\n.TP\nScaffold json workflow file:\n.B wfm scaffold ./new-workflow.json --format json\n.TP\nAuthenticate with a CLI token:\n.B wfm auth login --token wm_exampletoken\n.TP\nPublish a workflow:\n.B wfm publish ./example-workflow.json --visibility public --tag example,automation\n.TP\nPull a remote workflow:\n.B wfm pull alice/remote-bunny --output ./remote-bunny.json\n.TP\nSearch the remote registry:\n.B wfm search bunny\n.TP\nRun with explicit confirmations:\n.B wfm run ./example-workflow.json --confirm discover:human,qa_gate:human\n.TP\nInspect host setup:\n.B wfm doctor\n.TP\nCheck a workflow before running it:\n.B wfm doctor ./example-workflow.json\n.TP\nInstall the bundled CLI skill for Claude Code:\n.B wfm skill install\n.TP\nInstall every bundled skill globally:\n.B wfm skill install --all --global\n.SH FILES\n.TP\n.B man/wfm.1\nThe manual page source shipped with this repository.\n.SH EXIT STATUS\n.TP\n.B 0\nSuccessful command execution.\n.TP\n.B 1\nValidation or runtime error.\n.TP\n.B 2\nRun completed in non-success terminal status.\n";
package/dist/manPage.js CHANGED
@@ -28,7 +28,7 @@ path ends in .json.
28
28
  .B validate <workflow.md|workflow.json>
29
29
  Validate workflow structure and report schema errors.
30
30
  .TP
31
- .B run <workflow.md|workflow.json> [--input input.json] [--objective text] [--confirm list] [--auto-confirm-all] [--port number] [--verbose] [--json]
31
+ .B run <workflow.md|workflow.json> [--input input.json] [--objective text] [--confirm list] [--auto-confirm-all] [--port number] [--verbose] [--json] [--ui]
32
32
  Run the workflow with live CLI progress and optional JSON output.
33
33
  .TP
34
34
  .B approve [--url value] [--token value] [--run-id value] [--step value] [--actor value] [--note text]
@@ -83,6 +83,9 @@ Stream per-step agent output and execution updates to stderr while the workflow
83
83
  .B --json
84
84
  Print the final run result as JSON on stdout while keeping live progress on stderr.
85
85
  .TP
86
+ .B --ui
87
+ Full-screen terminal UI (requires a TTY; falls back to standard output).
88
+ .TP
86
89
  Human approval steps in an interactive terminal show an inline review prompt so they can be approved or cancelled without a separate HTTP client.
87
90
  .TP
88
91
  .B --url <value>
@@ -0,0 +1,11 @@
1
+ import type { StepDefinition, StepRunStatus, WorkflowRunStatus } from "./types.js";
2
+ export declare function isTerminalStatus(status: WorkflowRunStatus): boolean;
3
+ export declare function formatCount(value: number, singular: string, plural: string): string;
4
+ export declare function formatDurationMs(value: number): string;
5
+ export declare function elapsedFrom(iso: string | null, now?: number): string;
6
+ export declare function splitLines(value: string): {
7
+ lines: string[];
8
+ remainder: string;
9
+ };
10
+ export declare function describeAgentStatus(status: StepRunStatus): string;
11
+ export declare function stepLabel(step: StepDefinition): string;
@@ -0,0 +1,56 @@
1
+ export function isTerminalStatus(status) {
2
+ return status === "succeeded" || status === "failed" || status === "cancelled";
3
+ }
4
+ export function formatCount(value, singular, plural) {
5
+ return `${value} ${value === 1 ? singular : plural}`;
6
+ }
7
+ export function formatDurationMs(value) {
8
+ if (!Number.isFinite(value) || value < 0) {
9
+ return "0s";
10
+ }
11
+ if (value < 1000) {
12
+ return `${Math.max(0, Math.round(value))}ms`;
13
+ }
14
+ const seconds = value / 1000;
15
+ if (seconds < 60) {
16
+ return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
17
+ }
18
+ const minutes = Math.floor(seconds / 60);
19
+ const remainingSeconds = Math.round(seconds % 60);
20
+ return `${minutes}m ${remainingSeconds}s`;
21
+ }
22
+ export function elapsedFrom(iso, now = Date.now()) {
23
+ if (!iso) {
24
+ return "0s";
25
+ }
26
+ const startedAt = Date.parse(iso);
27
+ if (Number.isNaN(startedAt)) {
28
+ return "0s";
29
+ }
30
+ return formatDurationMs(now - startedAt);
31
+ }
32
+ export function splitLines(value) {
33
+ const normalized = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
34
+ const parts = normalized.split("\n");
35
+ const remainder = parts.pop() ?? "";
36
+ return { lines: parts, remainder };
37
+ }
38
+ export function describeAgentStatus(status) {
39
+ switch (status) {
40
+ case "running":
41
+ return "started";
42
+ case "succeeded":
43
+ return "succeeded";
44
+ case "failed":
45
+ return "failed";
46
+ case "waiting_for_approval":
47
+ return "waiting for approval";
48
+ case "cancelled":
49
+ return "cancelled";
50
+ default:
51
+ return status.replace(/_/g, " ");
52
+ }
53
+ }
54
+ export function stepLabel(step) {
55
+ return step.title ?? step.objective ?? step.key;
56
+ }
@@ -0,0 +1,23 @@
1
+ export declare const ALT_SCREEN_ENTER = "\u001B[?1049h";
2
+ export declare const ALT_SCREEN_LEAVE = "\u001B[?1049l";
3
+ export declare const CURSOR_HIDE = "\u001B[?25l";
4
+ export declare const CURSOR_SHOW = "\u001B[?25h";
5
+ export declare const CURSOR_HOME = "\u001B[H";
6
+ export declare const CLEAR_SCREEN = "\u001B[2J";
7
+ export declare const CLEAR_LINE_END = "\u001B[K";
8
+ export declare function stripAnsi(text: string): string;
9
+ /** ANSI-aware column count (per code unit; see module comment). */
10
+ export declare function visibleWidth(text: string): number;
11
+ /**
12
+ * Truncates `text` to at most `width` visible columns, appending `ellipsis`
13
+ * when truncation happens. ANSI escape sequences are copied through intact
14
+ * (never split) and do not count toward the width. If any escape sequence
15
+ * was emitted before the cut, a style reset is appended so styling does not
16
+ * bleed past the truncated text.
17
+ *
18
+ * Invariant: visibleWidth(result) <= width. When `width` is smaller than the
19
+ * ellipsis itself, a slice of the ellipsis is returned to keep the invariant.
20
+ */
21
+ export declare function truncateVisible(text: string, width: number, ellipsis?: string): string;
22
+ /** Right-pads `text` with spaces up to `width` visible columns (no-op when already wider). */
23
+ export declare function padVisible(text: string, width: number): string;
@@ -0,0 +1,102 @@
1
+ // Escape-code constants and ANSI-aware string math for the TUI renderer.
2
+ // Pure string helpers only: no I/O, no terminal capability detection.
3
+ //
4
+ // Width is counted per UTF-16 code unit after stripping ANSI CSI sequences.
5
+ // Grapheme clusters and East Asian wide characters are intentionally not
6
+ // handled (no grapheme lib); this is acceptable for the status/log text the
7
+ // TUI renders and keeps these helpers dependency-free.
8
+ export const ALT_SCREEN_ENTER = "\x1b[?1049h";
9
+ export const ALT_SCREEN_LEAVE = "\x1b[?1049l";
10
+ export const CURSOR_HIDE = "\x1b[?25l";
11
+ export const CURSOR_SHOW = "\x1b[?25h";
12
+ export const CURSOR_HOME = "\x1b[H";
13
+ export const CLEAR_SCREEN = "\x1b[2J";
14
+ export const CLEAR_LINE_END = "\x1b[K";
15
+ const ESC = "\x1b";
16
+ const STYLE_RESET = "\x1b[0m";
17
+ /**
18
+ * Returns the length of the ANSI CSI escape sequence starting at `start`,
19
+ * or 0 when `start` does not begin a well-formed CSI sequence.
20
+ *
21
+ * CSI grammar: ESC `[` then parameter/intermediate bytes (0x20-0x3f)
22
+ * terminated by a final byte (0x40-0x7e).
23
+ */
24
+ function escapeSequenceLength(text, start) {
25
+ if (text[start] !== ESC || text[start + 1] !== "[") {
26
+ return 0;
27
+ }
28
+ let index = start + 2;
29
+ while (index < text.length) {
30
+ const code = text.charCodeAt(index);
31
+ if (code >= 0x40 && code <= 0x7e) {
32
+ return index - start + 1;
33
+ }
34
+ if (code < 0x20 || code > 0x3f) {
35
+ return 0;
36
+ }
37
+ index += 1;
38
+ }
39
+ return 0;
40
+ }
41
+ export function stripAnsi(text) {
42
+ let out = "";
43
+ let index = 0;
44
+ while (index < text.length) {
45
+ const skip = escapeSequenceLength(text, index);
46
+ if (skip > 0) {
47
+ index += skip;
48
+ continue;
49
+ }
50
+ out += text[index];
51
+ index += 1;
52
+ }
53
+ return out;
54
+ }
55
+ /** ANSI-aware column count (per code unit; see module comment). */
56
+ export function visibleWidth(text) {
57
+ return stripAnsi(text).length;
58
+ }
59
+ /**
60
+ * Truncates `text` to at most `width` visible columns, appending `ellipsis`
61
+ * when truncation happens. ANSI escape sequences are copied through intact
62
+ * (never split) and do not count toward the width. If any escape sequence
63
+ * was emitted before the cut, a style reset is appended so styling does not
64
+ * bleed past the truncated text.
65
+ *
66
+ * Invariant: visibleWidth(result) <= width. When `width` is smaller than the
67
+ * ellipsis itself, a slice of the ellipsis is returned to keep the invariant.
68
+ */
69
+ export function truncateVisible(text, width, ellipsis = "…") {
70
+ if (visibleWidth(text) <= width) {
71
+ return text;
72
+ }
73
+ if (width <= 0) {
74
+ return "";
75
+ }
76
+ if (width < ellipsis.length) {
77
+ return ellipsis.slice(0, width);
78
+ }
79
+ const budget = width - ellipsis.length;
80
+ let out = "";
81
+ let visible = 0;
82
+ let sawEscape = false;
83
+ let index = 0;
84
+ while (index < text.length && visible < budget) {
85
+ const skip = escapeSequenceLength(text, index);
86
+ if (skip > 0) {
87
+ out += text.slice(index, index + skip);
88
+ sawEscape = true;
89
+ index += skip;
90
+ continue;
91
+ }
92
+ out += text[index];
93
+ visible += 1;
94
+ index += 1;
95
+ }
96
+ return out + ellipsis + (sawEscape ? STYLE_RESET : "");
97
+ }
98
+ /** Right-pads `text` with spaces up to `width` visible columns (no-op when already wider). */
99
+ export function padVisible(text, width) {
100
+ const missing = width - visibleWidth(text);
101
+ return missing > 0 ? text + " ".repeat(missing) : text;
102
+ }
@@ -0,0 +1,18 @@
1
+ import type { RunSnapshot, StepDetailSnapshot } from "../types.js";
2
+ import type { TuiLogStore } from "./logStore.js";
3
+ import type { Theme } from "./theme.js";
4
+ export interface TuiFrameState {
5
+ snapshot: RunSnapshot | null;
6
+ stepDetails: Map<string, StepDetailSnapshot>;
7
+ logs: TuiLogStore;
8
+ selectedStepKey: string | null;
9
+ follow: boolean;
10
+ attachUrl: string;
11
+ attachToken: string;
12
+ statusMessage?: string | null;
13
+ width: number;
14
+ height: number;
15
+ now: number;
16
+ theme: Theme;
17
+ }
18
+ export declare function renderFrame(state: TuiFrameState): string[];