@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,595 @@
1
+ // Every tool call in its own frame: name on the top rule, call and result inside, how it went on the bottom rule.
2
+ import {
3
+ CUT,
4
+ type CallRuling,
5
+ isObject,
6
+ type FeatureScope,
7
+ CALLS_FRAMED,
8
+ RULING,
9
+ type RulingTone,
10
+ GLYPH,
11
+ LINE,
12
+ } from "../kit/index.ts";
13
+ import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ type Component,
16
+ getKeybindings,
17
+ stripTerminalSequences,
18
+ Text,
19
+ truncateToWidth,
20
+ visibleWidth,
21
+ } from "@earendil-works/pi-tui";
22
+
23
+ import { editBody } from "../render/diff.ts";
24
+ import { tabs } from "../render/format.ts";
25
+ import { type IconSet, glyphsOf } from "../render/glyphs.ts";
26
+ import { pathLine, type Stream, STREAMERS } from "../render/streaming.ts";
27
+ import { styledText } from "../render/safe.ts";
28
+ import { stateMark, toolIcon, toolLabel } from "../render/tools.ts";
29
+ import { icons, timing } from "../settings.ts";
30
+ import { clockTime, exactly } from "../render/timing.ts";
31
+
32
+ type Phase = "writing" | "waiting" | "running" | "done" | "failed";
33
+
34
+ const PHASE_COLORS = {
35
+ writing: "muted",
36
+ waiting: "accent",
37
+ running: "accent",
38
+ done: "success",
39
+ failed: "error",
40
+ } as const;
41
+ const BODY_PAD = 4;
42
+ const COLLAPSED_ARGS_CHARS = 100;
43
+ const RESULT_PREVIEW_LINES = 10;
44
+
45
+ export function registerToolFrames(scope: FeatureScope): void {
46
+ const redraws = new Map<string, () => void>();
47
+ const written = new Map<string, number>();
48
+ // Asked for on every drawing, and it changes only with the setting.
49
+ const set = glyphsOf(() => icons.get(scope));
50
+ CALLS_FRAMED.provide(scope, () => true);
51
+ RULING.onChanged(scope, (_ruling, toolCallId) => redraws.get(toolCallId)?.());
52
+ // Pi completes a message's calls only when the message ends; the provider says when each one ends.
53
+ scope.on("message_update", (event) => {
54
+ const update = event.assistantMessageEvent;
55
+ if (update.type === "toolcall_end") written.set(update.toolCall.id, Date.now());
56
+ });
57
+
58
+ scope.registerToolRenderer((toolName, next) =>
59
+ framed(scope, next(), {
60
+ name: toolName,
61
+ set: () => set().set,
62
+ redraws,
63
+ written,
64
+ }),
65
+ );
66
+ }
67
+
68
+ interface FrameContext {
69
+ name: string;
70
+ set: () => IconSet;
71
+ redraws: Map<string, () => void>;
72
+ written: Map<string, number>;
73
+ }
74
+
75
+ function framed(
76
+ scope: FeatureScope,
77
+ base: ToolRenderers | undefined,
78
+ frame: FrameContext,
79
+ ): ToolRenderers {
80
+ // The frame wraps whichever half a tool brings a renderer for.
81
+ const call = base?.renderCall ?? genericCall;
82
+ const result = base?.renderResult ?? genericResult;
83
+
84
+ // Pi's renderers mutate the component they returned last time, so they get that one back, not the frame.
85
+ const inner = new WeakMap<Component, Component | undefined>();
86
+ const passed = (last: Component | undefined): Component | undefined =>
87
+ last === undefined || !inner.has(last) ? last : inner.get(last);
88
+ const streamer = STREAMERS[frame.name];
89
+
90
+ return {
91
+ ...base,
92
+ renderShell: "self",
93
+ renderCall(args, theme, context) {
94
+ frame.redraws.set(context.toolCallId, context.invalidate);
95
+ const clock = clockOf(context);
96
+ const now = Date.now();
97
+ const ended = frame.written.get(context.toolCallId);
98
+ frame.written.delete(context.toolCallId);
99
+ // A call replayed or already running was written long ago.
100
+ const done =
101
+ ended !== undefined ||
102
+ context.argsComplete ||
103
+ context.executionStarted ||
104
+ !context.isPartial;
105
+ if (!done) clock.writingAt ??= now;
106
+ else clock.writtenAt ??= ended ?? now;
107
+ if (context.executionStarted) clock.startedAt ??= now;
108
+ const writing = clock.writtenAt === undefined;
109
+ const ruling = RULING.current(scope, context.toolCallId);
110
+
111
+ // Pi hands every call its arguments again on each piece the model writes, so an unchanged call keeps its frame.
112
+ const inputs = [
113
+ args,
114
+ theme,
115
+ ruling,
116
+ writing,
117
+ context.expanded,
118
+ context.isPartial,
119
+ context.executionStarted,
120
+ ];
121
+ const last = context.lastComponent;
122
+ if (last instanceof CallFrame && last.drawnFrom(inputs)) return last;
123
+
124
+ const stream = streamer?.(args, theme, {
125
+ cwd: context.cwd,
126
+ expanded: context.expanded,
127
+ writing,
128
+ state: context.state as object,
129
+ });
130
+ // While the model writes, pi's renderer has nothing new to show. The frame pads its inside itself.
131
+ const streaming = writing && stream !== undefined;
132
+ const own = streaming
133
+ ? undefined
134
+ : call(args, theme, {
135
+ ...context,
136
+ argsComplete: !writing,
137
+ outputPad: 0,
138
+ lastComponent: passed(last),
139
+ });
140
+ // Pi paints its edit on a colored box; the frame draws the diff itself, in the diff colors.
141
+ const drawnOwn = (component: Component): Component =>
142
+ frame.name === "edit" && isObject(args)
143
+ ? editBody(component, pathLine("edit", pathOf(args), theme), theme)
144
+ : component;
145
+ const body =
146
+ own === undefined ? drawnBody(stream?.lines ?? []) : orStream(drawnOwn(own), stream);
147
+
148
+ const box = new CallFrame(
149
+ {
150
+ theme,
151
+ icon: toolIcon(frame.name, frame.set()),
152
+ label: toolLabel(frame.name),
153
+ body,
154
+ ruling,
155
+ expanded: context.expanded,
156
+ phase: phaseOf(context, writing),
157
+ mark: stateMark,
158
+ set: frame.set,
159
+ progress: writing ? stream?.progress : undefined,
160
+ clock,
161
+ executing: context.isPartial && context.executionStarted,
162
+ runningAs: RUNNING_WORDS[frame.name],
163
+ },
164
+ inputs,
165
+ );
166
+ inner.set(box, own);
167
+ return box;
168
+ },
169
+ renderResult(shown, options, theme, context) {
170
+ const clock = clockOf(context);
171
+ // A blocked call prints only the reason the line above already gives.
172
+ const blocked = RULING.current(scope, context.toolCallId)?.blocked === true;
173
+ // Pi draws a result's pictures right below the frame, so the frame counts them.
174
+ const images = shown.content.filter((block) => block.type === "image").length;
175
+ if (!options.isPartial) clock.endedAt ??= Date.now();
176
+ clock.resulted = true;
177
+ const body = result(shown, options, theme, {
178
+ ...context,
179
+ outputPad: 0,
180
+ lastComponent: passed(context.lastComponent),
181
+ });
182
+ const phase: Phase = options.isPartial ? "running" : context.isError ? "failed" : "done";
183
+ const box = new ResultFrame({
184
+ theme,
185
+ body,
186
+ quiet: blocked,
187
+ phase,
188
+ set: frame.set,
189
+ elapsed: phase === "running" || !timing.get(scope) ? ranFor(clock, phase === "running") : tookFor(clock),
190
+ wrote: wroteFor(clock),
191
+ images: images === 0 ? undefined : `${images} image${images === 1 ? "" : "s"}`,
192
+ });
193
+ inner.set(box, body);
194
+ return box;
195
+ },
196
+ };
197
+ }
198
+
199
+ const genericCall: NonNullable<ToolRenderers["renderCall"]> = (args, theme, context) =>
200
+ new Text(callText(args, theme, context.expanded), 0, 0);
201
+
202
+ const genericResult: NonNullable<ToolRenderers["renderResult"]> = (result, options, theme) =>
203
+ new Text(resultText(result, options.expanded, theme), 0, 0);
204
+
205
+ const RUNNING_WORDS: Readonly<Record<string, string>> = {
206
+ ask_questions: "waiting for your answer",
207
+ };
208
+
209
+ /** Without a result: writing the arguments, then waiting to run. */
210
+ function phaseOf(
211
+ context: { isPartial: boolean; executionStarted: boolean },
212
+ writing: boolean,
213
+ ): Phase | undefined {
214
+ if (!context.isPartial || context.executionStarted) return undefined;
215
+ return writing ? "writing" : "waiting";
216
+ }
217
+
218
+ function callText(args: unknown, theme: Theme, expanded: boolean): string {
219
+ if (args === null || args === undefined) return "";
220
+
221
+ const entries: [string, unknown][] =
222
+ typeof args === "object" && !Array.isArray(args) ? Object.entries(args) : [["args", args]];
223
+ if (entries.length === 0) return "";
224
+
225
+ if (expanded) {
226
+ const lines = entries.map(([key, value]) => {
227
+ const text =
228
+ typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
229
+ return ` ${key}: ${tabs(text).split("\n").join("\n ")}`;
230
+ });
231
+ return theme.fg("muted", lines.join("\n"));
232
+ }
233
+
234
+ const pairs = entries
235
+ .map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`)
236
+ .join(" ");
237
+ const preview =
238
+ pairs.length > COLLAPSED_ARGS_CHARS ? `${pairs.slice(0, COLLAPSED_ARGS_CHARS - 3)}...` : pairs;
239
+ return theme.fg("muted", preview);
240
+ }
241
+
242
+ function resultText(
243
+ result: { content: readonly { type: string; text?: string }[] },
244
+ expanded: boolean,
245
+ theme: Theme,
246
+ ): string {
247
+ const text = result.content
248
+ .filter((block) => block.type === "text")
249
+ .map((block) => stripTerminalSequences(block.text ?? "").replace(/\r/g, ""))
250
+ .join("\n");
251
+ if (text === "") return "";
252
+
253
+ const lines = text.split("\n");
254
+ const shown = expanded ? lines : lines.slice(0, RESULT_PREVIEW_LINES);
255
+ const printed = shown.map((line) => theme.fg("toolOutput", line));
256
+ if (shown.length < lines.length)
257
+ printed.push(
258
+ theme.fg("muted", `... (${lines.length - shown.length} more lines, ${expandHint(theme)})`),
259
+ );
260
+ return printed.join("\n");
261
+ }
262
+
263
+ function expandHint(theme: Theme): string {
264
+ const keys = getKeybindings().getKeys("app.tools.expand");
265
+ if (keys.length === 0) return theme.fg("muted", "expand");
266
+ return `${theme.fg("dim", keys.join("/"))}${theme.fg("muted", " to expand")}`;
267
+ }
268
+
269
+ interface FrameParts {
270
+ theme: Theme;
271
+ body: Component;
272
+ }
273
+
274
+ class CallFrame implements Component {
275
+ #drawn?: Drawn;
276
+ /** Set when pi or the terminal asks for a fresh drawing. */
277
+ #stale = false;
278
+
279
+ constructor(
280
+ private readonly parts: FrameParts & CallParts,
281
+ private readonly inputs: readonly unknown[],
282
+ ) {}
283
+
284
+ drawnFrom(inputs: readonly unknown[]): boolean {
285
+ return (
286
+ !this.#stale &&
287
+ inputs.length === this.inputs.length &&
288
+ inputs.every((input, index) => input === this.inputs[index])
289
+ );
290
+ }
291
+
292
+ invalidate(): void {
293
+ this.#drawn = undefined;
294
+ this.#stale = true;
295
+ this.parts.body.invalidate();
296
+ }
297
+
298
+ render(width: number): string[] {
299
+ const { theme, icon, label, ruling, expanded, clock } = this.parts;
300
+ // A running call closes too until its result draws the rest of the frame.
301
+ const running = this.parts.executing === true && clock?.resulted !== true;
302
+ const phase = this.parts.phase ?? (running ? "running" : undefined);
303
+ // Read on every drawing, so the time goes on while nothing new arrives.
304
+ const time = clock === undefined || phase === undefined ? undefined : timeOf(phase, clock);
305
+ const mark = phase === undefined ? undefined : this.parts.mark(phase, this.parts.set());
306
+ const body = this.parts.body.render(room(width));
307
+ const progress = this.parts.progress?.join("\u0000");
308
+ const drawn = { width, body, rest: [ruling, expanded, phase, time, progress, mark] };
309
+ if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
310
+
311
+ const title = `${theme.fg("toolTitle", icon)} ${theme.fg("toolTitle", theme.bold(label))}`;
312
+ const lines = [rule("top", title, width, theme), ...inside(withoutBlanks(body), width, theme)];
313
+ if (ruling !== undefined)
314
+ lines.push(...inside([rulingText(ruling, theme, expanded)], width, theme));
315
+ // With nothing else to show yet, the frame closes with what the call is doing.
316
+ if (phase !== undefined && mark !== undefined) {
317
+ const times = [time, ...(this.parts.progress ?? [])];
318
+ const word = phase === "running" ? this.parts.runningAs : undefined;
319
+ const closing = phaseLabel(theme, mark, phase, times, word);
320
+ lines.push(rule("bottom", closing, width, theme, PHASE_COLORS[phase]));
321
+ }
322
+ this.#drawn = { ...drawn, lines };
323
+ return lines;
324
+ }
325
+ }
326
+
327
+ interface CallParts {
328
+ icon: string;
329
+ label: string;
330
+ ruling?: CallRuling;
331
+ expanded: boolean;
332
+ phase?: Phase;
333
+ mark: (phase: Phase, set: IconSet) => string;
334
+ set: () => IconSet;
335
+ progress?: readonly string[];
336
+ clock?: Clock;
337
+ /** It runs and its result is not final. */
338
+ executing?: boolean;
339
+ /** When the tool's running means something else. */
340
+ runningAs?: string;
341
+ }
342
+
343
+ function drawnBody(drawn: readonly string[]): Component {
344
+ return { render: () => [...drawn], invalidate: () => {} };
345
+ }
346
+
347
+ /** The stream while pi's body is still only its first line. */
348
+ function orStream(own: Component, stream: Stream | undefined): Component {
349
+ if (stream === undefined) return own;
350
+ return {
351
+ render(width) {
352
+ const drawn = own.render(width);
353
+ return hasLines(drawn, 2) ? drawn : [...stream.lines];
354
+ },
355
+ invalidate: () => own.invalidate(),
356
+ };
357
+ }
358
+
359
+ class ResultFrame implements Component {
360
+ #drawn?: Drawn;
361
+
362
+ constructor(private readonly parts: FrameParts & ResultParts) {}
363
+
364
+ invalidate(): void {
365
+ this.#drawn = undefined;
366
+ this.parts.body.invalidate();
367
+ }
368
+
369
+ render(width: number): string[] {
370
+ const { theme, phase, wrote, quiet, images } = this.parts;
371
+ const time = this.parts.elapsed;
372
+ const mark = stateMark(phase, this.parts.set());
373
+ const body = this.parts.body.render(room(width));
374
+ const drawn = { width, body, rest: [phase, mark, time, wrote, images, quiet] };
375
+ if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
376
+
377
+ const printed =
378
+ quiet === true ? [] : withoutBlanks(dropTime(body)).map((line) => hidden(line, theme));
379
+ const lines = [
380
+ // A call with nothing to print has no cut.
381
+ ...(printed.length === 0 ? [] : [cut(width, theme), ...inside(printed, width, theme)]),
382
+ rule(
383
+ "bottom",
384
+ phaseLabel(theme, mark, phase, [
385
+ time,
386
+ wrote === undefined ? undefined : `wrote ${wrote}`,
387
+ images === undefined ? undefined : `with ${images}`,
388
+ ]),
389
+ width,
390
+ theme,
391
+ PHASE_COLORS[phase],
392
+ ),
393
+ ];
394
+ this.#drawn = { ...drawn, lines };
395
+ return lines;
396
+ }
397
+ }
398
+
399
+ interface ResultParts {
400
+ phase: Phase;
401
+ set: () => IconSet;
402
+ elapsed?: string;
403
+ wrote?: string;
404
+ /** Pi draws them outside the frame. */
405
+ images?: string;
406
+ /** Nothing worth printing, like a call the gate blocked. */
407
+ quiet?: boolean;
408
+ }
409
+
410
+ function rule(
411
+ edge: "top" | "bottom",
412
+ left: string,
413
+ width: number,
414
+ theme: Theme,
415
+ color: "accent" | "success" | "error" | "muted" | "border" = "border",
416
+ ): string {
417
+ const [start, end] =
418
+ edge === "top" ? [LINE.topLeft, LINE.topRight] : [LINE.bottomLeft, LINE.bottomRight];
419
+ const paint = (text: string) => theme.fg(color, text);
420
+ const cell = left === "" ? "" : ` ${styledText(left)} `;
421
+ const fill = Math.max(1, width - BODY_PAD - visibleWidth(cell));
422
+ const line =
423
+ paint(`${start}${LINE.h}`) + cell + paint(LINE.h.repeat(fill)) + paint(`${LINE.h}${end}`);
424
+ return fill === 1 ? truncateToWidth(line, width, "") : line;
425
+ }
426
+
427
+ function cut(width: number, theme: Theme): string {
428
+ const paint = (text: string) => theme.fg("border", text);
429
+ const fill = Math.max(1, width - BODY_PAD);
430
+ const line = `${paint(LINE.teeLeft)}${paint(LINE.h.repeat(fill + 2))}${paint(LINE.teeRight)}`;
431
+ return fill === 1 ? truncateToWidth(line, width, "") : line;
432
+ }
433
+
434
+ function phaseLabel(
435
+ theme: Theme,
436
+ mark: string,
437
+ phase: Phase,
438
+ times: readonly (string | undefined)[],
439
+ word: string = phase,
440
+ ): string {
441
+ const cells = times
442
+ .filter((time) => time !== undefined)
443
+ .map((time) => theme.fg("dim", time ?? ""));
444
+ return [`${theme.fg(PHASE_COLORS[phase], mark)} ${theme.fg("muted", word)}`, ...cells].join(" ");
445
+ }
446
+
447
+ function inside(lines: readonly string[], width: number, theme: Theme): string[] {
448
+ const paint = (text: string) => theme.fg("border", text);
449
+ const inner = room(width);
450
+ return trim(lines).map((drawn) => {
451
+ // Whichever renderer drew it, and whatever it was given to show.
452
+ const line = styledText(drawn);
453
+ const wide = visibleWidth(line);
454
+ const text = wide > inner ? truncateToWidth(line, inner, CUT, true) : line;
455
+ const pad = " ".repeat(Math.max(0, inner - (wide > inner ? visibleWidth(text) : wide)));
456
+ return `${paint(LINE.v)} ${text}${pad} ${paint(LINE.v)}`;
457
+ });
458
+ }
459
+
460
+ interface Drawn {
461
+ width: number;
462
+ body: readonly string[];
463
+ rest: readonly unknown[];
464
+ lines: string[];
465
+ }
466
+
467
+ /** Keeps typing cheap. */
468
+ function unchanged(drawn: Drawn | undefined, next: Omit<Drawn, "lines">): drawn is Drawn {
469
+ if (drawn === undefined || drawn.width !== next.width) return false;
470
+ if (drawn.body.length !== next.body.length || drawn.rest.length !== next.rest.length)
471
+ return false;
472
+ if (drawn.rest.some((part, index) => part !== next.rest[index])) return false;
473
+ // A renderer that kept its lines hands back the same array.
474
+ return drawn.body === next.body || drawn.body.every((line, index) => line === next.body[index]);
475
+ }
476
+
477
+ function room(width: number): number {
478
+ return Math.max(1, width - BODY_PAD);
479
+ }
480
+
481
+ /** As `... (240 earlier lines, ctrl+o to expand)`. */
482
+ const HIDDEN = /^\.\.\. \((\d+) (earlier|more) lines, (.+?) to expand\)$/u;
483
+
484
+ export function hidden(line: string, theme: Theme): string {
485
+ if (!line.includes(" to expand)")) return line;
486
+ const match = HIDDEN.exec(stripTerminalSequences(line).trim());
487
+ if (match === null) return line;
488
+ const [, count, side, key] = match;
489
+ const where = side === "earlier" ? "above" : "below";
490
+ const lines = theme.fg("muted", `${count} lines ${where}`);
491
+ return `${theme.fg("dim", GLYPH.ellipsisUp)} ${lines} ${theme.fg("dim", key ?? "")}`;
492
+ }
493
+
494
+ /** The frame says the time on the bottom rule instead. */
495
+ function dropTime(lines: readonly string[]): string[] {
496
+ const at = lines.findLastIndex((line) => !isBlank(line));
497
+ if (at === -1) return [...lines];
498
+ if (!/^(Took|Elapsed) [\d.]/u.test(stripTerminalSequences(lines[at] ?? "").trim()))
499
+ return [...lines];
500
+ return lines.slice(0, at);
501
+ }
502
+
503
+ interface Clock {
504
+ writingAt?: number;
505
+ writtenAt?: number;
506
+ startedAt?: number;
507
+ endedAt?: number;
508
+ /** Closes the frame from then on. */
509
+ resulted?: boolean;
510
+ }
511
+
512
+ function clockOf(context: { state: unknown }): Clock {
513
+ return context.state as Clock;
514
+ }
515
+
516
+ function wroteFor(clock: Clock): string | undefined {
517
+ if (clock.writingAt === undefined || clock.writtenAt === undefined) return undefined;
518
+ return elapsed(clock.writtenAt - clock.writingAt);
519
+ }
520
+
521
+ function ranFor(clock: Clock, running: boolean): string | undefined {
522
+ if (clock.startedAt === undefined) return undefined;
523
+ const end = clock.endedAt ?? (running ? Date.now() : undefined);
524
+ return end === undefined ? undefined : elapsed(end - clock.startedAt);
525
+ }
526
+
527
+ /** When a finished call started and how long it ran, to the millisecond. */
528
+ function tookFor(clock: Clock): string | undefined {
529
+ if (clock.startedAt === undefined || clock.endedAt === undefined) return undefined;
530
+ return `${clockTime(clock.startedAt)} took ${exactly(clock.endedAt - clock.startedAt)}`;
531
+ }
532
+
533
+ function timeOf(phase: Phase, clock: Clock): string | undefined {
534
+ if (phase === "running") return ranFor(clock, true);
535
+ const from = phase === "waiting" ? clock.writtenAt : clock.writingAt;
536
+ return from === undefined ? undefined : elapsed(Date.now() - from);
537
+ }
538
+
539
+ function elapsed(ms: number): string {
540
+ const seconds = ms / 1000;
541
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
542
+ const total = Math.floor(seconds);
543
+ if (total < 3600) return `${Math.floor(total / 60)}m ${total % 60}s`;
544
+ return `${Math.floor(total / 3600)}h ${Math.floor((total % 3600) / 60)}m`;
545
+ }
546
+
547
+ function trim(lines: readonly string[]): string[] {
548
+ let [from, to] = [0, lines.length];
549
+ while (from < to && isBlank(lines[from])) from++;
550
+ while (to > from && isBlank(lines[to - 1])) to--;
551
+ return lines.slice(from, to);
552
+ }
553
+
554
+ /** Stops at the count, so a long body costs no more than a short one. */
555
+ function hasLines(lines: readonly string[], count: number): boolean {
556
+ let found = 0;
557
+ for (const line of lines) {
558
+ if (!isBlank(line) && ++found >= count) return true;
559
+ }
560
+ return false;
561
+ }
562
+
563
+ function withoutBlanks(lines: readonly string[]): string[] {
564
+ return lines.filter((line) => !isBlank(line));
565
+ }
566
+
567
+ export function isBlank(line: string | undefined): boolean {
568
+ if (line === undefined) return true;
569
+ // A line of only color codes reads as blank; one without escapes needs no stripping.
570
+ return (line.includes("\u001b") ? stripTerminalSequences(line) : line).trim() === "";
571
+ }
572
+
573
+ function rulingText(ruling: CallRuling, theme: Theme, expanded: boolean): string {
574
+ const color = PHASE_COLORS[stateOf(ruling.tone)];
575
+ let line = `${theme.fg(color, GLYPH.judge)} ${theme.fg(color, ruling.head)}`;
576
+ if (ruling.note) line += ` ${theme.fg("accent", GLYPH.chevron)} ${ruling.note}`;
577
+ if (ruling.why) line += ` ${theme.fg("muted", ruling.why)}`;
578
+ if (expanded && ruling.detail) line += ` ${theme.fg("dim", `(${ruling.detail})`)}`;
579
+ return line;
580
+ }
581
+
582
+ function stateOf(tone: RulingTone): Phase {
583
+ switch (tone) {
584
+ case "pending":
585
+ return "running";
586
+ case "success":
587
+ return "done";
588
+ default:
589
+ return "failed";
590
+ }
591
+ }
592
+
593
+ function pathOf(args: Record<string, unknown>): string | undefined {
594
+ return typeof args.path === "string" ? args.path : undefined;
595
+ }
@@ -0,0 +1,25 @@
1
+ export type Activity =
2
+ | { kind: "waiting" }
3
+ | { kind: "thinking" }
4
+ | { kind: "writing" }
5
+ | { kind: "drafting"; tool: string }
6
+ | { kind: "running"; tool: string };
7
+
8
+ export function describe(activity: Activity): string {
9
+ switch (activity.kind) {
10
+ case "waiting":
11
+ case "thinking":
12
+ case "writing":
13
+ return activity.kind;
14
+ case "drafting":
15
+ return `drafting ${activity.tool.toLowerCase()}`;
16
+ case "running":
17
+ return `running ${activity.tool.toLowerCase()}`;
18
+ }
19
+ }
20
+
21
+ export function draftedTool(content: unknown): string | undefined {
22
+ if (!Array.isArray(content)) return undefined;
23
+ const part = content.at(-1) as { type?: unknown; name?: unknown } | undefined;
24
+ return part?.type === "toolCall" && typeof part.name === "string" ? part.name : undefined;
25
+ }