@oh-my-pi/pi-tui 18.3.0 → 18.3.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/THIRD-PARTY-NOTICES.txt +2 -2
  3. package/dist/types/chat/read-tool-group.d.ts +3 -3
  4. package/dist/types/chat/user-message.d.ts +4 -1
  5. package/dist/types/components/editor.d.ts +0 -2
  6. package/dist/types/components/input.d.ts +16 -0
  7. package/dist/types/overlays/btw-history-panel.d.ts +4 -1
  8. package/dist/types/overlays/rewind-selector.d.ts +4 -2
  9. package/dist/types/overlays/settings-defs.d.ts +5 -0
  10. package/dist/types/prompt/composer.d.ts +12 -1
  11. package/dist/types/prompt/custom-editor.d.ts +5 -26
  12. package/dist/types/space-hold.d.ts +45 -0
  13. package/dist/types/status-line/host.d.ts +2 -0
  14. package/dist/types/tools/cfg-render.d.ts +37 -0
  15. package/dist/types/tools/cfg-url.d.ts +16 -0
  16. package/dist/types/tools/find.d.ts +2 -2
  17. package/dist/types/tools/output-meta.d.ts +2 -0
  18. package/dist/types/tools/proc-render.d.ts +1 -8
  19. package/dist/types/tools/read.d.ts +14 -3
  20. package/dist/types/tools/result-card.d.ts +20 -0
  21. package/dist/types/tools/url-scheme-host.d.ts +29 -0
  22. package/dist/types/tools/write.d.ts +2 -0
  23. package/dist/types/tools/xdev.d.ts +9 -1
  24. package/package.json +9 -9
  25. package/src/chat/chat-transcript-builder.ts +5 -1
  26. package/src/chat/read-tool-group.ts +8 -9
  27. package/src/chat/tool-execution.ts +7 -5
  28. package/src/chat/user-message.ts +16 -6
  29. package/src/components/editor.ts +2 -4
  30. package/src/components/input.ts +103 -30
  31. package/src/components/markdown.ts +18 -6
  32. package/src/overlays/annotation-overlay.ts +0 -1
  33. package/src/overlays/btw-history-panel.ts +4 -0
  34. package/src/overlays/model-hub.ts +18 -6
  35. package/src/overlays/rewind-selector.ts +186 -12
  36. package/src/overlays/settings-defs.ts +6 -0
  37. package/src/overlays/settings-selector.ts +4 -3
  38. package/src/prompt/composer.ts +83 -56
  39. package/src/prompt/custom-editor.ts +15 -137
  40. package/src/prompt/tips.txt +1 -0
  41. package/src/space-hold.ts +143 -0
  42. package/src/status-line/component.ts +6 -0
  43. package/src/status-line/host.ts +2 -0
  44. package/src/status-line/segments.ts +4 -0
  45. package/src/tools/cfg-render.ts +117 -0
  46. package/src/tools/cfg-url.ts +30 -0
  47. package/src/tools/find.ts +7 -6
  48. package/src/tools/output-meta.ts +2 -0
  49. package/src/tools/proc-render.ts +2 -26
  50. package/src/tools/read.ts +109 -68
  51. package/src/tools/result-card.ts +36 -0
  52. package/src/tools/url-scheme-host.ts +42 -0
  53. package/src/tools/write.ts +109 -66
  54. package/src/tools/xdev.ts +9 -1
  55. package/dist/types/chat/read-target.d.ts +0 -4
  56. package/src/chat/read-target.ts +0 -7
@@ -25,4 +25,5 @@ No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm
25
25
  Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice
26
26
  Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y)
27
27
  Press shift+tab to cycle through reasoning effort levels
28
+ omp can reconfigure and extend itself — ask any questions you have!
28
29
  Lint/type errors piling up? `omp cleanse` (or /cleanse right here) hunts project diagnostics and fixes them with parallel subagents — esc cancels
@@ -0,0 +1,143 @@
1
+ /** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
2
+ * deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
3
+ export const SPACE_REPEAT_MAX_GAP_MS = 120;
4
+ /** Two consecutive inter-space gaps are "mechanical" (machine-driven auto-repeat) when both are
5
+ * within {@link SPACE_REPEAT_MAX_GAP_MS} and differ by no more than this — an absolute jitter floor
6
+ * or, for slower repeat rates, {@link SPACE_REPEAT_JITTER_RATIO} of the smaller gap. OS key-repeat
7
+ * is metronomic; a human smashing the bar is fast but irregular, so its deltas never stay this
8
+ * steady. */
9
+ export const SPACE_REPEAT_JITTER_MS = 18;
10
+ export const SPACE_REPEAT_JITTER_RATIO = 0.35;
11
+ /** Consecutive mechanical (fast + steady) deltas that confirm the space bar is held and start
12
+ * recording. Needs a sustained metronomic cadence, so jittery smashing and deliberate taps never
13
+ * reach it. */
14
+ export const SPACE_HOLD_MECHANICAL_RUN = 2;
15
+ /** Idle gap (ms) after the last repeated space that counts as the space bar being released, ending
16
+ * the push-to-talk recording. Must comfortably exceed the OS key-repeat interval. */
17
+ export const SPACE_HOLD_RELEASE_MS = 250;
18
+
19
+ /** Whether two consecutive inter-space gaps look machine-driven: both within the auto-repeat band
20
+ * and steady enough (small absolute or proportional difference). OS key-repeat is metronomic, so
21
+ * its successive deltas match closely; human smashing is fast but irregular and deliberate taps are
22
+ * too slow, so neither passes. */
23
+ function gapsAreMechanical(gap: number, prevGap: number): boolean {
24
+ if (gap > SPACE_REPEAT_MAX_GAP_MS || prevGap > SPACE_REPEAT_MAX_GAP_MS) return false;
25
+ const tolerance = Math.max(SPACE_REPEAT_JITTER_MS, Math.min(gap, prevGap) * SPACE_REPEAT_JITTER_RATIO);
26
+ return Math.abs(gap - prevGap) <= tolerance;
27
+ }
28
+
29
+ /** What a recognized space-bar hold drives — the push-to-talk start and stop. */
30
+ export interface SpaceHoldHandler {
31
+ /** Gate for the gesture. Returns false to keep the space bar inserting spaces normally, so
32
+ * disabling push-to-talk restores plain space behavior. */
33
+ enabled(): boolean;
34
+ /** A sustained hold was recognized. The optimistically-typed spaces have already been deleted. */
35
+ onStart(): void;
36
+ /** The held space bar was released (an idle gap with no further repeated spaces, or any other key). */
37
+ onEnd(): void;
38
+ }
39
+
40
+ /** What the host must do with a keypress after {@link SpaceHoldGesture.process} has seen it:
41
+ * `"pass"` — handle it normally; `"type"` — insert the space now (it is tracked back out if the run
42
+ * turns into a hold); `"swallow"` — drop it, the gesture consumed it. */
43
+ export type SpaceHoldStep = "pass" | "type" | "swallow";
44
+
45
+ /** Space-hold push-to-talk state machine. A held space bar emits OS auto-repeat: a *steady* stream
46
+ * of spaces at a fixed fast interval. We watch the inter-space deltas and only recognize a hold once
47
+ * {@link SPACE_HOLD_MECHANICAL_RUN} consecutive deltas are "mechanical" — both auto-repeat-fast and
48
+ * near-identical (see {@link gapsAreMechanical}). Smashing the bar is fast but jittery and deliberate
49
+ * taps are too slow, so neither escalates and both keep typing real spaces; the few spaces typed
50
+ * before a real hold is recognized are tracked back out of the host input. */
51
+ export class SpaceHoldGesture {
52
+ /** Unset (the default) keeps the space bar typing spaces. */
53
+ handler: SpaceHoldHandler | undefined;
54
+ /** Deletes the given number of characters before the host input's cursor. */
55
+ readonly #deleteTyped: (count: number) => void;
56
+ /** Host input state that rules the gesture out, e.g. an editor mode where space is not text. */
57
+ readonly #allowed: () => boolean;
58
+ /** Spaces the host typed in the current run; tracked back out when a hold is recognized. */
59
+ #typed = 0;
60
+ /** Consecutive "mechanical" deltas (fast + steady); a sustained run of these confirms a held bar. */
61
+ #mechanicalRun = 0;
62
+ /** Inter-space gap (ms) of the previous space pair, compared against the next to judge steadiness. */
63
+ #prevGap: number | undefined;
64
+ /** Monotonic timestamp (ms) of the last space, to measure the gap to the next one. */
65
+ #lastSpaceAt = Number.NEGATIVE_INFINITY;
66
+ /** True while a recognized space-hold push-to-talk recording is in progress. */
67
+ #active = false;
68
+ /** Idle timer that ends the hold once repeated spaces stop arriving. */
69
+ #releaseTimer: NodeJS.Timeout | undefined;
70
+
71
+ constructor(deleteTyped: (count: number) => void, allowed: () => boolean = () => true) {
72
+ this.#deleteTyped = deleteTyped;
73
+ this.#allowed = allowed;
74
+ }
75
+
76
+ /** Feed one keypress, `isSpace` when it is a plain space bar press. */
77
+ process(isSpace: boolean): SpaceHoldStep {
78
+ if (this.#active) {
79
+ if (isSpace) {
80
+ // Auto-repeat while held: swallow it and keep the release timer alive.
81
+ this.#armReleaseTimer();
82
+ return "swallow";
83
+ }
84
+ // Any non-space means the bar was released — stop recording, then let the key through.
85
+ this.#end();
86
+ return "pass";
87
+ }
88
+ if (!isSpace) {
89
+ this.#resetRun();
90
+ return "pass";
91
+ }
92
+ if (!(this.handler?.enabled() && this.#allowed())) return "pass";
93
+ const now = performance.now();
94
+ const gap = now - this.#lastSpaceAt;
95
+ const prevGap = this.#prevGap;
96
+ this.#lastSpaceAt = now;
97
+ this.#prevGap = gap;
98
+ if (prevGap === undefined || !gapsAreMechanical(gap, prevGap)) {
99
+ // First space, a deliberate tap, or jittery smashing: not a steady machine cadence yet, so
100
+ // type a real space and reset the mechanical run.
101
+ this.#mechanicalRun = 0;
102
+ this.#typed++;
103
+ return "type";
104
+ }
105
+ // Steady fast repeat: swallow it. Once the cadence has held for SPACE_HOLD_MECHANICAL_RUN
106
+ // deltas it's a held bar — track back the few pre-burst spaces already typed and start.
107
+ if (++this.#mechanicalRun >= SPACE_HOLD_MECHANICAL_RUN) {
108
+ this.#deleteTyped(this.#typed);
109
+ this.#resetRun();
110
+ this.#active = true;
111
+ this.#armReleaseTimer();
112
+ this.handler?.onStart();
113
+ }
114
+ return "swallow";
115
+ }
116
+
117
+ #resetRun(): void {
118
+ this.#typed = 0;
119
+ this.#mechanicalRun = 0;
120
+ this.#prevGap = undefined;
121
+ this.#lastSpaceAt = Number.NEGATIVE_INFINITY;
122
+ }
123
+
124
+ #armReleaseTimer(): void {
125
+ if (this.#releaseTimer) clearTimeout(this.#releaseTimer);
126
+ this.#releaseTimer = setTimeout(() => {
127
+ this.#releaseTimer = undefined;
128
+ this.#end();
129
+ }, SPACE_HOLD_RELEASE_MS);
130
+ this.#releaseTimer.unref?.();
131
+ }
132
+
133
+ #end(): void {
134
+ if (!this.#active) return;
135
+ this.#active = false;
136
+ this.#resetRun();
137
+ if (this.#releaseTimer) {
138
+ clearTimeout(this.#releaseTimer);
139
+ this.#releaseTimer = undefined;
140
+ }
141
+ this.handler?.onEnd();
142
+ }
143
+ }
@@ -301,6 +301,7 @@ interface StatusLineExternalInputs {
301
301
  isStreaming: boolean | undefined;
302
302
  isAutoThinking: boolean | undefined;
303
303
  isFastModeActive: boolean;
304
+ anthropicSlowModeLabel: string | undefined;
304
305
  compactionSpeculation: unknown;
305
306
  }
306
307
 
@@ -2369,6 +2370,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2369
2370
  isAutoThinking: this.session.isAutoThinking,
2370
2371
  isFastModeActive:
2371
2372
  typeof this.session.isFastModeActive === "function" ? this.session.isFastModeActive() : false,
2373
+ anthropicSlowModeLabel:
2374
+ typeof this.session.getAnthropicSlowModeLabel === "function"
2375
+ ? this.session.getAnthropicSlowModeLabel()
2376
+ : undefined,
2372
2377
  compactionSpeculation: this.session.compactionSpeculation,
2373
2378
  };
2374
2379
  }
@@ -2423,6 +2428,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2423
2428
  left.isStreaming === right.isStreaming &&
2424
2429
  left.isAutoThinking === right.isAutoThinking &&
2425
2430
  left.isFastModeActive === right.isFastModeActive &&
2431
+ left.anthropicSlowModeLabel === right.anthropicSlowModeLabel &&
2426
2432
  left.compactionSpeculation === right.compactionSpeculation
2427
2433
  );
2428
2434
  }
@@ -53,6 +53,8 @@ export interface StatusLineSession {
53
53
  getContextUsage(): { tokens: number; contextWindow: number; percent: number | null } | undefined;
54
54
  autoResolvedThinkingLevel(): string | undefined;
55
55
  isFastModeActive(): boolean;
56
+ /** Anthropic usage-limit label (`limit reached · wrapping up · resets 14:30`, `low priority until 14:30 · 62% left`). */
57
+ getAnthropicSlowModeLabel?(): string | undefined;
56
58
  getPrewalkState?(): unknown;
57
59
  getAsyncJobSnapshot(): { running: readonly { type: string; agentId?: string }[] } | null | undefined;
58
60
  getGoalModeState(): { goal?: { status: string; tokensUsed: number; tokenBudget?: number } } | undefined;
@@ -293,6 +293,10 @@ const modelSegment: StatusLineSegment = {
293
293
  content += accentFg(ctx, "statusLineModel", tail);
294
294
  }
295
295
 
296
+ // Anthropic usage-limit stage (wrap-up allowance or low priority): a
297
+ // warning-colored badge so past-the-limit service is never mistaken for normal.
298
+ const slowModeLabel = ctx.session.getAnthropicSlowModeLabel?.();
299
+ if (slowModeLabel) content += theme.fg("warning", `${theme.sep.dot}${slowModeLabel}`);
296
300
  return { content, visible: true };
297
301
  },
298
302
  };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Transcript renderers for `cfg://` settings reads and writes.
3
+ *
4
+ * Reads show the YAML-ish settings listing the model received, highlighted as
5
+ * YAML. Writes show the `previous → value` change, its scope (session or
6
+ * saved), and whether the user approved it.
7
+ */
8
+ import type { Component } from "../tui";
9
+ import { renderStatusLine } from "../render";
10
+ import {
11
+ cappedHeadLines,
12
+ formatBadge,
13
+ formatErrorDetail,
14
+ formatMoreItems,
15
+ PREVIEW_LIMITS,
16
+ } from "../render/render-utils";
17
+ import { highlightCode, type Theme } from "../theme/theme";
18
+ import { parseCfgUrl } from "./cfg-url";
19
+ import type { RenderResultOptions } from "./renderer";
20
+ import { card, type CardToolResult, firstText, safe } from "./result-card";
21
+
22
+ /** Summary of a `cfg://` read, attached to read tool details. */
23
+ export interface CfgReadDetails {
24
+ /** Dotted setting path or namespace that was read; empty for the whole tree. */
25
+ path: string;
26
+ /** Settings in the listing. */
27
+ count: number;
28
+ /** Listed settings whose effective value differs from the schema default. */
29
+ modified: number;
30
+ }
31
+
32
+ /** How a `cfg://` write ended. */
33
+ export type CfgWriteOutcome = "applied" | "declined" | "unchanged";
34
+
35
+ /** A `cfg://` write, attached to write tool details. Values are display-formatted (credentials redacted). */
36
+ export interface CfgWriteDetails {
37
+ path: string;
38
+ /** Persisted to config.yml rather than scoped to the session. */
39
+ save: boolean;
40
+ previous: string;
41
+ value: string;
42
+ outcome: CfgWriteOutcome;
43
+ /** Effective value after saving when a higher-precedence layer still shadows the saved one. */
44
+ effective?: string;
45
+ }
46
+
47
+ /** Render `read cfg://…`: pending header, or the highlighted listing with counts. */
48
+ export function renderCfgRead(
49
+ url: string,
50
+ result: CardToolResult | undefined,
51
+ details: CfgReadDetails | undefined,
52
+ options: RenderResultOptions,
53
+ theme: Theme,
54
+ ): Component {
55
+ return card((_width, expanded) => {
56
+ const meta: string[] = [];
57
+ if (details) {
58
+ meta.push(details.count === 1 ? "1 setting" : `${details.count} settings`);
59
+ if (details.modified) meta.push(`${details.modified} modified`);
60
+ }
61
+ const header = renderStatusLine(
62
+ {
63
+ icon: result === undefined ? "pending" : result.isError ? "error" : "info",
64
+ title: "Config",
65
+ description: (details?.path ?? parseCfgUrl(url)?.segments.join(".")) || "all settings",
66
+ meta,
67
+ },
68
+ theme,
69
+ );
70
+ if (!result) return [header];
71
+ if (result.isError) return [header, formatErrorDetail(firstText(result) || "Settings read failed.", theme)];
72
+ const body = firstText(result).trimEnd().split("\n");
73
+ const shown = cappedHeadLines(body, expanded ? body.length : PREVIEW_LIMITS.COLLAPSED_ITEMS);
74
+ const lines = [header, ...highlightCode(safe(shown.lines.join("\n")), "yaml", theme).map(line => ` ${line}`)];
75
+ if (shown.hidden) lines.push(theme.fg("dim", ` ${formatMoreItems(shown.hidden, "line")}`));
76
+ return lines;
77
+ }, options);
78
+ }
79
+
80
+ /** Render `write cfg://…[/save]`: the proposed value while pending, then the approved/declined change. */
81
+ export function renderCfgWrite(
82
+ url: string,
83
+ content: string | undefined,
84
+ result: CardToolResult | undefined,
85
+ details: CfgWriteDetails | undefined,
86
+ options: RenderResultOptions,
87
+ theme: Theme,
88
+ ): Component {
89
+ return card(() => {
90
+ const target = parseCfgUrl(url);
91
+ const save = details?.save ?? target?.save ?? false;
92
+ const icon =
93
+ result === undefined
94
+ ? "pending"
95
+ : result.isError
96
+ ? "error"
97
+ : details?.outcome === "declined"
98
+ ? "warning"
99
+ : "success";
100
+ const badges = [formatBadge(save ? "persist" : "session", save ? "accent" : "muted", theme)];
101
+ if (details?.outcome === "declined") badges.push(formatBadge("declined", "warning", theme));
102
+ if (details?.outcome === "unchanged") badges.push(formatBadge("unchanged", "muted", theme));
103
+ const header = `${renderStatusLine({ icon, title: "Config", description: details?.path ?? target?.segments.join(".") }, theme)} ${badges.join(" ")}`;
104
+ if (result?.isError) return [header, formatErrorDetail(firstText(result) || "Settings write failed.", theme)];
105
+ const arrow = theme.fg("dim", "→");
106
+ const next = theme.fg("toolOutput", details?.value ?? content?.trim() ?? "…");
107
+ const lines = [
108
+ header,
109
+ details ? ` ${theme.fg("dim", details.previous)} ${arrow} ${next}` : ` ${arrow} ${next}`,
110
+ ];
111
+ if (details?.effective !== undefined)
112
+ lines.push(
113
+ theme.fg("warning", ` still ${details.effective}: a higher-precedence layer overrides the saved value`),
114
+ );
115
+ return lines;
116
+ }, options);
117
+ }
@@ -0,0 +1,30 @@
1
+ /** Canonical prefix for settings URLs. */
2
+ export const CFG_URL_PREFIX = "cfg://";
3
+
4
+ /** Trailing write segment that persists a `cfg://` change instead of scoping it to the session. */
5
+ export const CFG_SAVE_SEGMENT = "save";
6
+
7
+ /** Setting addressed by a `cfg://` URL. */
8
+ export interface CfgTarget {
9
+ /** Setting path segments; empty for the root. `/` and `.` both separate segments. */
10
+ segments: string[];
11
+ /** Whether the URL ends in the `/save` suffix, which is removed from {@link segments}. */
12
+ save: boolean;
13
+ }
14
+
15
+ /**
16
+ * Parse a `cfg://` URL (`cfg://advisor/enabled`, `cfg://advisor.enabled/save`).
17
+ * Returns `null` for other schemes; query and fragment are ignored.
18
+ */
19
+ export function parseCfgUrl(input: string): CfgTarget | null {
20
+ const trimmed = input.trim();
21
+ if (!trimmed.toLowerCase().startsWith(CFG_URL_PREFIX)) return null;
22
+ const segments = trimmed
23
+ .slice(CFG_URL_PREFIX.length)
24
+ .replace(/[?#].*$/s, "")
25
+ .split(/[/.]/)
26
+ .filter(Boolean);
27
+ const save = segments.length > 1 && segments.at(-1) === CFG_SAVE_SEGMENT;
28
+ if (save) segments.pop();
29
+ return { segments, save };
30
+ }
package/src/tools/find.ts CHANGED
@@ -28,6 +28,7 @@ import type { Theme, ThemeColor } from "../theme/theme";
28
28
  import type { Component } from "../tui";
29
29
  import type { OutputMeta } from "./output-meta";
30
30
  import type { RenderResultOptions, ToolRenderer } from "./renderer";
31
+ import { splitUrlScheme } from "./url-scheme-host";
31
32
 
32
33
  /** A verified line range with its yes-probability and a one-line preview. */
33
34
  export interface FindRange {
@@ -39,7 +40,7 @@ export interface FindRange {
39
40
 
40
41
  /** A file whose verified passages cleared the threshold; `ranges` are merged positive spans, strongest first. */
41
42
  export interface FindHit {
42
- /** Display path relative to {@link FindToolDetails.cwd}, or an `omp://` doc URL for docs scopes. */
43
+ /** Display path relative to {@link FindToolDetails.cwd}, or an internal URL under URL scopes. */
43
44
  rel: string;
44
45
  /** Filename judgment, when the name batch answered. */
45
46
  nameScore?: number;
@@ -85,7 +86,7 @@ export interface FindToolDetails {
85
86
  elapsedMs: number;
86
87
  /** Session cwd; hit paths are relative to it. */
87
88
  cwd: string;
88
- /** Display form of the searched directory when narrower than cwd. */
89
+ /** Display form of the searched directory or file when narrower than cwd. */
89
90
  scopePath?: string;
90
91
  meta?: OutputMeta;
91
92
  }
@@ -124,11 +125,11 @@ function gauge(p: number, theme: Theme): string {
124
125
  }
125
126
 
126
127
  function renderHit(hit: FindHit, rangeLimit: number, cwd: string | undefined, theme: Theme): string[] {
127
- // `omp://` hits are virtual docs, not files under `cwd`: link the URL
128
- // itself instead of joining it onto a filesystem base.
129
- const isOmpHit = /^omp:\/\//i.test(hit.rel);
128
+ // `scheme://` hits (e.g. virtual docs) are not files under `cwd`: link the
129
+ // URL itself instead of joining it onto a filesystem base.
130
+ const isUrlHit = splitUrlScheme(hit.rel) !== undefined;
130
131
  const link = (text: string, line?: number): string => {
131
- if (isOmpHit) return uriHyperlink(line === undefined ? hit.rel : `${hit.rel}:${line}`, text);
132
+ if (isUrlHit) return uriHyperlink(line === undefined ? hit.rel : `${hit.rel}:${line}`, text);
132
133
  const absPath = cwd === undefined ? undefined : path.join(cwd, hit.rel);
133
134
  return absPath === undefined ? text : fileHyperlink(absPath, text, { line });
134
135
  };
@@ -72,6 +72,8 @@ export interface OutputMeta {
72
72
  /** Capture failure of this output itself; aggregate reports keep source failures on their entries. */
73
73
  artifactError?: OutputArtifactError;
74
74
  source?: SourceMeta;
75
+ /** The output is a bounded page of session artifact storage its `source` re-reads with line selectors; spill-to-artifact skips it. */
76
+ pagedSource?: true;
75
77
  diagnostics?: DiagnosticMeta;
76
78
  limits?: LimitsMeta;
77
79
  }
@@ -1,5 +1,5 @@
1
1
  import type { Component } from "../tui";
2
- import { createCachedComponent, Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../render";
2
+ import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../render";
3
3
  import {
4
4
  cappedHeadLines,
5
5
  formatBadge,
@@ -8,7 +8,6 @@ import {
8
8
  formatStatusIcon,
9
9
  PREVIEW_LIMITS,
10
10
  TRUNCATE_LENGTHS,
11
- replaceTabs,
12
11
  type ToolUIColor,
13
12
  } from "../render/render-utils";
14
13
  import type { Theme } from "../theme/theme";
@@ -17,6 +16,7 @@ import type { AgentActivitySnapshot, CoordinationDetails, JobSnapshot } from "./
17
16
  import type { IrcDeliveryReceipt } from "./irc";
18
17
  import type { DaemonSnapshot } from "./daemon";
19
18
  import { styleTerminalRow } from "./terminal-output";
19
+ import { card, type CardToolResult as ToolResult, firstText, safe } from "./result-card";
20
20
 
21
21
  export interface ProcReadDetails {
22
22
  jobs?: JobSnapshot[];
@@ -35,30 +35,6 @@ export type ProcWriteDetails =
35
35
  /** Process operation selected by a write URL, independent of its content. */
36
36
  export type ProcWriteAction = "stdin" | "mode" | "kill";
37
37
 
38
- interface ToolResult {
39
- content: Array<{ type: string; text?: string }>;
40
- isError?: boolean;
41
- }
42
-
43
- function firstText(result: ToolResult): string {
44
- return result.content.find(item => item.type === "text")?.text ?? "";
45
- }
46
-
47
- function safe(value: string): string {
48
- return replaceTabs(value).replace(/\r/g, "");
49
- }
50
-
51
- function card(lines: (width: number, expanded: boolean) => string[], options: RenderResultOptions): Component {
52
- return createCachedComponent(
53
- () => Boolean(options.expanded),
54
- (width, expanded) =>
55
- lines(width, expanded)
56
- .flatMap(line => line.split("\n"))
57
- .map(line => truncateToWidth(safe(line), width, Ellipsis.Unicode)),
58
- { paddingX: 1 },
59
- );
60
- }
61
-
62
38
  function preview(body: string, expanded: boolean, theme: Theme, tone: "dim" | "toolOutput" = "dim"): string[] {
63
39
  if (!body.trim()) return [];
64
40
  const limit = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;