@quintinshaw/pi-dynamic-workflows 1.9.1 β†’ 1.9.2

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
@@ -25,7 +25,8 @@ Inspired by Anthropic's [dynamic workflows in Claude Code](https://claude.com/bl
25
25
  - 🌲 **Git worktree isolation** β€” `isolation: "worktree"` gives an agent its own branch so parallel agents can edit the same files without clobbering each other.
26
26
  - πŸ”­ **Bundled `/deep-research`** β€” fans out **real** web searches, fetches sources, keeps only multi-source-supported claims, and writes a cited report. Plus `/adversarial-review` for skeptic-vetted findings.
27
27
  - 🧩 **Saved & nested workflows** β€” turn any run into a `/<name>` slash command; compose saved workflows from inside other scripts.
28
- - πŸͺŸ **Background + live task panel** β€” run workflows in the background, watch a "Workflows running" panel under your input, and get the result delivered back into the chat when it finishes.
28
+ - πŸͺŸ **Non-blocking by default + live task panel** β€” workflows run in the background: the turn ends immediately so you can keep chatting or start other tasks, a "Workflows running" panel tracks them under your input, and when one finishes its result is delivered back and the conversation **auto-continues** (queued politely after whatever you're doing, never interrupting).
29
+ - 🌈 **Workflows mode in the input box** β€” type `workflow`/`workflows` and the word turns into a flowing rainbow, arming a forced workflow for that message. One Backspace right after the word disarms it (turns plain white) without deleting it.
29
30
 
30
31
  > **This is a heavily extended fork.** The [upstream project](https://github.com/Michaelliv/pi-dynamic-workflows) shipped the core script runtime; here, every advertised capability is actually **implemented, real-tested against the Pi SDK, and shipped** β€” see the [comparison](#whats-different-from-upstream) below.
31
32
 
@@ -56,7 +57,7 @@ Just ask for a workflow in plain language:
56
57
  Run a workflow to audit every route under src/routes/ for missing auth checks.
57
58
  ```
58
59
 
59
- Pi writes the script and streams compact progress inline:
60
+ Pi writes the script and runs it in the background β€” your turn ends right away, and a compact progress view streams in the "Workflows running" task panel while you keep working:
60
61
 
61
62
  ```text
62
63
  β—† Workflow: auth_audit (5/5 done Β· 48,210 tokens Β· $0.0131)
@@ -70,7 +71,7 @@ Pi writes the script and streams compact progress inline:
70
71
  #5 βœ“ adversarial recheck
71
72
  ```
72
73
 
73
- Press `Esc` to cancel; active subagents are aborted and surfaced as skipped.
74
+ When it finishes, the result is delivered back into the conversation and the turn auto-continues β€” queued after whatever you're doing so it never interrupts. (Need the result inline in the same turn instead? The model can pass `background: false` to block.)
74
75
 
75
76
  ## What's different from upstream
76
77
 
@@ -88,7 +89,7 @@ This fork turns the original's roadmap into working, tested features:
88
89
  | **`/deep-research` with real web access** | β€” | βœ… live search + cross-checking |
89
90
  | **Saved workflows as `/<name>`** | β€” | βœ… |
90
91
  | **Nested `workflow()`** | β€” | βœ… shares the global caps |
91
- | **Background runs + live task panel + result delivery** | β€” | βœ… |
92
+ | **Non-blocking background runs + live task panel + auto-continue delivery** | β€” | βœ… |
92
93
  | Test suite | minimal | βœ… 43 tests + real Pi end-to-end |
93
94
 
94
95
  <sub>*Upstream injected the requested model as a text line in the prompt; it never changed the subagent's actual model.</sub>
@@ -105,7 +106,11 @@ This fork turns the original's roadmap into working, tested features:
105
106
  /adversarial-review <task> # findings cross-checked by skeptical reviewers
106
107
  ```
107
108
 
108
- In the **interactive navigator**: `↑/↓` (or `j/k`) select Β· `enter`/`β†’` open Β· `esc`/`←` back Β· `j/k` scroll detail Β· `p` pause/resume Β· `x` stop Β· `s` save Β· `q` quit.
109
+ In the **interactive navigator**: `↑/↓` (or `j/k`) select Β· `enter`/`β†’` open Β· `esc`/`←` back Β· `j/k` scroll detail Β· `p` pause Β· `x` stop Β· `r` restart (re-runs the whole workflow as a fresh background run) Β· `s` save Β· `q` quit. The agents list and each agent's detail show **which model it ran on**.
110
+
111
+ ### Workflows mode (input box)
112
+
113
+ As you type, the words `workflow`/`workflows` light up as a **flowing rainbow** β€” a signal that submitting this message will deliberately run a workflow (the message is rewritten to ask Pi to orchestrate subagents rather than answer directly). Changed your mind? Press **Backspace** once right after the word: it turns plain white (disarmed) without being deleted. Type a fresh trigger word to re-arm. Slash commands like `/workflows` are left alone (never highlighted). Everything else about the editor β€” history, autocomplete, paste, multiline β€” is unchanged.
109
114
 
110
115
  ## Writing a workflow
111
116
 
@@ -152,7 +157,9 @@ return { inventory, summary }
152
157
  | `isolation` | `"worktree"` | Run this agent in its own throwaway git worktree (parallel edits without conflict) |
153
158
  | `timeoutMs` | number | Override the default 5-minute agent timeout |
154
159
 
155
- Models can also be set per phase via `meta.phases[].model`. Precedence: `opts.model` > phase model > session default; an unknown model logs a warning and falls back.
160
+ Models can also be set per phase via `meta.phases[].model`. Precedence: `opts.model` > phase model > session default; an unknown model logs a warning and falls back. The model each agent ran on is recorded and shown in the `/workflows` navigator.
161
+
162
+ **Model routing is decided by the assistant, not hardcoded.** When it writes a workflow, Pi is given the routing policy and the list of your currently authenticated models, and picks each agent's `model` accordingly: a lighter same-family model (one tier below your main model — e.g. Claude→Haiku, GPT→a mini) for exploration/search/gathering agents, and your main model for analysis/judgment/decision agents. If you name a specific model, that wins.
156
163
 
157
164
  ### Structured output
158
165
 
package/dist/agent.d.ts CHANGED
@@ -9,6 +9,12 @@ export interface WorkflowAgentOptions {
9
9
  /** Extra system guidance prepended to every subagent task. */
10
10
  instructions?: string;
11
11
  }
12
+ /**
13
+ * List the user's currently available models (those with auth configured) as
14
+ * `provider/modelId` specs. Used to tell the workflow author which models it may
15
+ * route agents to. Best-effort: returns [] if the registry can't be built.
16
+ */
17
+ export declare function listAvailableModelSpecs(): string[];
12
18
  /** Real token/cost usage for a single subagent run, read from the SDK session. */
13
19
  export interface AgentUsage {
14
20
  input: number;
package/dist/agent.js CHANGED
@@ -1,6 +1,22 @@
1
1
  import { join } from "node:path";
2
2
  import { AuthStorage, createAgentSession, createCodingTools, getAgentDir, ModelRegistry, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
3
3
  import { createStructuredOutputTool } from "./structured-output.js";
4
+ /**
5
+ * List the user's currently available models (those with auth configured) as
6
+ * `provider/modelId` specs. Used to tell the workflow author which models it may
7
+ * route agents to. Best-effort: returns [] if the registry can't be built.
8
+ */
9
+ export function listAvailableModelSpecs() {
10
+ try {
11
+ const dir = getAgentDir();
12
+ const auth = AuthStorage.create(join(dir, "auth.json"));
13
+ const registry = ModelRegistry.create(auth, join(dir, "models.json"));
14
+ return registry.getAvailable().map((m) => `${m.provider}/${m.id}`);
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ }
4
20
  export class WorkflowAgent {
5
21
  cwd;
6
22
  baseTools;
package/dist/display.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface WorkflowAgentSnapshot {
11
11
  error?: string;
12
12
  /** Tokens used by this agent. */
13
13
  tokens?: number;
14
+ /** The model this agent ran on (provider/id), when known. */
15
+ model?: string;
14
16
  }
15
17
  export interface WorkflowSnapshot {
16
18
  name: string;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type { AdversarialReviewConfig } from "./adversarial-review.js";
2
2
  export { generateAdversarialReviewWorkflow, generateMultiPerspectiveWorkflow } from "./adversarial-review.js";
3
3
  export type { AgentRunOptions, AgentRunResult, WorkflowAgentOptions } from "./agent.js";
4
- export { WorkflowAgent } from "./agent.js";
4
+ export { listAvailableModelSpecs, WorkflowAgent } from "./agent.js";
5
5
  export type { AutoWorkflowConfig } from "./auto-workflow.js";
6
6
  export { shouldUseWorkflow, suggestWorkflowScript } from "./auto-workflow.js";
7
7
  export { registerBuiltinWorkflows } from "./builtin-commands.js";
@@ -25,12 +25,13 @@ export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-t
25
25
  export type { AgentOptions, JournalEntry, SharedRuntime, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
26
26
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
27
27
  export { registerWorkflowCommands } from "./workflow-commands.js";
28
+ export { buildForcedWorkflowPrompt, colorizeWorkflow, endsWithTrigger, hasTrigger, installWorkflowEditor, RAINBOW, tokenizeAnsi, WorkflowEditor, type WorkflowModeState, } from "./workflow-editor.js";
28
29
  export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
29
30
  export { WorkflowManager } from "./workflow-manager.js";
30
31
  export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
31
32
  export { createWorkflowStorage } from "./workflow-saved.js";
32
33
  export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
33
- export { createWorkflowTool } from "./workflow-tool.js";
34
+ export { backgroundStartedText, createWorkflowTool } from "./workflow-tool.js";
34
35
  export { keyToAction, type NavAction, NavigatorModel, NavigatorState, openWorkflowNavigator, renderNavigator, type ViewKind, } from "./workflow-ui.js";
35
36
  export type { Worktree } from "./worktree.js";
36
37
  export { createWorktree, removeWorktree } from "./worktree.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { generateAdversarialReviewWorkflow, generateMultiPerspectiveWorkflow } from "./adversarial-review.js";
2
- export { WorkflowAgent } from "./agent.js";
2
+ export { listAvailableModelSpecs, WorkflowAgent } from "./agent.js";
3
3
  export { shouldUseWorkflow, suggestWorkflowScript } from "./auto-workflow.js";
4
4
  export { registerBuiltinWorkflows } from "./builtin-commands.js";
5
5
  export * from "./config.js";
@@ -15,8 +15,9 @@ export { installResultDelivery, installTaskPanel } from "./task-panel.js";
15
15
  export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-tools.js";
16
16
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
17
17
  export { registerWorkflowCommands } from "./workflow-commands.js";
18
+ export { buildForcedWorkflowPrompt, colorizeWorkflow, endsWithTrigger, hasTrigger, installWorkflowEditor, RAINBOW, tokenizeAnsi, WorkflowEditor, } from "./workflow-editor.js";
18
19
  export { WorkflowManager } from "./workflow-manager.js";
19
20
  export { createWorkflowStorage } from "./workflow-saved.js";
20
- export { createWorkflowTool } from "./workflow-tool.js";
21
+ export { backgroundStartedText, createWorkflowTool } from "./workflow-tool.js";
21
22
  export { keyToAction, NavigatorModel, NavigatorState, openWorkflowNavigator, renderNavigator, } from "./workflow-ui.js";
22
23
  export { createWorktree, removeWorktree } from "./worktree.js";
@@ -12,6 +12,8 @@ export interface PersistedAgentState {
12
12
  error?: string;
13
13
  startedAt?: string;
14
14
  endedAt?: string;
15
+ /** The model this agent ran on (provider/id), when known. */
16
+ model?: string;
15
17
  }
16
18
  export interface PersistedRunState {
17
19
  runId: string;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Background-run UX, mirroring Claude Code:
3
3
  * - A live task panel below the input lists in-progress runs while you keep working.
4
- * Focus it (↓) and press enter to open the full navigator.
4
+ * It is informational; run /workflows to open the full navigator.
5
5
  * - When a background run finishes, its result is delivered back into the
6
6
  * conversation so the paused task continues with the outcome.
7
7
  */
@@ -13,12 +13,21 @@ export interface TaskPanelOptions {
13
13
  cwd?: string;
14
14
  }
15
15
  /**
16
- * Deliver a background run's result into the conversation when it completes or
17
- * fails. Set up once per extension; idempotent via an internal guard.
16
+ * When a background run finishes (or fails), deliver its result back into the
17
+ * conversation AND continue the turn so the assistant can act on it β€” without
18
+ * blocking the user meanwhile:
19
+ *
20
+ * - `triggerTurn: true` starts a fresh turn when the agent is idle, feeding the
21
+ * result to the model so the paused conversation continues.
22
+ * - `deliverAs: "followUp"` means that if the user is busy in another turn, the
23
+ * result is queued and picked up after that turn finishes β€” never interrupting.
24
+ *
25
+ * Set up once per extension; idempotent via an internal guard.
18
26
  */
19
27
  export declare function installResultDelivery(pi: ExtensionAPI, manager: WorkflowManager): void;
20
28
  /**
21
29
  * Install the live "workflows running" panel below the editor. Re-rendered on
22
- * every manager event; focus + enter opens the navigator.
30
+ * every manager event. Informational only β€” the user opens the navigator with
31
+ * /workflows. (`_pi`/`_opts` are kept for signature stability.)
23
32
  */
24
- export declare function installTaskPanel(pi: ExtensionAPI, manager: WorkflowManager, ui: ExtensionUIContext, opts?: TaskPanelOptions): void;
33
+ export declare function installTaskPanel(_pi: ExtensionAPI, manager: WorkflowManager, ui: ExtensionUIContext, _opts?: TaskPanelOptions): void;
@@ -1,42 +1,56 @@
1
1
  /**
2
2
  * Background-run UX, mirroring Claude Code:
3
3
  * - A live task panel below the input lists in-progress runs while you keep working.
4
- * Focus it (↓) and press enter to open the full navigator.
4
+ * It is informational; run /workflows to open the full navigator.
5
5
  * - When a background run finishes, its result is delivered back into the
6
6
  * conversation so the paused task continues with the outcome.
7
7
  */
8
- import { parseKey } from "@earendil-works/pi-tui";
9
- import { openWorkflowNavigator } from "./workflow-ui.js";
10
8
  const RUN_EVENTS = ["agentStart", "agentEnd", "phase", "log", "complete", "error", "stopped", "paused", "resumed"];
11
9
  function deliverText(run) {
12
10
  const r = run.result?.result;
13
11
  const body = r && typeof r.report === "string" && r.report.trim() ? r.report : JSON.stringify(run.result?.result, null, 2);
14
12
  const tokens = run.result?.tokenUsage ? ` Β· ${run.result.tokenUsage.total.toLocaleString()} tokens` : "";
15
13
  const agents = run.result?.agentCount ?? run.snapshot.agentCount;
16
- return `βœ“ Workflow "${run.snapshot.name}" finished (${agents} agents${tokens}).\n\n${body}`;
14
+ return [
15
+ `βœ“ Background workflow "${run.snapshot.name}" finished (${agents} agents${tokens}).`,
16
+ "Continue helping the user based on this result.",
17
+ "",
18
+ body,
19
+ ].join("\n");
17
20
  }
18
21
  /**
19
- * Deliver a background run's result into the conversation when it completes or
20
- * fails. Set up once per extension; idempotent via an internal guard.
22
+ * When a background run finishes (or fails), deliver its result back into the
23
+ * conversation AND continue the turn so the assistant can act on it β€” without
24
+ * blocking the user meanwhile:
25
+ *
26
+ * - `triggerTurn: true` starts a fresh turn when the agent is idle, feeding the
27
+ * result to the model so the paused conversation continues.
28
+ * - `deliverAs: "followUp"` means that if the user is busy in another turn, the
29
+ * result is queued and picked up after that turn finishes β€” never interrupting.
30
+ *
31
+ * Set up once per extension; idempotent via an internal guard.
21
32
  */
22
33
  export function installResultDelivery(pi, manager) {
23
34
  if (manager.__deliveryInstalled)
24
35
  return;
25
36
  manager.__deliveryInstalled = true;
37
+ const deliver = (content) => {
38
+ void pi.sendMessage({ customType: "workflow-result", content, display: true }, { triggerTurn: true, deliverAs: "followUp" });
39
+ };
26
40
  manager.on("complete", ({ runId }) => {
27
41
  const run = manager.getRun(runId);
28
- if (run)
29
- void pi.sendMessage({ customType: "workflow-result", content: deliverText(run), display: true });
42
+ // Only background/resumed runs are delivered: a foreground (sync) run already
43
+ // returns its result inline as the tool result, so re-delivering would dup it.
44
+ if (run?.background)
45
+ deliver(deliverText(run));
30
46
  });
31
47
  manager.on("error", ({ runId, error }) => {
32
- void pi.sendMessage({
33
- customType: "workflow-result",
34
- content: `βœ— Workflow ${runId} failed: ${error?.message ?? "unknown error"}`,
35
- display: true,
36
- });
48
+ if (!manager.getRun(runId)?.background)
49
+ return;
50
+ deliver(`βœ— Background workflow ${runId} failed: ${error?.message ?? "unknown error"}`);
37
51
  });
38
52
  }
39
- function renderPanel(manager, theme, focused) {
53
+ function renderPanel(manager, theme) {
40
54
  const active = manager.listRuns().filter((r) => r.status === "running" || r.status === "paused");
41
55
  if (!active.length)
42
56
  return [];
@@ -48,29 +62,23 @@ function renderPanel(manager, theme, focused) {
48
62
  const phase = live?.snapshot.currentPhase ? ` Β· ${live.snapshot.currentPhase}` : "";
49
63
  return ` ${icon} ${r.workflowName} ${done}/${agents.length} agents${phase}`;
50
64
  });
51
- const hint = focused
52
- ? theme.fg("accent", " enter: open Β· esc: back")
53
- : theme.fg("dim", " ↓ then enter, or /workflows, to open");
65
+ const hint = theme.fg("dim", " run /workflows to open");
54
66
  return [theme.bold(`Workflows running (${active.length}):`), ...rows, hint];
55
67
  }
56
68
  /**
57
69
  * Install the live "workflows running" panel below the editor. Re-rendered on
58
- * every manager event; focus + enter opens the navigator.
70
+ * every manager event. Informational only β€” the user opens the navigator with
71
+ * /workflows. (`_pi`/`_opts` are kept for signature stability.)
59
72
  */
60
- export function installTaskPanel(pi, manager, ui, opts = {}) {
73
+ export function installTaskPanel(_pi, manager, ui, _opts = {}) {
61
74
  ui.setWidget("workflow-tasks", (tui, theme) => {
62
75
  const onEvent = () => tui.requestRender();
63
76
  for (const ev of RUN_EVENTS)
64
77
  manager.on(ev, onEvent);
78
+ // Purely informational: it lists running runs and re-renders on events. To
79
+ // open the navigator, the user runs /workflows (the panel takes no input).
65
80
  const comp = {
66
- focused: false,
67
- render: () => renderPanel(manager, theme, comp.focused ?? false),
68
- handleInput: (data) => {
69
- const key = parseKey(data);
70
- if (key === "enter" || key === "return" || key === "right") {
71
- void openWorkflowNavigator(pi, manager, ui, opts);
72
- }
73
- },
81
+ render: () => renderPanel(manager, theme),
74
82
  invalidate: () => { },
75
83
  dispose: () => {
76
84
  for (const ev of RUN_EVENTS)
@@ -0,0 +1,74 @@
1
+ /**
2
+ * "Workflows mode" input affordance, Γ  la a smart input box:
3
+ *
4
+ * - While the editor text contains the word `workflow`/`workflows`, those letters
5
+ * render as a flowing rainbow, signalling that submitting will engage a workflow.
6
+ * - Pressing Backspace immediately after such a word toggles the highlight OFF
7
+ * (the word stays, but turns plain white) β€” a non-destructive "don't run a
8
+ * workflow after all". Re-typing a fresh trigger word turns it back on.
9
+ * - When the highlight is ON at submit time, the user's message is transformed to
10
+ * instruct Pi to actually run the workflow tool.
11
+ *
12
+ * Implementation: we replace the core editor with a thin subclass of the exported
13
+ * `CustomEditor` (which itself extends pi-tui's `Editor`), overriding only
14
+ * `render()` (to colorize) and `handleInput()` (for the Backspace toggle). All
15
+ * other editor behavior β€” history, autocomplete, paste, undo, multiline β€” is
16
+ * inherited untouched.
17
+ */
18
+ import { CustomEditor, type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
19
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
20
+ /** 256-color ring cycling through the spectrum β€” shifted by a tick to "flow". */
21
+ export declare const RAINBOW: number[];
22
+ export declare function hasTrigger(text: string): boolean;
23
+ export declare function endsWithTrigger(textBeforeCursor: string): boolean;
24
+ /** Shared, mutable view of whether "workflows mode" is currently armed. */
25
+ export interface WorkflowModeState {
26
+ active: boolean;
27
+ }
28
+ interface AnsiToken {
29
+ esc?: string;
30
+ ch?: string;
31
+ }
32
+ /**
33
+ * Split a rendered line into ANSI-escape tokens (passed through verbatim) and
34
+ * single visible-character tokens. Handles CSI sequences (`\x1b[…m`, e.g. the
35
+ * cursor's inverse-video) and APC/OSC string sequences (e.g. the zero-width
36
+ * `CURSOR_MARKER` = `\x1b_pi:c\x07`) so colorization never corrupts them.
37
+ */
38
+ export declare function tokenizeAnsi(line: string): AnsiToken[];
39
+ /**
40
+ * Colorize every `workflow`/`workflows` occurrence in a rendered line with a
41
+ * flowing rainbow, leaving all ANSI escapes (cursor, markers) intact. Returns the
42
+ * line unchanged when it contains no trigger.
43
+ */
44
+ export declare function colorizeWorkflow(line: string, tick: number, palette?: number[]): string;
45
+ /**
46
+ * Editor that paints the trigger words and owns the on/off toggle. Reads/writes
47
+ * `state.active` so the extension's `input` handler can decide whether to force a
48
+ * workflow at submit time.
49
+ */
50
+ export declare class WorkflowEditor extends CustomEditor {
51
+ private readonly modeState;
52
+ private tick;
53
+ private timer?;
54
+ /** Toggled off by Backspace-after-word; re-armed when a fresh trigger appears. */
55
+ private disabled;
56
+ private wasTriggered;
57
+ constructor(tui: TUI, theme: EditorTheme, keybindings: ConstructorParameters<typeof CustomEditor>[2], modeState: WorkflowModeState);
58
+ /** Highlighted/armed: a trigger is present and the user hasn't toggled it off. */
59
+ isActive(): boolean;
60
+ handleInput(data: string): void;
61
+ render(width: number): string[];
62
+ /** Absolute text before the cursor, used to detect "right after the word". */
63
+ private cursorAfterTrigger;
64
+ private syncState;
65
+ private reconcileAnimation;
66
+ }
67
+ /** The directive appended to a submitted message when workflows mode is armed. */
68
+ export declare function buildForcedWorkflowPrompt(text: string): string;
69
+ /**
70
+ * Install the workflows-mode editor and the submit-time forcing hook.
71
+ * Call once with the UI context (e.g. in `session_start`).
72
+ */
73
+ export declare function installWorkflowEditor(pi: ExtensionAPI, ui: ExtensionUIContext): WorkflowModeState;
74
+ export {};
@@ -0,0 +1,228 @@
1
+ /**
2
+ * "Workflows mode" input affordance, Γ  la a smart input box:
3
+ *
4
+ * - While the editor text contains the word `workflow`/`workflows`, those letters
5
+ * render as a flowing rainbow, signalling that submitting will engage a workflow.
6
+ * - Pressing Backspace immediately after such a word toggles the highlight OFF
7
+ * (the word stays, but turns plain white) β€” a non-destructive "don't run a
8
+ * workflow after all". Re-typing a fresh trigger word turns it back on.
9
+ * - When the highlight is ON at submit time, the user's message is transformed to
10
+ * instruct Pi to actually run the workflow tool.
11
+ *
12
+ * Implementation: we replace the core editor with a thin subclass of the exported
13
+ * `CustomEditor` (which itself extends pi-tui's `Editor`), overriding only
14
+ * `render()` (to colorize) and `handleInput()` (for the Backspace toggle). All
15
+ * other editor behavior β€” history, autocomplete, paste, undo, multiline β€” is
16
+ * inherited untouched.
17
+ */
18
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
19
+ // A trigger is `workflow`/`workflows` (substring, case-insensitive) that is NOT
20
+ // immediately preceded by `/` β€” so a slash command like `/workflows` or `/workflow`
21
+ // is left alone (not colored, not armed).
22
+ /** Matches a trigger anywhere in the text. */
23
+ const TRIGGER = /(?<!\/)workflows?/i;
24
+ /** Global variant for finding every occurrence to colorize. */
25
+ const TRIGGER_G = /(?<!\/)workflows?/gi;
26
+ /** True when the text immediately before the cursor ends with a trigger word. */
27
+ const TRIGGER_AT_END = /(?<!\/)workflows?$/i;
28
+ /** 256-color ring cycling through the spectrum β€” shifted by a tick to "flow". */
29
+ export const RAINBOW = [
30
+ 196, 160, 202, 166, 208, 172, 214, 178, 220, 184, 226, 190, 118, 82, 46, 47, 48, 49, 50, 51, 45, 39, 33, 27, 21, 57,
31
+ 93, 129, 165, 201, 198, 197,
32
+ ];
33
+ export function hasTrigger(text) {
34
+ return TRIGGER.test(text);
35
+ }
36
+ export function endsWithTrigger(textBeforeCursor) {
37
+ return TRIGGER_AT_END.test(textBeforeCursor);
38
+ }
39
+ /**
40
+ * Split a rendered line into ANSI-escape tokens (passed through verbatim) and
41
+ * single visible-character tokens. Handles CSI sequences (`\x1b[…m`, e.g. the
42
+ * cursor's inverse-video) and APC/OSC string sequences (e.g. the zero-width
43
+ * `CURSOR_MARKER` = `\x1b_pi:c\x07`) so colorization never corrupts them.
44
+ */
45
+ export function tokenizeAnsi(line) {
46
+ const tokens = [];
47
+ let i = 0;
48
+ while (i < line.length) {
49
+ if (line[i] === "\x1b") {
50
+ let j = i + 1;
51
+ const next = line[j];
52
+ if (next === "[") {
53
+ // CSI: ends at a final byte in 0x40–0x7e.
54
+ j++;
55
+ while (j < line.length && !(line[j] >= "@" && line[j] <= "~"))
56
+ j++;
57
+ j++;
58
+ }
59
+ else if (next === "]" || next === "_" || next === "P" || next === "^") {
60
+ // String sequence: ends at BEL (\x07) or ST (\x1b\\).
61
+ j++;
62
+ while (j < line.length && line[j] !== "\x07" && !(line[j] === "\x1b" && line[j + 1] === "\\"))
63
+ j++;
64
+ if (line[j] === "\x07")
65
+ j++;
66
+ else if (line[j] === "\x1b")
67
+ j += 2;
68
+ }
69
+ else {
70
+ j++; // lone ESC + one byte
71
+ }
72
+ tokens.push({ esc: line.slice(i, j) });
73
+ i = j;
74
+ }
75
+ else {
76
+ tokens.push({ ch: line[i] });
77
+ i++;
78
+ }
79
+ }
80
+ return tokens;
81
+ }
82
+ /**
83
+ * Colorize every `workflow`/`workflows` occurrence in a rendered line with a
84
+ * flowing rainbow, leaving all ANSI escapes (cursor, markers) intact. Returns the
85
+ * line unchanged when it contains no trigger.
86
+ */
87
+ export function colorizeWorkflow(line, tick, palette = RAINBOW) {
88
+ const tokens = tokenizeAnsi(line);
89
+ const visible = tokens
90
+ .filter((t) => t.ch !== undefined)
91
+ .map((t) => t.ch)
92
+ .join("");
93
+ if (!TRIGGER.test(visible))
94
+ return line;
95
+ const ranges = [];
96
+ TRIGGER_G.lastIndex = 0;
97
+ for (let m = TRIGGER_G.exec(visible); m; m = TRIGGER_G.exec(visible)) {
98
+ ranges.push([m.index, m.index + m[0].length]);
99
+ }
100
+ const inRange = (idx) => ranges.some(([s, e]) => idx >= s && idx < e);
101
+ let out = "";
102
+ let vi = 0;
103
+ for (const t of tokens) {
104
+ if (t.esc !== undefined) {
105
+ out += t.esc;
106
+ continue;
107
+ }
108
+ if (inRange(vi)) {
109
+ const color = palette[(vi + tick) % palette.length];
110
+ // Reset only the foreground (39) afterwards so a surrounding inverse-video
111
+ // (the cursor) is preserved.
112
+ out += `\x1b[38;5;${color}m${t.ch}\x1b[39m`;
113
+ }
114
+ else {
115
+ out += t.ch ?? "";
116
+ }
117
+ vi++;
118
+ }
119
+ return out;
120
+ }
121
+ /** Backspace arrives as DEL (0x7f) or BS (0x08) depending on the terminal. */
122
+ function isBackspace(data) {
123
+ return data === "\x7f" || data === "\b";
124
+ }
125
+ /**
126
+ * Editor that paints the trigger words and owns the on/off toggle. Reads/writes
127
+ * `state.active` so the extension's `input` handler can decide whether to force a
128
+ * workflow at submit time.
129
+ */
130
+ export class WorkflowEditor extends CustomEditor {
131
+ modeState;
132
+ tick = 0;
133
+ timer;
134
+ /** Toggled off by Backspace-after-word; re-armed when a fresh trigger appears. */
135
+ disabled = false;
136
+ wasTriggered = false;
137
+ constructor(tui, theme, keybindings, modeState) {
138
+ super(tui, theme, keybindings);
139
+ this.modeState = modeState;
140
+ }
141
+ /** Highlighted/armed: a trigger is present and the user hasn't toggled it off. */
142
+ isActive() {
143
+ return !this.disabled && hasTrigger(this.getText());
144
+ }
145
+ handleInput(data) {
146
+ // First Backspace right after a trigger word disarms (non-destructive).
147
+ if (isBackspace(data) && this.isActive() && this.cursorAfterTrigger()) {
148
+ this.disabled = true;
149
+ this.syncState();
150
+ this.tui.requestRender();
151
+ return;
152
+ }
153
+ const before = this.getText();
154
+ super.handleInput(data);
155
+ const after = this.getText();
156
+ if (after !== before) {
157
+ const now = hasTrigger(after);
158
+ // A freshly typed trigger re-arms a previously disabled box.
159
+ if (now && !this.wasTriggered)
160
+ this.disabled = false;
161
+ this.wasTriggered = now;
162
+ }
163
+ this.syncState();
164
+ }
165
+ render(width) {
166
+ const lines = super.render(width);
167
+ // Keep the shared state current even for non-keystroke changes (history
168
+ // recall, programmatic setText) so the submit hook reads the right value.
169
+ this.syncState();
170
+ this.reconcileAnimation();
171
+ if (!this.isActive() || lines.length === 0)
172
+ return lines;
173
+ // First and last lines are the editor's horizontal borders; only the text
174
+ // lines in between are colorized.
175
+ return lines.map((ln, i) => (i === 0 || i === lines.length - 1 ? ln : colorizeWorkflow(ln, this.tick)));
176
+ }
177
+ /** Absolute text before the cursor, used to detect "right after the word". */
178
+ cursorAfterTrigger() {
179
+ const lines = this.getLines();
180
+ const { line, col } = this.getCursor();
181
+ const before = lines.slice(0, line).join("\n") + (line > 0 ? "\n" : "") + (lines[line] ?? "").slice(0, col);
182
+ return endsWithTrigger(before);
183
+ }
184
+ syncState() {
185
+ this.modeState.active = this.isActive();
186
+ }
187
+ reconcileAnimation() {
188
+ const shouldRun = this.isActive() && this.focused;
189
+ if (shouldRun && !this.timer) {
190
+ this.timer = setInterval(() => {
191
+ this.tick = (this.tick + 1) % (RAINBOW.length * 6);
192
+ this.tui.requestRender();
193
+ }, 90);
194
+ // Don't keep the process alive for the animation.
195
+ this.timer.unref?.();
196
+ }
197
+ else if (!shouldRun && this.timer) {
198
+ clearInterval(this.timer);
199
+ this.timer = undefined;
200
+ }
201
+ }
202
+ }
203
+ /** The directive appended to a submitted message when workflows mode is armed. */
204
+ export function buildForcedWorkflowPrompt(text) {
205
+ return [
206
+ text,
207
+ "",
208
+ "[workflows mode] The user armed workflows mode for this message. Handle it by",
209
+ "running the `workflow` tool: decompose the request and orchestrate subagents",
210
+ "with a workflow script, rather than answering directly.",
211
+ ].join("\n");
212
+ }
213
+ /**
214
+ * Install the workflows-mode editor and the submit-time forcing hook.
215
+ * Call once with the UI context (e.g. in `session_start`).
216
+ */
217
+ export function installWorkflowEditor(pi, ui) {
218
+ const state = { active: false };
219
+ ui.setEditorComponent((tui, theme, keybindings) => new WorkflowEditor(tui, theme, keybindings, state));
220
+ // When armed at submit time, rewrite the user's message to force a workflow.
221
+ pi.on("input", (event) => {
222
+ if (event.source !== "interactive" || !state.active || !event.text)
223
+ return { action: "continue" };
224
+ state.active = false; // consume the arm for this submission
225
+ return { action: "transform", text: buildForcedWorkflowPrompt(event.text) };
226
+ });
227
+ return state;
228
+ }