@gavin-hu/my-pi-agent 0.13.1 → 0.15.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 (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -28,7 +28,7 @@
28
28
  import type { Theme } from "@earendil-works/pi-coding-agent";
29
29
  import { type Component, stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
30
30
  import { sanitizeLine } from "../../../lib/format.ts";
31
- import { BODY_INDENT, bodyLines, indentBlockLines } from "../../../lib/tui/indent.ts";
31
+ import { BODY_INDENT, bodyLines, indentBlockLines, isBlank } from "../../../lib/tui/indent.ts";
32
32
  import { RenderCache } from "../../../lib/tui/render-cache.ts";
33
33
  import { paintPrefix } from "../../../lib/tui/tokens.ts";
34
34
 
@@ -174,14 +174,6 @@ export function isRewritable(component: Component): component is Component & Rew
174
174
  return typeof candidate.text === "string" && typeof candidate.setText === "function";
175
175
  }
176
176
 
177
- /**
178
- * Whether a line has no visible text. Tested after stripping terminal sequences,
179
- * so a line carrying only a background or the indent counts as blank.
180
- */
181
- function isBlankLine(line: string): boolean {
182
- return stripTerminalSequences(line).trim().length === 0;
183
- }
184
-
185
177
  /**
186
178
  * Whether a call line degenerated to the tool name alone, so the rest of it —
187
179
  * the path, the command — moved onto the body lines under it.
@@ -195,7 +187,7 @@ function isBlankLine(line: string): boolean {
195
187
  */
196
188
  function isBareHeadline(body: readonly string[], toolName: string | undefined): boolean {
197
189
  if (!toolName) return false;
198
- const headline = body.find((line) => !isBlankLine(line));
190
+ const headline = body.find((line) => !isBlank(line));
199
191
  return headline !== undefined && stripTerminalSequences(headline).trim() === sanitizeLine(toolName);
200
192
  }
201
193
 
@@ -206,9 +198,9 @@ function isBareHeadline(body: readonly string[], toolName: string | undefined):
206
198
  */
207
199
  function callContinuationCount(body: readonly string[], toolName: string | undefined): number {
208
200
  if (!isBareHeadline(body, toolName)) return 0;
209
- let index = body.findIndex((line) => !isBlankLine(line)) + 1;
201
+ let index = body.findIndex((line) => !isBlank(line)) + 1;
210
202
  let count = 0;
211
- while (index < body.length && !isBlankLine(body[index] ?? "")) {
203
+ while (index < body.length && !isBlank(body[index] ?? "")) {
212
204
  count++;
213
205
  index++;
214
206
  }
@@ -228,7 +220,7 @@ function frameLines(
228
220
  options: ToolBlockOptions = {},
229
221
  ): string[] {
230
222
  const w = Math.max(1, width);
231
- const indent = (outputPad ?? 0) + BODY_INDENT;
223
+ const indent = outputPad + BODY_INDENT;
232
224
  const lines = indentBlockLines(body, w, {
233
225
  indent,
234
226
  rail: options.rail,
@@ -277,7 +269,7 @@ export function toolBlockLines(
277
269
  options: ToolBlockOptions = {},
278
270
  ): string[] {
279
271
  const w = Math.max(1, width);
280
- const indent = (outputPad ?? 0) + BODY_INDENT;
272
+ const indent = outputPad + BODY_INDENT;
281
273
  return frameLines(bodyLines(inner, w, indent), w, outputPad, options);
282
274
  }
283
275
 
@@ -307,7 +299,7 @@ export class ToolBlock implements Component {
307
299
 
308
300
  render(width: number): string[] {
309
301
  const w = Math.max(1, width);
310
- const indent = (this.outputPad ?? 0) + BODY_INDENT;
302
+ const indent = this.outputPad + BODY_INDENT;
311
303
  const body = bodyLines(this.inner, w, indent);
312
304
  return this.cache.lines(w, body, () => frameLines(body, w, this.outputPad, this.options));
313
305
  }
@@ -55,7 +55,12 @@ import { isRewritable, recolorCallHeadline, recolorShellCall, ToolBlock } from "
55
55
  import { BLOCK_CONFIG, type BlockStatus } from "./config.ts";
56
56
  import { callFallback, resultFallback } from "./fallback.ts";
57
57
 
58
- /** Per-row memory of the base renderer's own components, kept off Pi's state. */
58
+ /**
59
+ * Per-row memory of the base renderer's own components, stored in the `state`
60
+ * Pi creates for one tool row and hands to both its call and its result
61
+ * renderer. Keeping the base components there is what lets the row keep them
62
+ * while `lastComponent` points at this frame instead.
63
+ */
59
64
  interface BlockMemory {
60
65
  byteflyBaseCall?: Component;
61
66
  byteflyBaseResult?: Component;
@@ -14,6 +14,9 @@ import { clampInteger, loadConfigSection } from "../../../lib/config.ts";
14
14
  /** How long a single probe may take before it counts as unusable. */
15
15
  const DEFAULT_TIMEOUT_MS = 2_000;
16
16
 
17
+ /** The probe deadline range, in milliseconds; the panel's editor offers the same bounds. */
18
+ export const TIMEOUT_MS_RANGE = { min: 100, max: 30_000 } as const;
19
+
17
20
  export interface DoctorConfig {
18
21
  /** Probe enabled extensions at session start and warn when one is broken. */
19
22
  proactive: boolean;
@@ -34,7 +37,7 @@ export function normalizeConfig(raw: Record<string, unknown> | undefined, base:
34
37
  if (!raw) return base;
35
38
  return {
36
39
  proactive: typeof raw.proactive === "boolean" ? raw.proactive : base.proactive,
37
- timeoutMs: clampInteger(raw.timeoutMs, base.timeoutMs, 100, 30_000),
40
+ timeoutMs: clampInteger(raw.timeoutMs, base.timeoutMs, TIMEOUT_MS_RANGE.min, TIMEOUT_MS_RANGE.max),
38
41
  skip: Array.isArray(raw.skip)
39
42
  ? raw.skip.filter((name): name is string => typeof name === "string" && name.trim().length > 0)
40
43
  : base.skip,
@@ -45,7 +45,11 @@ export function registerDoctor(pi: ExtensionAPI, deps: DoctorDeps = {}): DoctorR
45
45
  // without importing the extension.
46
46
  pi.events.on(WEB_SEARCH_PROVIDER_CHANNEL, (facts) => runtime.noteSearchFacts(facts));
47
47
 
48
- pi.on("session_start", async (_event, ctx) => {
48
+ // Bumped when a session starts or ends, so a probe that outlives its session
49
+ // cannot post its notice into the next one (a reload re-registers the surface).
50
+ let probeToken = 0;
51
+
52
+ pi.on("session_start", (_event, ctx) => {
49
53
  const config = loadConfig(ctx.cwd);
50
54
  runtime.configure({ cwd: ctx.cwd, config });
51
55
  if (!config.proactive || !ctx.hasUI) return;
@@ -55,12 +59,27 @@ export function registerDoctor(pi: ExtensionAPI, deps: DoctorDeps = {}): DoctorR
55
59
  if (pi.getSettings?.().quietStartup !== true) return;
56
60
 
57
61
  // Only hard requirements can break an enabled extension, so the startup
58
- // probe is limited to the extensions that declare one, in parallel.
59
- const missing = missingFindings(await runtime.probeRequired());
60
- if (missing.length > 0) pi.sendMessage(warningNotice(missing));
62
+ // probe is limited to the extensions that declare one, in parallel. Fired
63
+ // without awaiting: Pi awaits every `session_start` handler, and these
64
+ // probes spawn processes, so holding the first prompt on them is the cost
65
+ // this avoids. The notice lands a moment later, in the flow, as before.
66
+ const token = ++probeToken;
67
+ void runtime
68
+ .probeRequired()
69
+ .then((reports) => {
70
+ if (token !== probeToken) return;
71
+ const missing = missingFindings(reports);
72
+ if (missing.length > 0) pi.sendMessage(warningNotice(missing));
73
+ })
74
+ .catch(() => {
75
+ // A background check never surfaces an error, and the report is advice.
76
+ });
61
77
  });
62
78
 
63
- pi.on("session_shutdown", () => runtime.reset());
79
+ pi.on("session_shutdown", () => {
80
+ probeToken += 1;
81
+ runtime.reset();
82
+ });
64
83
 
65
84
  return runtime;
66
85
  }
@@ -57,7 +57,11 @@ export function registerTools(pi: ExtensionAPI, runtime: DoctorRuntime): void {
57
57
  ],
58
58
  parameters: DoctorParams,
59
59
  outputSchema: DoctorOutput,
60
+ // `refresh` reaches the configured search endpoint, but that URL comes from
61
+ // config, not from the model, so the tool stays closed-world for the policy.
62
+ // `probeAll` writes the session's report cache, so its calls are ordered.
60
63
  annotations: { readOnlyHint: true, openWorldHint: false },
64
+ executionMode: "sequential",
61
65
 
62
66
  async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
63
67
  const args = params as DoctorArgs;
@@ -48,6 +48,7 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
48
48
  let delegationTotals = { runs: 0, usage: emptyUsage() };
49
49
  let cacheHitRate: number | null = null;
50
50
  let context: ContextInfo = { tokens: null, contextWindow: 0, percent: null };
51
+ let sessionName: string | null = null;
51
52
 
52
53
  let last: UsageTotals | undefined;
53
54
 
@@ -70,6 +71,10 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
70
71
  contextWindow: usageInfo?.contextWindow ?? current?.contextWindow ?? 0,
71
72
  percent: usageInfo?.percent ?? null,
72
73
  };
74
+ // The host walks the file backwards for the latest `session_info`, and a
75
+ // session renamed early makes that the whole transcript. A rename appends
76
+ // an entry, which moves the leaf, so the memo key already covers it.
77
+ sessionName = session.getSessionName() ?? null;
73
78
  key = nextKey;
74
79
  last = usage;
75
80
  }
@@ -80,7 +85,7 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
80
85
 
81
86
  return {
82
87
  cwd: ctx.cwd,
83
- sessionName: ctx.sessionManager.getSessionName() ?? null,
88
+ sessionName,
84
89
  branch: footerData.getGitBranch(),
85
90
  statuses: footerData.getExtensionStatuses(),
86
91
  model,
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Tuning for the two-line status bar.
3
3
  *
4
- * The bar's own content and layout: context thresholds, gauge glyphs and
5
- * widths, separators, icons, status keys, and label limits. Segment weights
4
+ * The bar's own content and layout: context thresholds, gauge widths,
5
+ * separators, icons, status keys, and label limits. Segment weights
6
6
  * live on the segments themselves (see `../render/segments.ts`); the price
7
7
  * configuration is `bytefly.json`, read by `../price/config.ts`.
8
8
  */
@@ -12,10 +12,14 @@ import { GLYPHS, SEPARATORS, STATUS_KEYS } from "../../../lib/tui/glyphs.ts";
12
12
  export const CONFIG = {
13
13
  /** Context percentage thresholds. */
14
14
  thresholds: { warn: 70, danger: 90 },
15
- /** Gauge glyphs, and the block counts the gauge steps down through. */
16
- gauge: { full: GLYPHS.gaugeFull, empty: GLYPHS.gaugeEmpty, widths: [10, 5, 3, 0] as const },
17
- /** Separators printed before grouped and other segments. */
18
- separators: { group: SEPARATORS.group, item: SEPARATORS.item },
15
+ /** The block counts the context gauge steps down through. */
16
+ gauge: { widths: [10, 5, 3, 0] as const },
17
+ /**
18
+ * Separators printed before grouped and other segments. The group divider is
19
+ * the footer's own solid `│`, not the shared `╎`: that glyph is the transcript
20
+ * rail (and `chrome.rail`'s default), and a status line is not a rail.
21
+ */
22
+ separators: { group: " │ ", item: SEPARATORS.item },
19
23
  icons: {
20
24
  branch: GLYPHS.branch,
21
25
  detached: GLYPHS.detached,
@@ -101,9 +101,10 @@ export const DEFAULT_BYTEFLY_CONFIG: ByteflyConfig = {
101
101
  budget: 0,
102
102
  };
103
103
 
104
- const MAX_DECIMALS = 4;
104
+ /** The highest `currency.decimals` the screen and the loader both accept. */
105
+ export const MAX_DECIMALS = 4;
105
106
  /** Upper bound for `budget`; large enough for any real session, small enough to clamp a typo. */
106
- const MAX_BUDGET = 10_000_000;
107
+ export const MAX_BUDGET = 10_000_000;
107
108
  const MAX_RATE = 1_000_000;
108
109
  const MAX_RATE_KEYS = 200;
109
110
  const MAX_WINDOWS = 16;
@@ -11,7 +11,7 @@
11
11
  import { emptyUsage } from "../collect/entries.ts";
12
12
  import type { ByteflyConfig, ModelRates } from "./types.ts";
13
13
  import type { UsageRecord, UsageTotals } from "../types.ts";
14
- import { findRates } from "./rates.ts";
14
+ import { createRateLookup } from "./rates.ts";
15
15
  import { isPeak, peakFormatter } from "./schedule.ts";
16
16
 
17
17
  const TOKENS_PER_MILLION = 1_000_000;
@@ -37,7 +37,9 @@ export function createPricer(config: ByteflyConfig): (records: readonly UsageRec
37
37
  // Both currencies price from `rates` and the tariff; they differ only in what
38
38
  // an unmatched model falls back to, and Pi's recorded cost is already dollars.
39
39
  const dollars = config.currency.code === "USD";
40
- // Building a formatter is the expensive part, and only a schedule needs one.
40
+ // Both are built once per config, never per record: the table splits every key
41
+ // and remembers the last match, and the formatter is the expensive part.
42
+ const lookup = createRateLookup(config.rates);
41
43
  const format = config.peak.windows.length > 0 ? peakFormatter(config.peak.timezone) : null;
42
44
 
43
45
  return (records) => {
@@ -47,7 +49,7 @@ export function createPricer(config: ByteflyConfig): (records: readonly UsageRec
47
49
  totals.output += record.output;
48
50
  totals.cacheRead += record.cacheRead;
49
51
  totals.cacheWrite += record.cacheWrite;
50
- const rates = findRates(config.rates, record.provider, record.model);
52
+ const rates = lookup(record.provider, record.model);
51
53
  if (rates !== undefined) {
52
54
  totals.cost += ratedCost(config, format, record, rates);
53
55
  continue;
@@ -2,13 +2,31 @@
2
2
  * Rate lookup for a usage record.
3
3
  *
4
4
  * A key is `provider/model`, and either segment may be a wildcard: `*` matches
5
- * anything, and a trailing star matches a prefix (`gpt-*`). The most specific
6
- * key wins and the model segment outranks the provider one, so the documented
7
- * order is `provider/model`, then `any/model`, then `provider/any`, then `*`.
5
+ * anything, and a trailing star matches a prefix (`gpt-*`). The key splits at
6
+ * its first slash, so the model segment may itself contain one — an OpenRouter
7
+ * id is `anthropic/claude-fable-5`, and its key is
8
+ * `openrouter/anthropic/claude-fable-5`. The most specific key wins and the
9
+ * model segment outranks the provider one, so the documented order is
10
+ * `provider/model`, then `any/model`, then `provider/any`, then `*`.
11
+ *
12
+ * `createRateLookup` prepares the table once for a config: the keys are split
13
+ * at build time, and the last match is remembered, because a session prices
14
+ * hundreds of records from one model in a row. Splitting every key per record
15
+ * would rebuild the whole table on the footer's hot path, once per record.
8
16
  */
9
17
 
10
18
  import type { ModelRates } from "./types.ts";
11
19
 
20
+ /** A prepared rate table: call once per record, not once per key. */
21
+ export type RateLookup = (provider: string | null, model: string | null) => ModelRates | undefined;
22
+
23
+ /** One compiled key: both segments already split out of the config string. */
24
+ interface CompiledRate {
25
+ provider: string;
26
+ model: string;
27
+ value: ModelRates;
28
+ }
29
+
12
30
  /** One segment's score against a value: exact 2, prefix 1, wildcard 0, no match -1. */
13
31
  function segmentScore(pattern: string, value: string): number {
14
32
  if (pattern === "*") return 0;
@@ -17,38 +35,57 @@ function segmentScore(pattern: string, value: string): number {
17
35
  }
18
36
 
19
37
  /**
20
- * How specifically `key` matches a record, or `-1` when it does not.
38
+ * How specifically a compiled key matches a record, or `-1` when it does not.
21
39
  *
22
40
  * The model segment dominates: the score is weighted so every model score
23
41
  * outranks any provider score.
24
42
  */
25
- function matchKey(key: string, provider: string, model: string): number {
26
- const parts = key.split("/");
27
- if (parts.length === 1) return key === "*" ? 0 : -1;
28
- if (parts.length !== 2) return -1;
29
- const providerScore = segmentScore(parts[0], provider);
30
- const modelScore = segmentScore(parts[1], model);
43
+ function matchRate(entry: CompiledRate, provider: string, model: string): number {
44
+ const providerScore = segmentScore(entry.provider, provider);
45
+ const modelScore = segmentScore(entry.model, model);
31
46
  if (providerScore < 0 || modelScore < 0) return -1;
32
47
  return modelScore * 3 + providerScore;
33
48
  }
34
49
 
35
- /** The most specific configured rate, or `undefined` when none matches. */
36
- export function findRates(
37
- rates: Readonly<Record<string, ModelRates>>,
38
- provider: string | null,
39
- model: string | null,
40
- ): ModelRates | undefined {
41
- // A record that names no model can only be priced by the catch-all.
42
- if (!provider || !model) return rates["*"];
43
- let best: ModelRates | undefined;
44
- let bestScore = -1;
50
+ /** Prepare `rates` for one config; the returned lookup remembers its last match. */
51
+ export function createRateLookup(rates: Readonly<Record<string, ModelRates>>): RateLookup {
52
+ const compiled: CompiledRate[] = [];
53
+ let catchAll: ModelRates | undefined;
45
54
  for (const [key, value] of Object.entries(rates)) {
46
- const score = matchKey(key, provider, model);
47
- // `>` keeps the first key in config order when two are equally specific.
48
- if (score > bestScore) {
49
- best = value;
50
- bestScore = score;
55
+ const slash = key.indexOf("/");
56
+ if (slash < 0) {
57
+ // A bare key is the catch-all only when it is `*`; anything else is
58
+ // malformed and never matches.
59
+ if (key === "*") {
60
+ catchAll = value;
61
+ compiled.push({ provider: "*", model: "*", value });
62
+ }
63
+ continue;
51
64
  }
65
+ compiled.push({ provider: key.slice(0, slash), model: key.slice(slash + 1), value });
52
66
  }
53
- return best;
67
+
68
+ let lastProvider: string | null = null;
69
+ let lastModel: string | null = null;
70
+ let last: ModelRates | undefined;
71
+
72
+ return (provider, model) => {
73
+ // A record that names no model can only be priced by the catch-all.
74
+ if (!provider || !model) return catchAll;
75
+ if (provider === lastProvider && model === lastModel) return last;
76
+ let best: ModelRates | undefined;
77
+ let bestScore = -1;
78
+ for (const entry of compiled) {
79
+ const score = matchRate(entry, provider, model);
80
+ // `>` keeps the first key in config order when two are equally specific.
81
+ if (score > bestScore) {
82
+ best = entry.value;
83
+ bestScore = score;
84
+ }
85
+ }
86
+ lastProvider = provider;
87
+ lastModel = model;
88
+ last = best;
89
+ return best;
90
+ };
54
91
  }
@@ -12,13 +12,13 @@ import { stripTerminalSequences } from "@earendil-works/pi-tui";
12
12
  /**
13
13
  * Compact a status to its icon form for a narrow bar, keeping its own colour.
14
14
  *
15
- * An icon plus a label (`⋮ plan`) keeps only the icon. An icon plus a bare
15
+ * An icon plus a label (`≡ plan`) keeps only the icon. An icon plus a bare
16
16
  * count (`↺ 2`) is a badge: it keeps the count and drops the space (`↺2`),
17
17
  * because the number is the state. The leading open and trailing reset SGR
18
18
  * sequences are reattached around the compacted text, so a red `✗1` stays red
19
19
  * instead of losing its colour exactly when the bar tightens. Requiring exactly
20
20
  * two tokens (rather than "the last token is numeric") keeps a numeric label
21
- * like `⋮ plan · 2024` from being mistaken for a count.
21
+ * like `≡ plan · 2024` from being mistaken for a count.
22
22
  */
23
23
  export function compactStatus(status: string): string {
24
24
  const prefix = status.match(/^(?:\x1b\[[0-9;]*m)*/)?.[0] ?? "";
@@ -33,7 +33,7 @@ export function compactStatus(status: string): string {
33
33
  /**
34
34
  * Drop a status's trailing ` · detail` while keeping its SGR styling.
35
35
  *
36
- * The `plan-mode` chip carries the plan file name as detail (`⋮ plan · name`);
36
+ * The `plan-mode` chip carries the plan file name as detail (`≡ plan · name`);
37
37
  * the bar shows only the mode. Matching `[^ESC]*` removes the visible suffix
38
38
  * without consuming the closing reset, so the chip's color stays balanced.
39
39
  */
@@ -18,9 +18,9 @@ import { BODY_INDENT } from "../../../lib/tui/indent.ts";
18
18
  import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
19
19
  import { paint } from "../../../lib/tui/tokens.ts";
20
20
  import { updateMarker } from "../update/format.ts";
21
- import { HEADER_CONFIG, type HelpEntry } from "./config.ts";
21
+ import { HEADER_CONFIG, type HeaderHeight, type HelpEntry } from "./config.ts";
22
22
 
23
- /** The header's shape, resolved once per apply so `render` stays cheap. */
23
+ /** The header's shape: everything but the live terminal height is resolved once per apply. */
24
24
  export interface HeaderView {
25
25
  /** Print Pi's version on the agent line. */
26
26
  showVersion: boolean;
@@ -28,6 +28,8 @@ export interface HeaderView {
28
28
  halfBlocks: boolean;
29
29
  /** Columns of left margin: Pi's output pad, so the nameplate sits on the transcript margin. */
30
30
  pad: number;
31
+ /** Terminal rows, when the host reports them; short terminals shed help rows. */
32
+ rows?: number;
31
33
  /** A newer published version of this package, when one is known. */
32
34
  latest?: string;
33
35
  }
@@ -35,6 +37,15 @@ export interface HeaderView {
35
37
  /** The mark block is four columns on both rows, so the identity column aligns. */
36
38
  const MARK_WIDTH = 4;
37
39
 
40
+ /**
41
+ * The tallest shape whose threshold the terminal meets. An unknown or impossible
42
+ * height keeps the full header, so a host without terminal rows still reads well.
43
+ */
44
+ function heightShape(rows: number | undefined): HeaderHeight {
45
+ if (rows === undefined || !Number.isFinite(rows) || rows <= 0) return HEADER_CONFIG.heights[0];
46
+ return HEADER_CONFIG.heights.find((shape) => rows >= shape.minRows) ?? HEADER_CONFIG.heights[0];
47
+ }
48
+
38
49
  /** Pad a themed mark line to the block width with plain spaces. */
39
50
  function markBlock(line: string): string {
40
51
  return line + " ".repeat(Math.max(0, MARK_WIDTH - visibleWidth(line)));
@@ -74,14 +85,15 @@ export class ByteflyHeader implements Component {
74
85
 
75
86
  /**
76
87
  * The header is static, so the lines are memoized on everything they read: the
77
- * width, the theme, the resolved shape, and the key chords the help rows print.
78
- * `keyText` is re-read every frame, so a live rebind still shows.
88
+ * width, the theme the getter returned, the resolved shape, and the key chords
89
+ * the help rows print. `keyText` is re-read every frame, so a live rebind still
90
+ * shows.
79
91
  */
80
92
  render(width: number): string[] {
81
93
  const w = Math.max(1, width);
82
94
  const theme = this.getTheme();
83
95
  const view = this.view();
84
- const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}`;
96
+ const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}:${view.rows ?? ""}`;
85
97
  const keys = this.keySignature();
86
98
  if (
87
99
  this.cachedWidth === w &&
@@ -111,6 +123,7 @@ export class ByteflyHeader implements Component {
111
123
  private build(w: number, theme: Theme, view: HeaderView): string[] {
112
124
  const { nameRole, chromeRole, detailRole, updateRole, chromeTagline, chromeName, chromeVersion, help } =
113
125
  HEADER_CONFIG;
126
+ const shape = heightShape(view.rows);
114
127
  const padWidth = Math.max(0, Math.min(Math.floor(view.pad), Math.max(0, w - 1)));
115
128
  const pad = " ".repeat(padWidth);
116
129
  const [markTop, markBottom] = markLines(theme, view.halfBlocks);
@@ -145,13 +158,17 @@ export class ByteflyHeader implements Component {
145
158
 
146
159
  // The header owns its leading blank: Pi mounts the built-in header inside
147
160
  // `Spacer(1)`s, but the `quietStartup: true` branch that hands this header the
148
- // screen adds none, and `setHeader` swaps only the header child.
161
+ // screen adds none, and `setHeader` swaps only the header child. A short
162
+ // terminal gives that row back, then sheds whole help groups.
149
163
  return [
150
- "",
164
+ ...(shape.blank ? [""] : []),
151
165
  this.identityLine(pad, markTop, agentCell, w),
152
166
  this.identityLine(pad, markBottom, chromeCell, w),
153
167
  this.rule(theme, w, pad),
154
- ...help.map((row) => this.helpLine(theme, w, row, padWidth)),
168
+ ...shape.helpRows.flatMap((index) => {
169
+ const group = help[index];
170
+ return group ? [this.helpLine(theme, w, group, padWidth)] : [];
171
+ }),
155
172
  ];
156
173
  }
157
174
 
@@ -193,6 +210,12 @@ export class ByteflyHeader implements Component {
193
210
  }
194
211
 
195
212
  /** Header factory passed to `ctx.ui.setHeader`. */
196
- export function createHeader(getTheme: () => Theme, view: () => HeaderView): (tui: TUI, theme: Theme) => Component {
197
- return () => new ByteflyHeader(getTheme, view, VERSION);
213
+ export function createHeader(
214
+ getTheme: () => Theme,
215
+ view: (rows: number | undefined) => HeaderView,
216
+ ): (tui: TUI, theme: Theme) => Component {
217
+ // Read the height on every render, so a terminal resize reflows the header
218
+ // instead of keeping the shape it was installed with. Tests may pass a bare
219
+ // object as the host, so the terminal is optional at the boundary.
220
+ return (tui) => new ByteflyHeader(getTheme, () => view(tui.terminal?.rows), VERSION);
198
221
  }
@@ -19,6 +19,16 @@ export type HelpEntry =
19
19
  | { readonly binding: AppKeybinding; readonly description: string }
20
20
  | { readonly raw: string; readonly description: string };
21
21
 
22
+ /** How much of the help block a terminal height can carry. */
23
+ export interface HeaderHeight {
24
+ /** Smallest terminal height, in rows, this shape applies to. Taller tiers come first. */
25
+ readonly minRows: number;
26
+ /** Help groups to print, by index in `help`; a group is dropped whole. */
27
+ readonly helpRows: readonly number[];
28
+ /** Open with a blank row above the nameplate. */
29
+ readonly blank: boolean;
30
+ }
31
+
22
32
  /** The header's shape and content. */
23
33
  export interface HeaderConfig {
24
34
  /** The agent's name, shown before its version. */
@@ -49,6 +59,12 @@ export interface HeaderConfig {
49
59
  readonly chromeVersion: string;
50
60
  /** Grouped help rows, printed one line each under the rule. */
51
61
  readonly help: readonly (readonly HelpEntry[])[];
62
+ /**
63
+ * The height ladder, tallest first: the first tier the terminal meets decides
64
+ * which help groups print and whether the leading blank row survives. The
65
+ * floor is `minRows: 0`, so a short terminal keeps the nameplate and rule.
66
+ */
67
+ readonly heights: readonly HeaderHeight[];
52
68
  }
53
69
 
54
70
  export const HEADER_CONFIG: HeaderConfig = {
@@ -89,4 +105,10 @@ export const HEADER_CONFIG: HeaderConfig = {
89
105
  { binding: "app.clipboard.pasteImage", description: "paste files" },
90
106
  ],
91
107
  ],
108
+ heights: [
109
+ { minRows: 28, helpRows: [0, 1, 2], blank: true },
110
+ { minRows: 22, helpRows: [0, 2], blank: true },
111
+ { minRows: 16, helpRows: [2], blank: true },
112
+ { minRows: 0, helpRows: [], blank: false },
113
+ ],
92
114
  };
@@ -55,11 +55,12 @@ export function registerHeader(pi: ExtensionAPI, updates: UpdateSource = createU
55
55
  // value the tool frame and the notice inset by. An older host without
56
56
  // `getSettings` keeps the default margin.
57
57
  const pad = pi.getSettings?.().outputPad ?? DEFAULT_HOST_PAD;
58
- const view = (): HeaderView => ({
58
+ const view = (rows: number | undefined): HeaderView => ({
59
59
  showVersion: HEADER_CONFIG.showVersion,
60
60
  halfBlocks,
61
61
  pad,
62
62
  latest: updates.latest(),
63
+ rows,
63
64
  });
64
65
  ctx.ui.setHeader(createHeader(() => ctx.ui.theme, view));
65
66
  };
@@ -4,13 +4,13 @@
4
4
  * (the two-line, width-adaptive status bar with its price calculation),
5
5
  * `message/` (the user-message nameplate), `notice/` (the shared transcript
6
6
  * notice renderer), `block/` (the unified tool-block frame), `separator/` (the
7
- * transcript turn rule), `working/` (the live elapsed counter on Pi's working
8
- * row), `panel/` (the unified `/bytefly` screen and its
7
+ * transcript turn rule), `working/` (the live run line on Pi's working row),
8
+ * `panel/` (the unified `/bytefly` screen and its
9
9
  * `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
10
10
  * probe runtime), `notify/` (OS notifications for unattended runs), and
11
- * `update/` (the published-version check behind the header's update marker).
11
+ * `update/` (Pi's package list, the host suppression, and the header marker).
12
12
  *
13
- * The header, footer, turn rule, and working timer are installed only in
13
+ * The header, footer, turn rule, and working line are installed only in
14
14
  * interactive (`tui`) sessions, and are always on; the user-message marker,
15
15
  * notice, and tool blocks register at load and take effect wherever a
16
16
  * transcript renders. `pi config` turns bytefly's chrome off. The panel and doctor work
@@ -47,7 +47,7 @@ export interface ByteflyDeps {
47
47
  doctor?: DoctorDeps;
48
48
  /** Seams for the notify capability. */
49
49
  notify?: NotifyDeps;
50
- /** Seams for the published-version check. */
50
+ /** Seams for the package-update surface. */
51
51
  update?: UpdateDeps;
52
52
  }
53
53
 
@@ -55,7 +55,7 @@ export interface ByteflyDeps {
55
55
  export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void {
56
56
  if (!isExtensionEnabled("bytefly")) return;
57
57
 
58
- // One store per load: the update check writes the newest published version and
58
+ // One store per load: the update check writes this package's new version and
59
59
  // the header reads it, so the marker and every re-install agree.
60
60
  const updates = createUpdateState();
61
61
  const header = registerHeader(pi, updates);
@@ -24,14 +24,19 @@ import { NOTICE_CONFIG } from "./config.ts";
24
24
  /** Severity order, so the rail can carry the worst one. */
25
25
  const SEVERITY_RANK: Readonly<Record<NoticeSeverity, number>> = { info: 0, ok: 1, warn: 2, bad: 3 };
26
26
 
27
+ /** A severity this module knows, or `fallback` for a value from stored data. */
28
+ function knownSeverity(value: NoticeSeverity | undefined, fallback: NoticeSeverity): NoticeSeverity {
29
+ return value !== undefined && Object.hasOwn(SEVERITY_RANK, value) ? value : fallback;
30
+ }
31
+
27
32
  /** The severity a row draws with, falling back to the notice's own. */
28
33
  function severityOf(item: NoticeItem, fallback: NoticeSeverity): NoticeSeverity {
29
- return item.severity ?? fallback;
34
+ return knownSeverity(item.severity, fallback);
30
35
  }
31
36
 
32
37
  /** The worst severity across the headline and its rows; the rail's role. */
33
38
  function worstSeverity(details: NoticeDetails): NoticeSeverity {
34
- const fallback = details.severity ?? "info";
39
+ const fallback = knownSeverity(details.severity, "info");
35
40
  let worst: NoticeSeverity = fallback;
36
41
  for (const item of details.items ?? []) {
37
42
  const severity = severityOf(item, fallback);
@@ -65,7 +70,7 @@ export function noticeLines(
65
70
  const title = paint(theme, NOTICE_CONFIG.titleRole, sanitizeLine(details.title));
66
71
  const items = details.items ?? [];
67
72
  const shown = items.slice(0, NOTICE_CONFIG.maxItems);
68
- const fallback = details.severity ?? "info";
73
+ const fallback = knownSeverity(details.severity, "info");
69
74
  const gap = " ".repeat(GLYPH_GAP);
70
75
 
71
76
  const rows = shown.map((item) => {