pi-taskflow 0.0.21 → 0.0.23

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.
@@ -44,6 +44,16 @@ import {
44
44
  } from "./store.ts";
45
45
  import { CacheStore } from "./cache.ts";
46
46
  import { safeParse } from "./interpolate.ts";
47
+ import {
48
+ isValidKey,
49
+ queueSpawn,
50
+ readVisibleFindings,
51
+ readTree,
52
+ nodeDepth,
53
+ writeFinding,
54
+ writeReport,
55
+ } from "./context-store.ts";
56
+ import { MAX_DYNAMIC_NESTING } from "./schema.ts";
47
57
 
48
58
  interface TaskflowDetails {
49
59
  state?: RunState;
@@ -206,7 +216,6 @@ async function runFlow(
206
216
  },
207
217
  done,
208
218
  () => tui.terminal.rows,
209
- tui.terminal,
210
219
  );
211
220
  const onAbort = () => done("reject");
212
221
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -293,6 +302,21 @@ async function runFlow(
293
302
  }
294
303
 
295
304
  export default function (pi: ExtensionAPI) {
305
+ // ---- Dual identity ----------------------------------------------------
306
+ // When this extension is loaded INSIDE a subagent process that the taskflow
307
+ // runtime spawned with Shared Context Tree enabled, PI_TASKFLOW_CTX_DIR +
308
+ // PI_TASKFLOW_NODE_ID are present. In that case we register the ctx_* tools
309
+ // (the blackboard + supervision API) instead of the host `taskflow` tool —
310
+ // a subagent has no business orchestrating its own taskflows, and the host
311
+ // tool's heavy machinery is irrelevant there. When the env is absent we are
312
+ // the host: register `taskflow` + `/tf` exactly as before (zero change).
313
+ const ctxDir = process.env.PI_TASKFLOW_CTX_DIR;
314
+ const nodeId = process.env.PI_TASKFLOW_NODE_ID;
315
+ if (ctxDir && nodeId) {
316
+ registerCtxTools(pi, ctxDir, nodeId);
317
+ return;
318
+ }
319
+
296
320
  // ---- Register per-saved-flow shortcut commands on session start ----
297
321
  const registerSavedFlowCommands = (ctx: ExtensionContext) => {
298
322
  const flows = listFlows(ctx.cwd);
@@ -799,8 +823,13 @@ export default function (pi: ExtensionAPI) {
799
823
  );
800
824
  return;
801
825
  }
802
- const result = await ctx.ui.custom<RunHistoryResult | undefined>((_tui, theme, _kb, done) => {
803
- return new RunHistoryComponent(runs, theme, (r) => done(r));
826
+ const result = await ctx.ui.custom<RunHistoryResult | undefined>((tui, theme, _kb, done) => {
827
+ const comp = new RunHistoryComponent(runs, theme, (r) => done(r), {
828
+ refresh: () => listRuns(ctx.cwd, 50),
829
+ requestRender: () => tui.requestRender(),
830
+ intervalMs: 1000,
831
+ });
832
+ return comp;
804
833
  });
805
834
  if (result?.action === "resume") {
806
835
  if (ctx.isIdle()) {
@@ -908,6 +937,116 @@ export default function (pi: ExtensionAPI) {
908
937
 
909
938
  // --- helpers ---
910
939
 
940
+ /**
941
+ * Register the Shared Context Tree tools inside a subagent process. These read
942
+ * & write the per-run blackboard at `ctxDir` on behalf of node `nodeId`.
943
+ *
944
+ * - ctx_read : read findings visible to this node (own + ancestors + completed others)
945
+ * - ctx_write : write a finding (last-write-wins per key) so siblings can reuse it
946
+ * - ctx_report : report a result upward to the parent
947
+ * - ctx_spawn : queue child tasks the runtime picks up after this node finishes
948
+ */
949
+ function registerCtxTools(pi: ExtensionAPI, ctxDir: string, nodeId: string) {
950
+ const textResult = (text: string, isError = false): ToolResult => ({
951
+ content: [{ type: "text", text }],
952
+ details: { action: "ctx" },
953
+ ...(isError ? { isError: true } : {}),
954
+ });
955
+
956
+ pi.registerTool({
957
+ name: "ctx_read",
958
+ label: "Context Read",
959
+ description:
960
+ "Read shared findings from the taskflow blackboard (what sibling/ancestor agents already discovered). Pass a key to read one value, or omit to list all visible findings. Use this BEFORE re-reading files another agent may have already mapped.",
961
+ parameters: Type.Object({
962
+ key: Type.Optional(Type.String({ description: "Specific finding key to read; omit to get all visible findings." })),
963
+ }),
964
+ async execute(_id, params) {
965
+ try {
966
+ const out = readVisibleFindings(ctxDir, nodeId, params.key);
967
+ return textResult(typeof out === "string" ? out : JSON.stringify(out ?? null, null, 2));
968
+ } catch (e) {
969
+ return textResult(`ctx_read failed: ${e instanceof Error ? e.message : String(e)}`, true);
970
+ }
971
+ },
972
+ });
973
+
974
+ pi.registerTool({
975
+ name: "ctx_write",
976
+ label: "Context Write",
977
+ description:
978
+ "Write a finding to the shared taskflow blackboard so sibling/descendant agents can reuse it without re-reading files. Key must be [A-Za-z0-9._-] (<=128 chars). Value is any JSON. Last write wins per key.",
979
+ parameters: Type.Object({
980
+ key: Type.String({ description: "Finding key, e.g. 'endpoints' or 'auth.summary'." }),
981
+ value: Type.Unknown({ description: "The value to store (string, number, object, or array)." }),
982
+ }),
983
+ async execute(_id, params) {
984
+ if (!isValidKey(params.key)) {
985
+ return textResult(`ctx_write rejected: invalid key '${params.key}'.`, true);
986
+ }
987
+ try {
988
+ writeFinding(ctxDir, nodeId, params.key, params.value);
989
+ return textResult(`Stored finding '${params.key}'.`);
990
+ } catch (e) {
991
+ return textResult(`ctx_write failed: ${e instanceof Error ? e.message : String(e)}`, true);
992
+ }
993
+ },
994
+ });
995
+
996
+ pi.registerTool({
997
+ name: "ctx_report",
998
+ label: "Context Report",
999
+ description:
1000
+ "Report your result upward to the parent task. Provide a concise summary and optional structured JSON. The parent (and downstream phases) will see this report.",
1001
+ parameters: Type.Object({
1002
+ summary: Type.String({ description: "Concise summary of what you accomplished / found." }),
1003
+ structured: Type.Optional(Type.Unknown({ description: "Optional structured result (JSON)." })),
1004
+ }),
1005
+ async execute(_id, params) {
1006
+ try {
1007
+ writeReport(ctxDir, nodeId, params.summary, params.structured);
1008
+ return textResult("Report recorded.");
1009
+ } catch (e) {
1010
+ return textResult(`ctx_report failed: ${e instanceof Error ? e.message : String(e)}`, true);
1011
+ }
1012
+ },
1013
+ });
1014
+
1015
+ pi.registerTool({
1016
+ name: "ctx_spawn",
1017
+ label: "Context Spawn",
1018
+ description:
1019
+ "Delegate sub-tasks to NEW child agents. After you finish, the runtime runs each child (isolated context) and folds their reports back into your output. Use when you discover the work needs to fan out. Each assignment is EITHER {task, agent?} for one flat task, OR {subflow, defaultAgent?} where subflow is an inline plan {phases:[...]} (a dependency-bearing DAG: phases can use dependsOn / map / gate / reduce). Use a subflow when the delegated work itself has multiple coordinated steps.",
1020
+ parameters: Type.Object({
1021
+ assignments: Type.Array(
1022
+ Type.Object({
1023
+ task: Type.Optional(Type.String({ description: "A single child task prompt (use this OR subflow, not both)." })),
1024
+ agent: Type.Optional(Type.String({ description: "Agent name for a flat task (optional)." })),
1025
+ subflow: Type.Optional(Type.Unknown({ description: "An inline Taskflow plan {phases:[...]} or a bare phases array, run as a nested validated sub-flow." })),
1026
+ defaultAgent: Type.Optional(Type.String({ description: "Fallback agent for subflow phases that don't name their own (optional)." })),
1027
+ }),
1028
+ { description: "Child tasks to spawn (1..16). Each is a flat {task} or a {subflow} DAG." },
1029
+ ),
1030
+ }),
1031
+ async execute(_id, params) {
1032
+ // Depth cap: walk the parent chain in the tree to find this node's depth.
1033
+ try {
1034
+ const depth = nodeDepth(readTree(ctxDir), nodeId);
1035
+ if (depth >= MAX_DYNAMIC_NESTING) {
1036
+ return textResult(
1037
+ `ctx_spawn rejected: depth ${depth} >= MAX_DYNAMIC_NESTING (${MAX_DYNAMIC_NESTING}). Do the work yourself.`,
1038
+ true,
1039
+ );
1040
+ }
1041
+ const n = queueSpawn(ctxDir, nodeId, params.assignments);
1042
+ return textResult(`Queued ${n} child task(s); they will run after you finish and their reports will be appended to your output.`);
1043
+ } catch (e) {
1044
+ return textResult(`ctx_spawn failed: ${e instanceof Error ? e.message : String(e)}`, true);
1045
+ }
1046
+ },
1047
+ });
1048
+ }
1049
+
911
1050
  function errorResult(action: string, message: string): ToolResult {
912
1051
  return {
913
1052
  content: [{ type: "text", text: message }],
@@ -8,8 +8,11 @@
8
8
  * {previous.output} alias for the immediately-preceding completed phase output
9
9
  * {item} / {item.f} map loop variable (or custom name via phase.as)
10
10
  *
11
- * Unknown placeholders are left intact (with a recorded warning) rather than
12
- * throwing, so a partially-specified task still runs.
11
+ * Unknown placeholders are left intact rather than throwing, so a
12
+ * partially-specified task still runs. The unresolved refs are returned in
13
+ * `missing[]`; the runtime surfaces them as a phase warning (see
14
+ * `warnUnresolvedRefs` in runtime.ts) — logged and persisted to
15
+ * `PhaseState.warnings`.
13
16
  */
14
17
 
15
18
  export interface InterpolationContext {
@@ -123,13 +126,21 @@ export function safeParse(text: string): unknown {
123
126
  } catch {
124
127
  // noop
125
128
  }
126
- // Extract from a ```json fenced block
127
- const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
128
- if (fence) {
129
+ // Extract from fenced blocks. Outputs often contain multiple fences
130
+ // (e.g. a ```typescript evidence block before the ```json payload), so try
131
+ // every fence — json-tagged blocks first, then untagged/other blocks.
132
+ const fenceRe = /```(\w*)[ \t]*\r?\n?([\s\S]*?)```/g;
133
+ const fenced: { lang: string; body: string }[] = [];
134
+ let fm: RegExpExecArray | null;
135
+ while ((fm = fenceRe.exec(trimmed)) !== null) {
136
+ fenced.push({ lang: fm[1].toLowerCase(), body: fm[2].trim() });
137
+ }
138
+ const ordered = [...fenced.filter((b) => b.lang === "json"), ...fenced.filter((b) => b.lang !== "json")];
139
+ for (const block of ordered) {
129
140
  try {
130
- return JSON.parse(fence[1].trim());
141
+ return JSON.parse(block.body);
131
142
  } catch {
132
- // noop
143
+ // noop — try the next fence
133
144
  }
134
145
  }
135
146
  // Extract the first balanced [...] or {...}
@@ -60,6 +60,14 @@ export interface RunOptions {
60
60
  * the prior behaviour (no idle timeout). Defaults to DEFAULT_IDLE_TIMEOUT_MS.
61
61
  */
62
62
  idleTimeoutMs?: number;
63
+ /**
64
+ * Shared Context Tree (opt-in). When set, the spawned subagent receives
65
+ * PI_TASKFLOW_CTX_DIR + PI_TASKFLOW_NODE_ID in its environment and is loaded
66
+ * with this extension via `--extension`, so it can register the ctx_* tools
67
+ * (read/write/report/spawn) that read & write the per-run blackboard.
68
+ */
69
+ ctxDir?: string;
70
+ nodeId?: string;
63
71
  }
64
72
 
65
73
  /**
@@ -71,6 +79,41 @@ export interface RunOptions {
71
79
  */
72
80
  const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000;
73
81
 
82
+ /** The Shared Context Tree tool names a subagent may call when sharing is on. */
83
+ export const CTX_TOOL_NAMES = ["ctx_read", "ctx_write", "ctx_report", "ctx_spawn"] as const;
84
+
85
+ /**
86
+ * Guidance appended to a subagent's system prompt when the Shared Context Tree
87
+ * is enabled for its phase. Registering the ctx_* tools makes them AVAILABLE;
88
+ * this block is what makes the model actually USE them with the right discipline
89
+ * (read-before-you-explore; publish reusable findings; report up; delegate when
90
+ * work fans out). Kept short and imperative on purpose.
91
+ */
92
+ export const CTX_TOOLS_GUIDANCE = [
93
+ "## Shared Context Tree (you are part of a coordinated team of agents)",
94
+ "",
95
+ "You are one agent in a tree working a shared goal, with a shared blackboard",
96
+ "and an upward report channel. Use these tools deliberately \u2014 they save tokens",
97
+ "and prevent the team from duplicating work:",
98
+ "",
99
+ "- ctx_read(key?): BEFORE exploring the codebase or re-reading files, call",
100
+ " ctx_read with no arguments to see what teammates already discovered. If a",
101
+ " finding you need already exists, REUSE it instead of re-deriving it.",
102
+ "- ctx_write(key, value): when you discover something other agents will likely",
103
+ " need (a file map, an endpoint list, an interface, a config value), publish it",
104
+ " under a short key (e.g. 'endpoints', 'db.schema'). Keep values concise and",
105
+ " structured (JSON) so others can consume them directly.",
106
+ "- ctx_report(summary, structured?): when you finish, report your result upward",
107
+ " so the parent task and downstream steps can see it. Lead with the outcome.",
108
+ "- ctx_spawn(assignments[]): if you discover the work should fan out into",
109
+ " independent sub-tasks, delegate them as child agents. They run after you",
110
+ " finish and their reports are folded back into your output. Only spawn when it",
111
+ " genuinely parallelizes \u2014 otherwise just do the work yourself.",
112
+ "",
113
+ "Default habit: ctx_read first, do the work (reusing shared findings), ctx_write",
114
+ "anything reusable, then ctx_report your result.",
115
+ ].join("\n");
116
+
74
117
  export function isFailed(r: RunResult): boolean {
75
118
  return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
76
119
  }
@@ -281,6 +324,25 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
281
324
  return { command: "pi", args };
282
325
  }
283
326
 
327
+ /**
328
+ * Resolve the path to this extension's entry file, so a spawned subagent can be
329
+ * launched with `--extension <path>` and register the ctx_* tools. Returns
330
+ * undefined if it cannot be resolved (the subagent then simply runs without the
331
+ * ctx tools — fail-open: context sharing degrades to "no sharing").
332
+ */
333
+ export function ctxExtensionPath(): string | undefined {
334
+ const override = process.env.PI_TASKFLOW_EXT_PATH;
335
+ if (override) return override;
336
+ try {
337
+ const here = path.dirname(new URL(import.meta.url).pathname);
338
+ const entry = path.join(here, "index.ts");
339
+ if (fs.existsSync(entry)) return entry;
340
+ } catch {
341
+ /* fall through */
342
+ }
343
+ return undefined;
344
+ }
345
+
284
346
  /**
285
347
  * Run a single subagent task. Resolves the agent from `agents` by name and
286
348
  * spawns an isolated pi process, returning structured output + usage.
@@ -310,7 +372,15 @@ export async function runAgentTask(
310
372
 
311
373
  const model = opts.model ?? agent.model;
312
374
  const thinking = opts.thinking ?? agent.thinking ?? globalThinking;
313
- const tools = opts.tools ?? agent.tools;
375
+ const ctxEnabledEarly = Boolean(opts.ctxDir && opts.nodeId);
376
+ let tools = opts.tools ?? agent.tools;
377
+ // If the agent restricts tools to a whitelist, the ctx_* tools we register
378
+ // would be filtered out by `--tools` even though they're registered. When
379
+ // context sharing is on, extend the whitelist so the subagent can actually
380
+ // call them. (No whitelist = all tools available = nothing to do.)
381
+ if (ctxEnabledEarly && tools && tools.length > 0) {
382
+ tools = [...new Set([...tools, ...CTX_TOOL_NAMES])];
383
+ }
314
384
 
315
385
  const args: string[] = ["--mode", "json", "-p", "--no-session"];
316
386
  if (model) args.push("--model", model);
@@ -332,18 +402,40 @@ export async function runAgentTask(
332
402
  };
333
403
 
334
404
  try {
335
- if (agent.systemPrompt.trim()) {
405
+ const ctxEnabled = Boolean(opts.ctxDir && opts.nodeId);
406
+ // Build the appended system prompt = the agent's own prompt PLUS, when the
407
+ // Shared Context Tree is enabled for this phase, a guidance block that tells
408
+ // the subagent the ctx_* tools exist and the discipline for using them.
409
+ // Without this the model only sees terse tool descriptions and rarely uses
410
+ // them proactively (capability != usage).
411
+ const appendedPrompt = [agent.systemPrompt.trim(), ctxEnabled ? CTX_TOOLS_GUIDANCE : ""]
412
+ .filter(Boolean)
413
+ .join("\n\n");
414
+ if (appendedPrompt) {
336
415
  // Allocate the temp dir + path BEFORE any fallible I/O so that if
337
416
  // writeFile throws, tmpPromptDir/tmpPromptPath are already set and
338
417
  // the finally block can clean up the directory (F-004).
339
418
  tmpPromptDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-taskflow-"));
340
419
  const safeName = agent.name.replace(/[^\w.-]+/g, "_");
341
420
  tmpPromptPath = path.join(tmpPromptDir, `prompt-${safeName}.md`);
342
- await writePromptToTempFile(tmpPromptPath, agent.systemPrompt);
421
+ await writePromptToTempFile(tmpPromptPath, appendedPrompt);
343
422
  args.push("--append-system-prompt", tmpPromptPath);
344
423
  }
345
424
  args.push(`Task: ${task}`);
346
425
 
426
+ // Shared Context Tree opt-in: load THIS extension into the subagent so it
427
+ // can register the ctx_* tools, and pass the blackboard dir + node id via
428
+ // env. `--extension` is the explicit, self-documenting fallback that does
429
+ // not rely on the subagent auto-discovering user/project extensions in
430
+ // `-p` mode. The env vars drive the dual-identity branch in index.ts.
431
+ const ctxEnv: Record<string, string> = {};
432
+ if (opts.ctxDir && opts.nodeId) {
433
+ const selfPath = ctxExtensionPath();
434
+ if (selfPath) args.push("--extension", selfPath);
435
+ ctxEnv.PI_TASKFLOW_CTX_DIR = opts.ctxDir;
436
+ ctxEnv.PI_TASKFLOW_NODE_ID = opts.nodeId;
437
+ }
438
+
347
439
  let wasAborted = false;
348
440
  let idleTimedOut = false;
349
441
  let killedBySignal: string | undefined;
@@ -353,6 +445,7 @@ export async function runAgentTask(
353
445
  cwd: opts.cwd ?? defaultCwd,
354
446
  shell: false,
355
447
  stdio: ["ignore", "pipe", "pipe"],
448
+ env: { ...process.env, ...ctxEnv },
356
449
  });
357
450
  if (proc.pid) activeChildren.add(proc.pid);
358
451
  let buffer = "";
@@ -40,6 +40,19 @@ function isResumable(r: RunState): boolean {
40
40
  return r.status === "paused" || r.status === "failed";
41
41
  }
42
42
 
43
+ /** Detect whether a refreshed run list differs from the current one in any way
44
+ * the panel renders (status, updatedAt, phase progress, membership). */
45
+ function hasChanged(prev: RunState[], next: RunState[]): boolean {
46
+ if (prev.length !== next.length) return true;
47
+ const byId = new Map(prev.map((r) => [r.runId, r]));
48
+ for (const n of next) {
49
+ const p = byId.get(n.runId);
50
+ if (!p) return true;
51
+ if (p.status !== n.status || p.updatedAt !== n.updatedAt) return true;
52
+ }
53
+ return false;
54
+ }
55
+
43
56
  export class RunHistoryComponent {
44
57
  private runs: RunState[];
45
58
  private theme: Theme;
@@ -48,14 +61,62 @@ export class RunHistoryComponent {
48
61
  private mode: "list" | "detail" = "list";
49
62
  private cachedWidth?: number;
50
63
  private cachedLines?: string[];
64
+ /** Live-refresh wiring: re-read run state from disk while the panel is open
65
+ * so background (detached) runs show live progress without reopening. */
66
+ private timer?: ReturnType<typeof setInterval>;
67
+ private refresh?: () => RunState[];
68
+ private requestRender?: () => void;
51
69
 
52
- constructor(runs: RunState[], theme: Theme, onDone: (result?: RunHistoryResult) => void) {
70
+ constructor(
71
+ runs: RunState[],
72
+ theme: Theme,
73
+ onDone: (result?: RunHistoryResult) => void,
74
+ /** Optional live-refresh hooks. When both are provided the panel polls
75
+ * `refresh()` on an interval and calls `requestRender()` if anything changed. */
76
+ live?: { refresh: () => RunState[]; requestRender: () => void; intervalMs?: number },
77
+ ) {
53
78
  if (!runs.length) {
54
79
  throw new Error("RunHistoryComponent requires at least one run");
55
80
  }
56
81
  this.runs = runs;
57
82
  this.theme = theme;
58
83
  this.onDone = onDone;
84
+ if (live) {
85
+ this.refresh = live.refresh;
86
+ this.requestRender = live.requestRender;
87
+ const intervalMs = Math.max(250, live.intervalMs ?? 1000);
88
+ this.timer = setInterval(() => this.poll(), intervalMs);
89
+ // Don't keep the event loop alive just for the panel refresh.
90
+ (this.timer as { unref?: () => void }).unref?.();
91
+ }
92
+ }
93
+
94
+ /** Re-read run state; if anything changed, refresh the cached render. */
95
+ private poll(): void {
96
+ if (!this.refresh) return;
97
+ let next: RunState[];
98
+ try {
99
+ next = this.refresh();
100
+ } catch {
101
+ return; // transient read/lock error — try again next tick
102
+ }
103
+ if (!next.length) return;
104
+ if (!hasChanged(this.runs, next)) return;
105
+ // Preserve the user's selection by runId across refreshes.
106
+ const selectedId = this.runs[this.selected]?.runId;
107
+ this.runs = next;
108
+ const idx = next.findIndex((r) => r.runId === selectedId);
109
+ this.selected = idx >= 0 ? idx : Math.min(this.selected, next.length - 1);
110
+ this.invalidate();
111
+ this.requestRender?.();
112
+ }
113
+
114
+ /** Stop the refresh timer when the panel closes. */
115
+ dispose(): void {
116
+ if (this.timer) {
117
+ clearInterval(this.timer);
118
+ this.timer = undefined;
119
+ }
59
120
  }
60
121
 
61
122
  handleInput(data: string): void {
@@ -104,7 +165,8 @@ export class RunHistoryComponent {
104
165
  for (const l of renderProgress(run, th).split("\n")) lines.push(truncateToWidth(l, width));
105
166
  lines.push("");
106
167
  const hint = isResumable(run) ? "Esc back · r resume" : "Esc back";
107
- lines.push(truncateToWidth(` ${th.fg("dim", hint)}`, width));
168
+ const liveTag = this.timer && run.status === "running" ? th.fg("success", " ● live") : "";
169
+ lines.push(truncateToWidth(` ${th.fg("dim", hint)}${liveTag}`, width));
108
170
  lines.push("");
109
171
  this.cachedWidth = width;
110
172
  this.cachedLines = lines;
@@ -129,7 +191,11 @@ export class RunHistoryComponent {
129
191
  });
130
192
 
131
193
  lines.push("");
132
- lines.push(truncateToWidth(` ${th.fg("dim", "↑↓ select · Enter details · r resume · q close")}`, width));
194
+ const anyRunning = this.runs.some((r) => r.status === "running");
195
+ const liveHint = this.timer && anyRunning ? th.fg("success", " ● live") : "";
196
+ lines.push(
197
+ truncateToWidth(` ${th.fg("dim", "↑↓ select · Enter details · r resume · q close")}${liveHint}`, width),
198
+ );
133
199
  lines.push("");
134
200
 
135
201
  this.cachedWidth = width;