@tt-a1i/openpi 0.3.1 → 0.5.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.
Files changed (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -0,0 +1,93 @@
1
+ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import type { EditorComponent } from "@earendil-works/pi-tui";
3
+ import {
4
+ BelowEditorNavigationEditor,
5
+ BelowEditorStripState,
6
+ } from "../../shared/below-editor-navigation.ts";
7
+ import { capabilitiesRequestedByPrompt } from "../../shared/capability-intent.ts";
8
+
9
+ const DELEGATE_NAMES = /\bsubagents?\b|子代理/giu;
10
+ const WORKFLOW_NAMES = /\bworkflows?\b|工作流/giu;
11
+ const FOREGROUND_RESET = "\u001b[39m";
12
+
13
+ interface CapabilityKeywordColorOptions {
14
+ readonly colorMode: "truecolor" | "256color";
15
+ readonly light: boolean;
16
+ }
17
+
18
+ export function isLightNamedTheme(name: string | undefined) {
19
+ return name !== undefined && /(?:^|[-_])light(?:$|[-_])/iu.test(name);
20
+ }
21
+
22
+ /**
23
+ * Claude-style lavender keyword color. The light variant preserves readable
24
+ * contrast instead of mechanically reusing the bright dark-terminal swatch.
25
+ */
26
+ export function colorCapabilityKeyword(
27
+ text: string,
28
+ options: CapabilityKeywordColorOptions,
29
+ ) {
30
+ const start = options.light
31
+ ? options.colorMode === "truecolor"
32
+ ? "\u001b[38;2;130;80;223m"
33
+ : "\u001b[38;5;98m"
34
+ : options.colorMode === "truecolor"
35
+ ? "\u001b[38;2;210;168;255m"
36
+ : "\u001b[38;5;183m";
37
+ return `${start}${text}${FOREGROUND_RESET}`;
38
+ }
39
+
40
+ export function highlightCapabilityNames(
41
+ line: string,
42
+ capabilities: readonly string[],
43
+ highlight: (text: string) => string,
44
+ ) {
45
+ let result = line;
46
+ if (capabilities.includes("delegate")) {
47
+ result = result.replace(DELEGATE_NAMES, (match) => highlight(match));
48
+ }
49
+ if (capabilities.includes("workflow")) {
50
+ result = result.replace(WORKFLOW_NAMES, (match) => highlight(match));
51
+ }
52
+ return result;
53
+ }
54
+
55
+ /**
56
+ * Transparent, pre-submit feedback for capability intent. It colours only
57
+ * names whose capability the shared classifier would load after submission;
58
+ * it never changes editor text, Session history, or model context.
59
+ */
60
+ export class CapabilityIntentHighlightEditor extends BelowEditorNavigationEditor {
61
+ private readonly highlight: (text: string) => string;
62
+
63
+ constructor(
64
+ base: EditorComponent,
65
+ keybindings: KeybindingsManager,
66
+ highlight: (text: string) => string,
67
+ ) {
68
+ super(
69
+ base,
70
+ keybindings,
71
+ new BelowEditorStripState(),
72
+ () => false,
73
+ () => undefined,
74
+ () => undefined,
75
+ );
76
+ this.highlight = highlight;
77
+ }
78
+
79
+ override render(width: number) {
80
+ const capabilities = capabilitiesRequestedByPrompt(this.getText());
81
+ if (
82
+ !capabilities.includes("delegate") &&
83
+ !capabilities.includes("workflow")
84
+ ) {
85
+ return super.render(width);
86
+ }
87
+ return super
88
+ .render(width)
89
+ .map((line) =>
90
+ highlightCapabilityNames(line, capabilities, this.highlight),
91
+ );
92
+ }
93
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionContext,
4
+ type KeybindingsManager,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Key, matchesKey, type EditorComponent } from "@earendil-works/pi-tui";
7
+ import {
8
+ BelowEditorNavigationEditor,
9
+ BelowEditorStripState,
10
+ } from "../shared/below-editor-navigation.ts";
11
+ import {
12
+ registerEditorLayer,
13
+ removeEditorLayer,
14
+ } from "../shared/editor-layers.ts";
15
+
16
+ export function shouldClearContext(
17
+ data: string,
18
+ editorText: string,
19
+ isIdle: boolean,
20
+ ) {
21
+ return isIdle && editorText.length === 0 && matchesKey(data, Key.ctrl("c"));
22
+ }
23
+
24
+ export class ClearContextEditor extends BelowEditorNavigationEditor {
25
+ private readonly isIdle: () => boolean;
26
+
27
+ constructor(
28
+ base: EditorComponent,
29
+ keybindings: KeybindingsManager,
30
+ isIdle: () => boolean,
31
+ ) {
32
+ super(
33
+ base,
34
+ keybindings,
35
+ new BelowEditorStripState(),
36
+ () => false,
37
+ () => undefined,
38
+ () => undefined,
39
+ );
40
+ this.isIdle = isIdle;
41
+ }
42
+
43
+ override handleInput(data: string) {
44
+ if (shouldClearContext(data, this.getText(), this.isIdle())) {
45
+ // Route through Pi's built-in /new command so session replacement keeps
46
+ // Pi's persistence, cleanup, and transcript lifecycle as the source of truth.
47
+ this.setText("/new");
48
+ this.onSubmit?.("/new");
49
+ return;
50
+ }
51
+
52
+ super.handleInput(data);
53
+ }
54
+ }
55
+
56
+ function installClearContextShortcut(pi: ExtensionAPI, ctx: ExtensionContext) {
57
+ if (ctx.mode !== "tui") return () => {};
58
+
59
+ registerEditorLayer(pi, ctx, {
60
+ id: "clear-context",
61
+ order: 100,
62
+ wrap: (base, _tui, _theme, keybindings) =>
63
+ new ClearContextEditor(base, keybindings, () => ctx.isIdle()),
64
+ });
65
+
66
+ return () => removeEditorLayer(pi, "clear-context");
67
+ }
68
+
69
+ export default function clearContext(pi: ExtensionAPI) {
70
+ let removeShortcut = () => {};
71
+
72
+ pi.on("session_start", (_event, ctx) => {
73
+ removeShortcut();
74
+ removeShortcut = installClearContextShortcut(pi, ctx);
75
+ });
76
+
77
+ pi.on("session_shutdown", () => {
78
+ removeShortcut();
79
+ removeShortcut = () => {};
80
+ });
81
+ }
82
+
83
+ export { installClearContextShortcut };
@@ -11,6 +11,9 @@ import {
11
11
 
12
12
  export const MIN_CONTEXT_PIVOT_TOKENS = 30_000;
13
13
  const STATUS_KEY = "context-pivot";
14
+ const NOTHING_TO_COMPACT_ERROR = "Nothing to compact (session too small)";
15
+ const NO_DISCARDABLE_HISTORY_MESSAGE =
16
+ "Context pivot could not run: this session has no discardable history to compact. Continue in the current session, or use /sessions to choose another session; to begin cleanly, start a new Session in Pi.";
14
17
 
15
18
  interface PendingPivot {
16
19
  brief: string;
@@ -57,6 +60,13 @@ export function buildPivotSummary(brief: string): string {
57
60
  ].join("\n");
58
61
  }
59
62
 
63
+ function formatContextPivotError(error: unknown): string {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ return message === NOTHING_TO_COMPACT_ERROR
66
+ ? NO_DISCARDABLE_HISTORY_MESSAGE
67
+ : `Context pivot failed: ${message}`;
68
+ }
69
+
60
70
  function impossibleKeptId(entries: readonly SessionEntry[]) {
61
71
  return `${entries.at(-1)?.id ?? "context-pivot"}-context-pivot-cut`;
62
72
  }
@@ -73,7 +83,7 @@ function validateBrief(brief: string, ctx: ExtensionContext) {
73
83
  }
74
84
  if (tokens < MIN_CONTEXT_PIVOT_TOKENS) {
75
85
  throw new Error(
76
- `Context is only ${Math.round(tokens).toLocaleString()} tokens; use context_pivot once context reaches at least ${MIN_CONTEXT_PIVOT_TOKENS.toLocaleString()} tokens, or /handoff for a genuinely new session.`,
86
+ `Context is only ${Math.round(tokens).toLocaleString()} tokens; use context_pivot once context reaches at least ${MIN_CONTEXT_PIVOT_TOKENS.toLocaleString()} tokens, or use /sessions to browse or switch an existing session; start a new Session in Pi when a clean session is needed.`,
77
87
  );
78
88
  }
79
89
  }
@@ -127,7 +137,7 @@ export default function contextPivot(pi: ExtensionAPI) {
127
137
  name: "context_pivot",
128
138
  label: "Context Pivot",
129
139
  description:
130
- "Deliberately replace a long, noisy active context with a concise brief for the next phase while staying in the same Pi session. Use once context is at least 30k tokens and the work is moving between phases such as research → implementation or implementation → review; below 30k it is rejected. Use /handoff instead for a genuinely new session.",
140
+ "Deliberately replace a long, noisy active context with a concise brief for the next phase while staying in the same Pi session. Use once context is at least 30k tokens and the work is moving between phases such as research → implementation or implementation → review; below 30k it is rejected. Use /sessions to browse or switch an existing session; start a new Session in Pi when a genuinely new session is needed.",
131
141
  promptSnippet:
132
142
  "Compress a long current session into a clean brief before changing phase",
133
143
  promptGuidelines: [
@@ -176,11 +186,11 @@ export default function contextPivot(pi: ExtensionAPI) {
176
186
  onError: (error) => {
177
187
  if (pivotGeneration === generation) pending = undefined;
178
188
  clear();
189
+ const message = formatContextPivotError(error);
179
190
  if (ctx.hasUI) {
180
- ctx.ui.notify(
181
- `Context pivot failed: ${error instanceof Error ? error.message : String(error)}`,
182
- "error",
183
- );
191
+ ctx.ui.notify(message, "error");
192
+ } else {
193
+ console.error(message);
184
194
  }
185
195
  },
186
196
  });
@@ -81,8 +81,14 @@ export function parseCronCommand(raw: string): ParsedCronCommand {
81
81
  error: `Minimum interval is ${MIN_INTERVAL_MS / 1000}s (the scheduler polls about that often).`,
82
82
  };
83
83
  }
84
- const prompt = schedule[3].trim().slice(0, CRON_PROMPT_MAX_CHARS);
84
+ const prompt = schedule[3].trim();
85
85
  if (!prompt) return { action: "help", error: "Provide a prompt to run." };
86
+ if (prompt.length > CRON_PROMPT_MAX_CHARS) {
87
+ return {
88
+ action: "help",
89
+ error: `Prompt is too long. Maximum is ${CRON_PROMPT_MAX_CHARS} characters.`,
90
+ };
91
+ }
86
92
  return {
87
93
  action: "add",
88
94
  intervalMs,
@@ -1,88 +1,28 @@
1
1
  import {
2
2
  createBashToolDefinition,
3
3
  createEditToolDefinition,
4
+ createFindToolDefinition,
5
+ createGrepToolDefinition,
6
+ createLsToolDefinition,
7
+ createReadToolDefinition,
4
8
  createWriteToolDefinition,
5
9
  type ExtensionAPI,
6
10
  type ToolDefinition,
7
11
  } from "@earendil-works/pi-coding-agent";
12
+ import type { TSchema } from "typebox";
8
13
  import { loadSetupConfig } from "../shared/setup-config.ts";
9
- import {
10
- compactBashRenderedComponent,
11
- compactRenderedComponent,
12
- singleLineRenderedComponent,
13
- } from "./render.ts";
14
-
15
- function withCompactCallRenderer(
16
- definition: ToolDefinition<any, any, any>,
17
- ): ToolDefinition<any, any, any> {
18
- const renderCall = definition.renderCall;
19
- if (!renderCall) return definition;
20
- return {
21
- ...definition,
22
- renderCall(args, theme, context) {
23
- const component = renderCall(args, theme, context);
24
- if (
25
- context.expanded ||
26
- loadSetupConfig().ui.fileMutationDisplay === "full"
27
- ) {
28
- return component;
29
- }
30
- // While the model is still streaming a large Write/Edit payload, a
31
- // syntax-highlighted preview changes on nearly every token and makes the
32
- // entire tool block flash. Keep that phase to a stable one-line header;
33
- // reveal the bounded preview once arguments are complete.
34
- if (!context.argsComplete) {
35
- return singleLineRenderedComponent(component, theme);
36
- }
37
- const background = context.isPartial
38
- ? (text: string) => theme.bg("toolPendingBg", text)
39
- : context.isError
40
- ? (text: string) => theme.bg("toolErrorBg", text)
41
- : (text: string) => theme.bg("toolSuccessBg", text);
42
- return compactRenderedComponent(component, theme, undefined, background);
43
- },
44
- };
45
- }
14
+ import { withActivityRenderer } from "./render.ts";
46
15
 
47
- function withCompactBashRenderer(
48
- definition: ToolDefinition<any, any, any>,
49
- ): ToolDefinition<any, any, any> {
50
- const renderCall = definition.renderCall;
51
- const renderResult = definition.renderResult;
52
- if (!renderCall || !renderResult) return definition;
53
- return {
54
- ...definition,
55
- renderCall(args, theme, context) {
56
- // A compact wrapper is not the native Text component expected through
57
- // lastComponent, so rebuild the cheap call renderer on each update.
58
- const component = renderCall(args, theme, {
59
- ...context,
60
- lastComponent: undefined,
61
- });
62
- if (context.expanded || loadSetupConfig().ui.bashToolDisplay === "full") {
63
- return component;
64
- }
65
- return singleLineRenderedComponent(component, theme);
66
- },
67
- renderResult(result, options, theme, context) {
68
- // Bash streams partial results. Never hand our wrapper back to Pi's
69
- // native BashResultRenderComponent updater.
70
- const component = renderResult(result, options, theme, {
71
- ...context,
72
- lastComponent: undefined,
73
- });
74
- if (options.expanded || loadSetupConfig().ui.bashToolDisplay === "full") {
75
- return component;
76
- }
77
- return compactBashRenderedComponent(component, theme);
78
- },
79
- };
16
+ function compact<TParams extends TSchema, TDetails, TState>(
17
+ definition: ToolDefinition<TParams, TDetails, TState>,
18
+ enabled: boolean,
19
+ ) {
20
+ return enabled ? withActivityRenderer(definition) : definition;
80
21
  }
81
22
 
82
23
  /**
83
- * Override only the TUI renderers. The wrapped definitions are Pi's native
84
- * Bash/Write/Edit tools, so schemas, execution, mutation queues, diffs, and
85
- * errors stay on the upstream implementation.
24
+ * Override only Pi's TUI projection. Every wrapped definition retains its
25
+ * native schema, prompt metadata, execute function, result, and details.
86
26
  */
87
27
  export default function fileMutationDisplay(pi: ExtensionAPI) {
88
28
  pi.on("session_start", (_event, ctx) => {
@@ -96,10 +36,28 @@ export default function fileMutationDisplay(pi: ExtensionAPI) {
96
36
  display.fileMutationDisplay === "full",
97
37
  );
98
38
  }
99
- pi.registerTool(withCompactBashRenderer(createBashToolDefinition(ctx.cwd)));
39
+
40
+ pi.registerTool(
41
+ compact(
42
+ createBashToolDefinition(ctx.cwd),
43
+ display.bashToolDisplay !== "full",
44
+ ),
45
+ );
46
+ pi.registerTool(
47
+ compact(
48
+ createWriteToolDefinition(ctx.cwd),
49
+ display.fileMutationDisplay !== "full",
50
+ ),
51
+ );
100
52
  pi.registerTool(
101
- withCompactCallRenderer(createWriteToolDefinition(ctx.cwd)),
53
+ compact(
54
+ createEditToolDefinition(ctx.cwd),
55
+ display.fileMutationDisplay !== "full",
56
+ ),
102
57
  );
103
- pi.registerTool(withCompactCallRenderer(createEditToolDefinition(ctx.cwd)));
58
+ pi.registerTool(withActivityRenderer(createReadToolDefinition(ctx.cwd)));
59
+ pi.registerTool(withActivityRenderer(createGrepToolDefinition(ctx.cwd)));
60
+ pi.registerTool(withActivityRenderer(createFindToolDefinition(ctx.cwd)));
61
+ pi.registerTool(withActivityRenderer(createLsToolDefinition(ctx.cwd)));
104
62
  });
105
63
  }
@@ -1,107 +1,166 @@
1
- import { stripVTControlCharacters } from "node:util";
2
- import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
3
- import {
4
- truncateToWidth,
5
- visibleWidth,
6
- type Component,
7
- } from "@earendil-works/pi-tui";
1
+ import type {
2
+ AgentToolResult,
3
+ Theme,
4
+ ToolDefinition,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { Component } from "@earendil-works/pi-tui";
7
+ import type { TSchema } from "typebox";
8
+ import { renderPaddedToolActivityLine } from "../shared/tool-activity.ts";
8
9
 
9
- // Native Write/Edit renderings include their title and context rows. Three
10
- // visible rows keep the operation identifiable while making repeated file
11
- // mutations substantially quieter than Claude Code's default preview.
12
- export const FILE_MUTATION_PREVIEW_LINES = 3;
13
- export const BASH_OUTPUT_PREVIEW_LINES = 1;
10
+ type ActivityStatus = "pending" | "success" | "error";
14
11
 
15
- function expandHint(hidden: number) {
16
- return `… ${hidden} more line${hidden === 1 ? "" : "s"} · ${keyHint("app.tools.expand", "to expand")}`;
12
+ type ActivityRenderState<TDetails> = {
13
+ openpiActivity?: {
14
+ result?: AgentToolResult<TDetails>;
15
+ status: ActivityStatus;
16
+ startedAt?: number;
17
+ endedAt?: number;
18
+ interval?: NodeJS.Timeout;
19
+ nativeCallComponent?: Component;
20
+ nativeResultComponent?: Component;
21
+ };
22
+ };
23
+
24
+ const emptyComponent: Component = {
25
+ render: () => [],
26
+ invalidate() {},
27
+ };
28
+
29
+ function textOutput(result: AgentToolResult<unknown> | undefined) {
30
+ return (
31
+ result?.content
32
+ .filter((item) => item.type === "text")
33
+ .map((item) => item.text)
34
+ .join("\n") ?? ""
35
+ );
17
36
  }
18
37
 
19
- /** Keep a long command identifiable without allowing it to wrap for many rows. */
20
- export function singleLineRenderedComponent(
21
- component: Component,
38
+ function activityComponent(
39
+ name: string,
40
+ args: unknown,
41
+ state: NonNullable<ActivityRenderState<unknown>["openpiActivity"]>,
22
42
  theme: Theme,
43
+ cwd: string,
23
44
  ): Component {
24
45
  return {
25
46
  render(width) {
26
- const lines = component.render(width);
27
- if (lines.length <= 1) return lines;
28
- if (width <= 2) return [truncateToWidth(lines[0] ?? "", width, "")];
29
- return [
30
- truncateToWidth(lines[0] ?? "", width - 2, "") + theme.fg("dim", " …"),
31
- ];
32
- },
33
- invalidate() {
34
- component.invalidate();
47
+ const line = renderPaddedToolActivityLine(
48
+ {
49
+ name,
50
+ args,
51
+ output: textOutput(state.result),
52
+ details: state.result?.details,
53
+ status: state.status,
54
+ cwd,
55
+ startedAt: state.startedAt,
56
+ endedAt: state.endedAt,
57
+ },
58
+ theme,
59
+ width,
60
+ );
61
+ return line ? [line] : [];
35
62
  },
63
+ invalidate() {},
36
64
  };
37
65
  }
38
66
 
39
- /** Preserve one output row, warnings, and final timing in compact Bash mode. */
40
- export function compactBashRenderedComponent(
41
- component: Component,
42
- theme: Theme,
43
- maximum = BASH_OUTPUT_PREVIEW_LINES,
44
- ): Component {
67
+ /**
68
+ * Preserve Pi's complete tool definition and execution semantics while
69
+ * replacing only the collapsed operator-facing projection.
70
+ */
71
+ export function withActivityRenderer<TParams extends TSchema, TDetails, TState>(
72
+ definition: ToolDefinition<TParams, TDetails, TState>,
73
+ ): ToolDefinition<TParams, TDetails, TState & ActivityRenderState<TDetails>> {
74
+ const nativeRenderCall = definition.renderCall;
75
+ const nativeRenderResult = definition.renderResult;
45
76
  return {
46
- render(width) {
47
- const rendered = component.render(width);
48
- const visible = rendered.filter(
49
- (line) => stripVTControlCharacters(line).trim().length > 0,
77
+ ...definition,
78
+ renderShell: "self",
79
+ renderCall(args, theme, context) {
80
+ const state = context.state as TState & ActivityRenderState<TDetails>;
81
+ state.openpiActivity ??= { status: "pending" };
82
+ const activity = state.openpiActivity;
83
+ if (context.executionStarted && activity.startedAt === undefined) {
84
+ activity.startedAt = Date.now();
85
+ }
86
+ if (
87
+ context.executionStarted &&
88
+ definition.name === "bash" &&
89
+ activity.status === "pending" &&
90
+ !context.expanded &&
91
+ activity.interval === undefined
92
+ ) {
93
+ activity.interval = setInterval(() => context.invalidate(), 1000);
94
+ activity.interval.unref();
95
+ }
96
+ if (context.expanded && activity.interval) {
97
+ clearInterval(activity.interval);
98
+ activity.interval = undefined;
99
+ }
100
+ if (context.expanded && nativeRenderCall) {
101
+ const nativeContext: Parameters<typeof nativeRenderCall>[2] = {
102
+ ...context,
103
+ state,
104
+ lastComponent: activity.nativeCallComponent,
105
+ };
106
+ const component = nativeRenderCall(args, theme, nativeContext);
107
+ activity.nativeCallComponent = component;
108
+ return component;
109
+ }
110
+ return activityComponent(
111
+ definition.name,
112
+ args,
113
+ activity as NonNullable<ActivityRenderState<unknown>["openpiActivity"]>,
114
+ theme,
115
+ context.cwd,
50
116
  );
51
- let nativeHidden = 0;
52
- const content: string[] = [];
53
- const metadata: string[] = [];
54
- let status: string | undefined;
55
- for (const line of visible) {
56
- const plain = stripVTControlCharacters(line).trim();
57
- const hiddenMatch = plain.match(/\(?([0-9]+) earlier lines,/);
58
- if (hiddenMatch) {
59
- nativeHidden += Number(hiddenMatch[1]);
60
- } else if (/^(Took|Elapsed)\s/.test(plain)) {
61
- status = line;
62
- } else if (/^\[(Full output|Truncated):/.test(plain)) {
63
- metadata.push(line);
64
- } else {
65
- content.push(line);
117
+ },
118
+ renderResult(result, options, theme, context) {
119
+ const state = context.state as TState & ActivityRenderState<TDetails>;
120
+ state.openpiActivity ??= { status: "pending" };
121
+ const activity = state.openpiActivity;
122
+ activity.result = result;
123
+ activity.status = options.isPartial
124
+ ? "pending"
125
+ : context.isError
126
+ ? "error"
127
+ : "success";
128
+ if (
129
+ options.isPartial &&
130
+ definition.name === "bash" &&
131
+ !options.expanded &&
132
+ activity.interval === undefined
133
+ ) {
134
+ activity.interval = setInterval(() => context.invalidate(), 1000);
135
+ activity.interval.unref();
136
+ }
137
+ if (!options.isPartial || context.isError || options.expanded) {
138
+ activity.endedAt ??= Date.now();
139
+ if (activity.interval) {
140
+ clearInterval(activity.interval);
141
+ activity.interval = undefined;
66
142
  }
67
143
  }
68
- const preview = content.slice(0, maximum);
69
- const hidden =
70
- nativeHidden + Math.max(0, content.length - preview.length);
71
- return [
72
- ...preview,
73
- ...(hidden > 0 ? [theme.fg("dim", expandHint(hidden))] : []),
74
- ...metadata,
75
- ...(status ? [status] : []),
76
- ];
77
- },
78
- invalidate() {
79
- component.invalidate();
80
- },
81
- };
82
- }
144
+ if (options.isPartial && options.expanded) {
145
+ activity.endedAt = undefined;
146
+ }
83
147
 
84
- export function compactRenderedComponent(
85
- component: Component,
86
- theme: Theme,
87
- maximum = FILE_MUTATION_PREVIEW_LINES,
88
- background?: (text: string) => string,
89
- ): Component {
90
- return {
91
- render(width) {
92
- const lines = component.render(width);
93
- if (lines.length <= maximum) return lines;
94
- const hidden = lines.length - maximum;
95
- const hint = theme.fg("dim", expandHint(hidden));
96
- const paddedHint =
97
- hint + " ".repeat(Math.max(0, width - visibleWidth(hint)));
98
- return [
99
- ...lines.slice(0, maximum),
100
- background ? background(paddedHint) : hint,
101
- ];
102
- },
103
- invalidate() {
104
- component.invalidate();
148
+ if (options.expanded && nativeRenderResult) {
149
+ const nativeContext: Parameters<typeof nativeRenderResult>[3] = {
150
+ ...context,
151
+ state,
152
+ lastComponent: activity.nativeResultComponent,
153
+ };
154
+ const component = nativeRenderResult(
155
+ result,
156
+ options,
157
+ theme,
158
+ nativeContext,
159
+ );
160
+ activity.nativeResultComponent = component;
161
+ return component;
162
+ }
163
+ return emptyComponent;
105
164
  },
106
165
  };
107
166
  }