@gavin-hu/my-pi-agent 0.0.0-stage → 0.7.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 (314) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -2
  3. package/assets/bytefly-chrome.png +0 -0
  4. package/assets/bytefly.schema.json +769 -0
  5. package/assets/file-browse.png +0 -0
  6. package/assets/plan-review.png +0 -0
  7. package/extensions/bytefly/README.md +820 -0
  8. package/extensions/bytefly/doctor/commands.ts +68 -0
  9. package/extensions/bytefly/doctor/config.ts +47 -0
  10. package/extensions/bytefly/doctor/format.ts +188 -0
  11. package/extensions/bytefly/doctor/register.ts +54 -0
  12. package/extensions/bytefly/doctor/runtime.ts +87 -0
  13. package/extensions/bytefly/doctor/schema.ts +51 -0
  14. package/extensions/bytefly/doctor/tools.ts +113 -0
  15. package/extensions/bytefly/doctor/tui.ts +140 -0
  16. package/extensions/bytefly/footer/collect/entries.ts +137 -0
  17. package/extensions/bytefly/footer/collect/snapshot.ts +64 -0
  18. package/extensions/bytefly/footer/config.ts +35 -0
  19. package/extensions/bytefly/footer/price/config.ts +152 -0
  20. package/extensions/bytefly/footer/price/price.ts +59 -0
  21. package/extensions/bytefly/footer/price/rates.ts +54 -0
  22. package/extensions/bytefly/footer/price/schedule.ts +112 -0
  23. package/extensions/bytefly/footer/price/types.ts +53 -0
  24. package/extensions/bytefly/footer/register.ts +116 -0
  25. package/extensions/bytefly/footer/render/footer.ts +51 -0
  26. package/extensions/bytefly/footer/render/format.ts +106 -0
  27. package/extensions/bytefly/footer/render/layout.ts +139 -0
  28. package/extensions/bytefly/footer/render/lines.ts +50 -0
  29. package/extensions/bytefly/footer/render/segments.ts +281 -0
  30. package/extensions/bytefly/footer/render/status.ts +42 -0
  31. package/extensions/bytefly/footer/types.ts +104 -0
  32. package/extensions/bytefly/header/component.ts +112 -0
  33. package/extensions/bytefly/header/config.ts +77 -0
  34. package/extensions/bytefly/header/register.ts +53 -0
  35. package/extensions/bytefly/index.ts +48 -0
  36. package/extensions/bytefly/notify/config.ts +60 -0
  37. package/extensions/bytefly/notify/dispatch.ts +138 -0
  38. package/extensions/bytefly/notify/format.ts +39 -0
  39. package/extensions/bytefly/notify/register.ts +85 -0
  40. package/extensions/bytefly/notify/types.ts +28 -0
  41. package/extensions/bytefly/picker/commands.ts +73 -0
  42. package/extensions/bytefly/picker/discovery.ts +128 -0
  43. package/extensions/bytefly/picker/resources.ts +226 -0
  44. package/extensions/bytefly/picker/runtime.ts +108 -0
  45. package/extensions/bytefly/picker/toggle.ts +145 -0
  46. package/extensions/bytefly/picker/tui.ts +260 -0
  47. package/extensions/bytefly/picker/types.ts +55 -0
  48. package/extensions/bytefly/separator/component.ts +26 -0
  49. package/extensions/bytefly/separator/config.ts +24 -0
  50. package/extensions/bytefly/separator/format.ts +50 -0
  51. package/extensions/bytefly/separator/register.ts +42 -0
  52. package/extensions/bytefly/separator/types.ts +22 -0
  53. package/extensions/document/README.md +281 -0
  54. package/extensions/document/config.ts +58 -0
  55. package/extensions/document/extract/doc.ts +19 -0
  56. package/extensions/document/extract/docx.ts +18 -0
  57. package/extensions/document/extract/file.ts +45 -0
  58. package/extensions/document/extract/odt.ts +101 -0
  59. package/extensions/document/extract/pdf.ts +25 -0
  60. package/extensions/document/extract/rtf.ts +45 -0
  61. package/extensions/document/extract/vendor.d.ts +39 -0
  62. package/extensions/document/extract/xlsx.ts +88 -0
  63. package/extensions/document/formats.ts +59 -0
  64. package/extensions/document/index.ts +17 -0
  65. package/extensions/document/paging.ts +110 -0
  66. package/extensions/document/render.ts +105 -0
  67. package/extensions/document/schema.ts +50 -0
  68. package/extensions/document/tool.ts +152 -0
  69. package/extensions/file-browse/README.md +269 -0
  70. package/extensions/file-browse/client.ts +550 -0
  71. package/extensions/file-browse/config.ts +82 -0
  72. package/extensions/file-browse/files.ts +433 -0
  73. package/extensions/file-browse/fuzzy.ts +75 -0
  74. package/extensions/file-browse/git.ts +74 -0
  75. package/extensions/file-browse/html.ts +346 -0
  76. package/extensions/file-browse/icons.ts +119 -0
  77. package/extensions/file-browse/index.ts +149 -0
  78. package/extensions/file-browse/markdown.ts +592 -0
  79. package/extensions/file-browse/paths.ts +101 -0
  80. package/extensions/file-browse/router.ts +289 -0
  81. package/extensions/file-browse/server.ts +104 -0
  82. package/extensions/file-open/README.md +135 -0
  83. package/extensions/file-open/index.ts +93 -0
  84. package/extensions/file-open/open.ts +102 -0
  85. package/extensions/file-open/schema.ts +31 -0
  86. package/extensions/goal/README.md +241 -0
  87. package/extensions/goal/clarify-parse.ts +203 -0
  88. package/extensions/goal/clarify-prompt.ts +80 -0
  89. package/extensions/goal/clarify.ts +234 -0
  90. package/extensions/goal/commands.ts +220 -0
  91. package/extensions/goal/config.ts +35 -0
  92. package/extensions/goal/format.ts +111 -0
  93. package/extensions/goal/index.ts +77 -0
  94. package/extensions/goal/runtime.ts +90 -0
  95. package/extensions/goal/schema.ts +105 -0
  96. package/extensions/goal/state.ts +69 -0
  97. package/extensions/goal/tools.ts +136 -0
  98. package/extensions/goal/tui.ts +97 -0
  99. package/extensions/goal/types.ts +42 -0
  100. package/extensions/job/README.md +309 -0
  101. package/extensions/job/commands.ts +74 -0
  102. package/extensions/job/config.ts +68 -0
  103. package/extensions/job/format.ts +256 -0
  104. package/extensions/job/index.ts +140 -0
  105. package/extensions/job/logs.ts +56 -0
  106. package/extensions/job/paths.ts +31 -0
  107. package/extensions/job/process.ts +106 -0
  108. package/extensions/job/registry.ts +265 -0
  109. package/extensions/job/reservations.ts +82 -0
  110. package/extensions/job/runtime.ts +846 -0
  111. package/extensions/job/schema.ts +148 -0
  112. package/extensions/job/session.ts +116 -0
  113. package/extensions/job/status.ts +69 -0
  114. package/extensions/job/store.ts +103 -0
  115. package/extensions/job/tools.ts +295 -0
  116. package/extensions/job/tui.ts +486 -0
  117. package/extensions/job/types.ts +92 -0
  118. package/extensions/job/ui.ts +99 -0
  119. package/extensions/job/waiters.ts +46 -0
  120. package/extensions/memory/README.md +221 -0
  121. package/extensions/memory/commands.ts +190 -0
  122. package/extensions/memory/config.ts +35 -0
  123. package/extensions/memory/errors.ts +9 -0
  124. package/extensions/memory/format.ts +141 -0
  125. package/extensions/memory/index.ts +59 -0
  126. package/extensions/memory/runtime.ts +199 -0
  127. package/extensions/memory/schema.ts +86 -0
  128. package/extensions/memory/store.ts +231 -0
  129. package/extensions/memory/tools.ts +165 -0
  130. package/extensions/memory/tui.ts +430 -0
  131. package/extensions/memory/types.ts +38 -0
  132. package/extensions/plan/README.md +417 -0
  133. package/extensions/plan/blocks.ts +197 -0
  134. package/extensions/plan/commands.ts +134 -0
  135. package/extensions/plan/feedback.ts +157 -0
  136. package/extensions/plan/git-bash.ts +320 -0
  137. package/extensions/plan/index.ts +179 -0
  138. package/extensions/plan/path-guard.ts +101 -0
  139. package/extensions/plan/plans.ts +267 -0
  140. package/extensions/plan/policy.ts +73 -0
  141. package/extensions/plan/runtime.ts +195 -0
  142. package/extensions/plan/steps.ts +228 -0
  143. package/extensions/plan/tools.ts +605 -0
  144. package/extensions/plan/tui.ts +391 -0
  145. package/extensions/plan/types.ts +67 -0
  146. package/extensions/plan/web-client.ts +433 -0
  147. package/extensions/plan/web-page.ts +169 -0
  148. package/extensions/plan/web-router.ts +155 -0
  149. package/extensions/plan/web.ts +267 -0
  150. package/extensions/question/README.md +189 -0
  151. package/extensions/question/answers.ts +119 -0
  152. package/extensions/question/dialogs.ts +109 -0
  153. package/extensions/question/index.ts +28 -0
  154. package/extensions/question/schema.ts +119 -0
  155. package/extensions/question/tools.ts +122 -0
  156. package/extensions/question/tui-state.ts +240 -0
  157. package/extensions/question/tui.ts +324 -0
  158. package/extensions/question/types.ts +61 -0
  159. package/extensions/rewind/README.md +202 -0
  160. package/extensions/rewind/commands.ts +74 -0
  161. package/extensions/rewind/config.ts +71 -0
  162. package/extensions/rewind/format.ts +110 -0
  163. package/extensions/rewind/git.ts +177 -0
  164. package/extensions/rewind/index.ts +60 -0
  165. package/extensions/rewind/policy.ts +42 -0
  166. package/extensions/rewind/queue.ts +27 -0
  167. package/extensions/rewind/restore.ts +96 -0
  168. package/extensions/rewind/rewind-tui.ts +135 -0
  169. package/extensions/rewind/rewind.ts +146 -0
  170. package/extensions/rewind/runtime.ts +174 -0
  171. package/extensions/rewind/snapshot-run.ts +103 -0
  172. package/extensions/rewind/snapshot.ts +80 -0
  173. package/extensions/rewind/status.ts +67 -0
  174. package/extensions/rewind/store.ts +142 -0
  175. package/extensions/rewind/temp-index.ts +87 -0
  176. package/extensions/rewind/timeline.ts +119 -0
  177. package/extensions/rewind/types.ts +58 -0
  178. package/extensions/subagent/README.md +310 -0
  179. package/extensions/subagent/agents.ts +454 -0
  180. package/extensions/subagent/format.ts +126 -0
  181. package/extensions/subagent/index.ts +181 -0
  182. package/extensions/subagent/invocation.ts +57 -0
  183. package/extensions/subagent/orchestrate.ts +182 -0
  184. package/extensions/subagent/render.ts +491 -0
  185. package/extensions/subagent/run.ts +212 -0
  186. package/extensions/subagent/schema.ts +102 -0
  187. package/extensions/subagent/stream.ts +144 -0
  188. package/extensions/subagent/types.ts +79 -0
  189. package/extensions/todo/README.md +230 -0
  190. package/extensions/todo/commands.ts +41 -0
  191. package/extensions/todo/config.ts +29 -0
  192. package/extensions/todo/format.ts +142 -0
  193. package/extensions/todo/index.ts +100 -0
  194. package/extensions/todo/nudge.ts +64 -0
  195. package/extensions/todo/runtime.ts +149 -0
  196. package/extensions/todo/schema.ts +125 -0
  197. package/extensions/todo/state.ts +65 -0
  198. package/extensions/todo/tools.ts +136 -0
  199. package/extensions/todo/tui.ts +170 -0
  200. package/extensions/todo/types.ts +33 -0
  201. package/extensions/watch/README.md +214 -0
  202. package/extensions/watch/commands.ts +144 -0
  203. package/extensions/watch/config.ts +59 -0
  204. package/extensions/watch/format.ts +212 -0
  205. package/extensions/watch/index.ts +129 -0
  206. package/extensions/watch/process.ts +63 -0
  207. package/extensions/watch/runtime.ts +525 -0
  208. package/extensions/watch/schema.ts +177 -0
  209. package/extensions/watch/tools.ts +278 -0
  210. package/extensions/watch/tui.ts +427 -0
  211. package/extensions/watch/types.ts +83 -0
  212. package/extensions/watch/ui.ts +97 -0
  213. package/extensions/web-access/README.md +347 -0
  214. package/extensions/web-access/config.ts +36 -0
  215. package/extensions/web-access/fetch/cache.ts +78 -0
  216. package/extensions/web-access/fetch/config.ts +81 -0
  217. package/extensions/web-access/fetch/extract.ts +124 -0
  218. package/extensions/web-access/fetch/find.ts +158 -0
  219. package/extensions/web-access/fetch/format.ts +117 -0
  220. package/extensions/web-access/fetch/page.ts +169 -0
  221. package/extensions/web-access/fetch/pdf.ts +45 -0
  222. package/extensions/web-access/fetch/schema.ts +192 -0
  223. package/extensions/web-access/fetch/ssrf.ts +191 -0
  224. package/extensions/web-access/fetch/tool.ts +238 -0
  225. package/extensions/web-access/fetch/types.ts +26 -0
  226. package/extensions/web-access/html.ts +41 -0
  227. package/extensions/web-access/http.ts +133 -0
  228. package/extensions/web-access/index.ts +28 -0
  229. package/extensions/web-access/search/config.ts +115 -0
  230. package/extensions/web-access/search/format.ts +65 -0
  231. package/extensions/web-access/search/json.ts +75 -0
  232. package/extensions/web-access/search/provider.ts +38 -0
  233. package/extensions/web-access/search/providers/brave.ts +76 -0
  234. package/extensions/web-access/search/providers/duckduckgo.ts +109 -0
  235. package/extensions/web-access/search/providers/searxng.ts +98 -0
  236. package/extensions/web-access/search/registry.ts +49 -0
  237. package/extensions/web-access/search/schema.ts +65 -0
  238. package/extensions/web-access/search/search.ts +129 -0
  239. package/extensions/web-access/search/tool.ts +112 -0
  240. package/extensions/web-access/search/types.ts +32 -0
  241. package/extensions/web-access/text.ts +26 -0
  242. package/extensions/web-access/transcript.ts +20 -0
  243. package/extensions/wechat/README.md +273 -0
  244. package/extensions/wechat/bridge.ts +577 -0
  245. package/extensions/wechat/client.ts +411 -0
  246. package/extensions/wechat/commands.ts +220 -0
  247. package/extensions/wechat/config.ts +88 -0
  248. package/extensions/wechat/credentials.ts +89 -0
  249. package/extensions/wechat/format.ts +76 -0
  250. package/extensions/wechat/index.ts +175 -0
  251. package/extensions/wechat/interaction.ts +130 -0
  252. package/extensions/wechat/lock.ts +86 -0
  253. package/extensions/wechat/login-screen.ts +91 -0
  254. package/extensions/wechat/login.ts +130 -0
  255. package/extensions/wechat/media.ts +103 -0
  256. package/extensions/wechat/outbound.ts +102 -0
  257. package/extensions/wechat/prompt.ts +104 -0
  258. package/extensions/wechat/qrcode.ts +46 -0
  259. package/extensions/wechat/remote-ui.ts +97 -0
  260. package/extensions/wechat/slash.ts +114 -0
  261. package/extensions/wechat/state.ts +71 -0
  262. package/extensions/wechat/status-screen.ts +176 -0
  263. package/extensions/wechat/tools.ts +109 -0
  264. package/extensions/wechat/types.ts +170 -0
  265. package/extensions/worktree/README.md +278 -0
  266. package/extensions/worktree/commands.ts +110 -0
  267. package/extensions/worktree/config.ts +107 -0
  268. package/extensions/worktree/enter.ts +273 -0
  269. package/extensions/worktree/errors.ts +18 -0
  270. package/extensions/worktree/exit.ts +122 -0
  271. package/extensions/worktree/git.ts +429 -0
  272. package/extensions/worktree/guard.ts +566 -0
  273. package/extensions/worktree/include.ts +147 -0
  274. package/extensions/worktree/index.ts +221 -0
  275. package/extensions/worktree/lifecycle.ts +14 -0
  276. package/extensions/worktree/prune.ts +99 -0
  277. package/extensions/worktree/registry.ts +221 -0
  278. package/extensions/worktree/root-tools.ts +162 -0
  279. package/extensions/worktree/runtime.ts +135 -0
  280. package/extensions/worktree/state.ts +65 -0
  281. package/extensions/worktree/status.ts +115 -0
  282. package/extensions/worktree/tools.ts +147 -0
  283. package/lib/README.md +49 -0
  284. package/lib/browser.ts +40 -0
  285. package/lib/config.ts +186 -0
  286. package/lib/env.ts +41 -0
  287. package/lib/escape.ts +27 -0
  288. package/lib/events.ts +40 -0
  289. package/lib/format.ts +76 -0
  290. package/lib/git/index.ts +7 -0
  291. package/lib/git/read.ts +76 -0
  292. package/lib/git/runner.ts +80 -0
  293. package/lib/highlight.ts +1000 -0
  294. package/lib/interaction.ts +132 -0
  295. package/lib/list-cursor.ts +222 -0
  296. package/lib/path.ts +49 -0
  297. package/lib/pi-invocation.ts +71 -0
  298. package/lib/policy.ts +67 -0
  299. package/lib/rails.ts +91 -0
  300. package/lib/requirements.ts +431 -0
  301. package/lib/shell.ts +33 -0
  302. package/lib/tool-names.ts +32 -0
  303. package/lib/tui.ts +184 -0
  304. package/lib/ui.ts +105 -0
  305. package/lib/web/base.ts +18 -0
  306. package/lib/web/components.ts +122 -0
  307. package/lib/web/index.ts +12 -0
  308. package/lib/web/layout.ts +30 -0
  309. package/lib/web/markup.ts +99 -0
  310. package/lib/web/runtime.ts +68 -0
  311. package/lib/web/tokens.ts +128 -0
  312. package/package.json +134 -4
  313. package/themes/bytefly-dark.json +91 -0
  314. package/themes/bytefly-light.json +91 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Peak / off-peak evaluation for a priced record.
3
+ *
4
+ * A window is stated in a configured IANA zone, because the tariff a provider
5
+ * publishes is stated in its own local time. `isPeak` takes the formatter as an
6
+ * argument: building one is the expensive part, so a pricer builds it once and
7
+ * reuses it across a branch, keeping this module pure and cheap to call.
8
+ */
9
+
10
+ import { asRecord } from "../../../../lib/config.ts";
11
+ import type { PeakSchedule, PeakWindow } from "./types.ts";
12
+
13
+ const TIME_OF_DAY = /^(\d{1,2}):(\d{2})$/;
14
+ /** Every ISO weekday; the meaning of an omitted `days`. */
15
+ const ALL_DAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];
16
+
17
+ /** The `en-US` short weekday names `peakFormatter` can produce. */
18
+ const WEEKDAYS: Readonly<Record<string, number>> = {
19
+ Mon: 1,
20
+ Tue: 2,
21
+ Wed: 3,
22
+ Thu: 4,
23
+ Fri: 5,
24
+ Sat: 6,
25
+ Sun: 7,
26
+ };
27
+
28
+ /**
29
+ * Weekday numbers for a window.
30
+ *
31
+ * Omitted or empty means every day. A non-empty list is filtered to valid
32
+ * weekdays; a list yielding none means the window itself is malformed, so it is
33
+ * dropped rather than silently widened to the whole week.
34
+ */
35
+ function parseDays(raw: unknown): readonly number[] | null {
36
+ if (raw === undefined) return ALL_DAYS;
37
+ if (!Array.isArray(raw)) return null;
38
+ if (raw.length === 0) return ALL_DAYS;
39
+ const days = new Set<number>();
40
+ for (const value of raw) {
41
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 7) continue;
42
+ days.add(value);
43
+ }
44
+ if (days.size === 0) return null;
45
+ return [...days].sort((a, b) => a - b);
46
+ }
47
+
48
+ /** Minutes since midnight for a `HH:MM` string, or `null`. */
49
+ function parseTimeOfDay(raw: unknown): number | null {
50
+ if (typeof raw !== "string") return null;
51
+ const match = TIME_OF_DAY.exec(raw.trim());
52
+ if (!match) return null;
53
+ const hours = Number(match[1]);
54
+ const minutes = Number(match[2]);
55
+ if (hours > 23 || minutes > 59) return null;
56
+ return hours * 60 + minutes;
57
+ }
58
+
59
+ /**
60
+ * Parse one peak window; a malformed window is dropped rather than guessed.
61
+ *
62
+ * A `to` equal to `from` states no duration, so it is treated as malformed.
63
+ */
64
+ export function parseWindow(raw: unknown): PeakWindow | null {
65
+ const value = asRecord(raw);
66
+ if (!value) return null;
67
+ const days = parseDays(value.days);
68
+ const from = parseTimeOfDay(value.from);
69
+ const to = parseTimeOfDay(value.to);
70
+ if (!days || from === null || to === null || from === to) return null;
71
+ return { days, from, to };
72
+ }
73
+
74
+ /** A formatter resolving the weekday and h23 time of day in `timezone`. */
75
+ export function peakFormatter(timezone: string): Intl.DateTimeFormat {
76
+ return new Intl.DateTimeFormat("en-US", {
77
+ timeZone: timezone,
78
+ weekday: "short",
79
+ hour: "2-digit",
80
+ minute: "2-digit",
81
+ hourCycle: "h23",
82
+ });
83
+ }
84
+
85
+ /** Whether the local time of `at` falls inside a peak window. */
86
+ export function isPeak(schedule: PeakSchedule, at: number, format: Intl.DateTimeFormat): boolean {
87
+ // No windows stated means the rate is flat, so every moment is peak.
88
+ if (schedule.windows.length === 0) return true;
89
+ const parts = format.formatToParts(at);
90
+ let weekday = 0;
91
+ let minutes = 0;
92
+ for (const part of parts) {
93
+ if (part.type === "weekday") weekday = WEEKDAYS[part.value] ?? 0;
94
+ else if (part.type === "hour") minutes = Number(part.value) * 60;
95
+ else if (part.type === "minute") minutes += Number(part.value);
96
+ }
97
+ return schedule.windows.some((window) => covers(window, weekday, minutes));
98
+ }
99
+
100
+ /**
101
+ * Whether a weekday and time of day fall inside a window.
102
+ *
103
+ * `from` is inclusive and `to` exclusive. A `to` before `from` wraps midnight,
104
+ * and the tail after midnight belongs to the day the window opened, so a
105
+ * Monday 22:00–02:00 window covers Tuesday 00:00–02:00 too.
106
+ */
107
+ function covers(window: PeakWindow, weekday: number, minutes: number): boolean {
108
+ if (window.to > window.from) return window.days.includes(weekday) && minutes >= window.from && minutes < window.to;
109
+ if (minutes >= window.from) return window.days.includes(weekday);
110
+ const previous = weekday === 1 ? 7 : weekday - 1;
111
+ return window.days.includes(previous) && minutes < window.to;
112
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Price-configuration types for the footer.
3
+ *
4
+ * These describe `bytefly.json` and the rate lookup only: currency, per-model
5
+ * rates, and the peak schedule. The footer's own data model lives in
6
+ * `../types.ts`.
7
+ */
8
+
9
+ /** How a cost is rendered. */
10
+ export interface Currency {
11
+ /** Printed before the number, e.g. `$` or `¥`. */
12
+ symbol: string;
13
+ /** Digits after the decimal point. */
14
+ decimals: number;
15
+ }
16
+
17
+ /** Per-million-token rates for one model, expressed in the configured currency. */
18
+ export interface ModelRates {
19
+ input: number;
20
+ output: number;
21
+ cacheRead: number;
22
+ cacheWrite: number;
23
+ /** Multiplier applied outside every peak window; `1` means no discount. */
24
+ offPeakMultiplier: number;
25
+ }
26
+
27
+ /** One peak window in local time, as minutes since midnight. */
28
+ export interface PeakWindow {
29
+ /** ISO weekday numbers, 1 = Monday .. 7 = Sunday. */
30
+ days: readonly number[];
31
+ /** Inclusive start, in minutes since local midnight. */
32
+ from: number;
33
+ /** Exclusive end; a value `<= from` wraps past midnight. */
34
+ to: number;
35
+ }
36
+
37
+ /** When a provider charges its peak rate. */
38
+ export interface PeakSchedule {
39
+ /** IANA zone the windows are stated in. */
40
+ timezone: string;
41
+ /** No windows means every moment is peak, so a rate is flat. */
42
+ windows: readonly PeakWindow[];
43
+ }
44
+
45
+ /** Everything the price calculation is configured with. */
46
+ export interface ByteflyConfig {
47
+ currency: Currency;
48
+ /** Fall back to Pi's recorded cost for a record that no rate matches. */
49
+ fallbackToRecordedCost: boolean;
50
+ /** Rates keyed by `provider/model`, matched through wildcards. */
51
+ rates: Readonly<Record<string, ModelRates>>;
52
+ peak: PeakSchedule;
53
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The footer surface: the two-line status bar, the `/bytefly` surface toggle,
3
+ * and the session lifecycle that installs and restores the footer.
4
+ *
5
+ * The footer is installed only in interactive (`tui`) sessions, and the price
6
+ * configuration is read once per install, so toggling the footer — or starting
7
+ * a new session — is how an edited `bytefly.json` is picked up. Nothing watches
8
+ * the file.
9
+ *
10
+ * `/bytefly` also drives the header surface, injected by the composition root
11
+ * so this folder never imports `header/`.
12
+ */
13
+
14
+ import { homedir } from "node:os";
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { createSnapshotReader } from "./collect/snapshot.ts";
17
+ import { loadByteflyConfig } from "./price/config.ts";
18
+ import { createFooter } from "./render/footer.ts";
19
+
20
+ /** A bytefly surface the `/bytefly` command can enable or disable. */
21
+ export interface ChromeSurface {
22
+ /** Install the surface, or restore Pi's built-in one, from the current state. */
23
+ apply(ctx: ExtensionContext): void;
24
+ /** Whether the surface is currently on. */
25
+ isEnabled(): boolean;
26
+ /** Set the enabled flag without installing. */
27
+ setEnabled(enabled: boolean): void;
28
+ }
29
+
30
+ /** Host seams for the footer surface. */
31
+ export interface FooterDeps {
32
+ /** The header surface, toggled by `/bytefly header` and the master switch. */
33
+ header?: ChromeSurface;
34
+ }
35
+
36
+ /** Register `/bytefly [header|footer|status]` and the install/restore lifecycle. */
37
+ export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
38
+ let enabled = true;
39
+
40
+ const install = (ctx: ExtensionContext): void => {
41
+ if (ctx.mode !== "tui") return;
42
+ const config = loadByteflyConfig(ctx.cwd);
43
+ const read = createSnapshotReader(config);
44
+
45
+ if (enabled) {
46
+ ctx.ui.setFooter((tui, _theme, footerData) =>
47
+ createFooter(
48
+ tui,
49
+ footerData,
50
+ () => read(ctx, footerData),
51
+ () => ctx.ui.theme,
52
+ homedir(),
53
+ ),
54
+ );
55
+ } else {
56
+ ctx.ui.setFooter(undefined);
57
+ }
58
+ };
59
+
60
+ const header = deps.header;
61
+ const headerState = (): string => (header?.isEnabled() ? "on" : "off");
62
+
63
+ pi.registerCommand("bytefly", {
64
+ description: "Toggle the TUI bytefly chrome: /bytefly [header|footer|status]",
65
+ getArgumentCompletions: (prefix: string) => {
66
+ const action = prefix.trim();
67
+ const options = ["footer", "header", "status"].filter((option) => option.startsWith(action));
68
+ return options.length > 0 ? options.map((value) => ({ value, label: value })) : null;
69
+ },
70
+ handler: async (args, ctx) => {
71
+ if (ctx.mode !== "tui") return;
72
+ const sub = args.trim().toLowerCase();
73
+
74
+ if (sub === "status") {
75
+ ctx.ui.notify(`bytefly: header ${headerState()}, footer ${enabled ? "on" : "off"}`, "info");
76
+ return;
77
+ }
78
+ if (sub === "header") {
79
+ if (!header) {
80
+ ctx.ui.notify("bytefly: header surface is not available", "warning");
81
+ return;
82
+ }
83
+ header.setEnabled(!header.isEnabled());
84
+ header.apply(ctx);
85
+ ctx.ui.notify(`bytefly header ${headerState()}`, "info");
86
+ return;
87
+ }
88
+ if (sub === "footer") {
89
+ enabled = !enabled;
90
+ install(ctx);
91
+ ctx.ui.notify(`bytefly footer ${enabled ? "on" : "off"}`, "info");
92
+ return;
93
+ }
94
+ if (sub === "") {
95
+ // The master switch brings both surfaces to one state.
96
+ const next = !(enabled && (header?.isEnabled() ?? true));
97
+ enabled = next;
98
+ install(ctx);
99
+ if (header) {
100
+ header.setEnabled(next);
101
+ header.apply(ctx);
102
+ }
103
+ ctx.ui.notify(`bytefly ${next ? "on" : "off"}`, "info");
104
+ return;
105
+ }
106
+ ctx.ui.notify("usage: /bytefly [header|footer|status]", "warning");
107
+ },
108
+ });
109
+
110
+ pi.on("session_start", (_event, ctx) => install(ctx));
111
+ pi.on("session_tree", (_event, ctx) => install(ctx));
112
+ pi.on("session_shutdown", (_event, ctx) => {
113
+ if (ctx.mode !== "tui") return;
114
+ ctx.ui.setFooter(undefined);
115
+ });
116
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The custom footer component installed with `ctx.ui.setFooter()`.
3
+ *
4
+ * The component reads a fresh snapshot on every render and resolves the theme
5
+ * through a getter, so a theme change or a settings change is picked up without
6
+ * rebuilding the component. `createFooter` also subscribes to git branch
7
+ * changes and returns the unsubscribe callback as `dispose`.
8
+ */
9
+
10
+ import type { Theme } from "@earendil-works/pi-coding-agent";
11
+ import type { Component } from "@earendil-works/pi-tui";
12
+ import { renderLine } from "./layout.ts";
13
+ import { buildLines } from "./lines.ts";
14
+ import type { FooterData, StatusSnapshot, TuiLike } from "../types.ts";
15
+
16
+ /** A two-line, width-adaptive status bar. */
17
+ class StatusBar implements Component {
18
+ /** Unsubscribes the branch watcher; set by `createFooter`. */
19
+ dispose?: () => void;
20
+
21
+ constructor(
22
+ private readonly read: () => StatusSnapshot,
23
+ private readonly getTheme: () => Theme,
24
+ private readonly home: string | undefined,
25
+ ) {}
26
+
27
+ invalidate(): void {
28
+ // The bar recomputes from a fresh snapshot each render, so there is
29
+ // nothing cached to clear. Theme-dependent strings are built in render.
30
+ }
31
+
32
+ render(width: number): string[] {
33
+ const snapshot = this.read();
34
+ const theme = this.getTheme();
35
+ const target = Math.max(1, width);
36
+ return buildLines(snapshot, theme, this.home).map((line) => renderLine(line, target, theme));
37
+ }
38
+ }
39
+
40
+ /** Factory passed to `ctx.ui.setFooter()`. */
41
+ export function createFooter(
42
+ tui: TuiLike,
43
+ footerData: FooterData,
44
+ read: () => StatusSnapshot,
45
+ getTheme: () => Theme,
46
+ home: string | undefined,
47
+ ): Component & { dispose?(): void } {
48
+ const bar = new StatusBar(read, getTheme, home);
49
+ bar.dispose = footerData.onBranchChange(() => tui.requestRender());
50
+ return bar;
51
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Pure formatting helpers for the status bar.
3
+ *
4
+ * These never touch the terminal; they turn numbers and paths into the short
5
+ * strings the bar shows. Styling happens in `lines.ts`.
6
+ */
7
+
8
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
9
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
10
+ import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
11
+ import { shortenPath } from "../../../../lib/format.ts";
12
+ import { CONFIG } from "../config.ts";
13
+ import { DEFAULT_CURRENCY } from "../price/config.ts";
14
+ import type { Currency } from "../price/types.ts";
15
+
16
+ /** Theme tokens for the reasoning-effort indicator. */
17
+ export type ThinkingColor =
18
+ | "thinkingOff"
19
+ | "thinkingMinimal"
20
+ | "thinkingLow"
21
+ | "thinkingMedium"
22
+ | "thinkingHigh"
23
+ | "thinkingXhigh"
24
+ | "thinkingMax";
25
+
26
+ /**
27
+ * A cost in the configured currency, at its configured precision.
28
+ *
29
+ * A positive amount below one minor unit never renders as zero — it shows
30
+ * `¥<0.01` — so a small cost is never reported as free.
31
+ */
32
+ export function formatCost(cost: number, currency: Currency = DEFAULT_CURRENCY): string {
33
+ const { symbol, decimals } = currency;
34
+ if (!Number.isFinite(cost) || cost <= 0) return `${symbol}${(0).toFixed(decimals)}`;
35
+ const minimum = 10 ** -decimals;
36
+ if (cost < minimum) return `${symbol}<${minimum.toFixed(decimals)}`;
37
+ return `${symbol}${cost.toFixed(decimals)}`;
38
+ }
39
+
40
+ /** Rounded percentage, or `?` when the value is unknown. */
41
+ export function formatPercent(percent: number | null): string {
42
+ return percent === null ? "?" : `${Math.round(percent)}%`;
43
+ }
44
+
45
+ /** The final path segment. */
46
+ function basename(path: string): string {
47
+ const parts = path.split("/").filter(Boolean);
48
+ return parts.at(-1) ?? path;
49
+ }
50
+
51
+ /**
52
+ * Shorten a working directory.
53
+ * `0` = full (`~/repo/project`), `1` = `~/project`, `2` = `project`.
54
+ */
55
+ export function formatCwd(cwd: string, home: string | undefined, level: 0 | 1 | 2): string {
56
+ // `shortenPath` normalizes separators and replaces the home prefix, so the
57
+ // level-1/2 logic below only splits an already-normalized path.
58
+ const shortened = shortenPath(cwd, home);
59
+ if (level === 0) return shortened;
60
+ const base = basename(shortened);
61
+ if (level === 2) return base;
62
+ if (shortened === "~") return "~";
63
+ if (shortened.startsWith("~/")) return `~/${base}`;
64
+ const parts = shortened.split("/").filter(Boolean);
65
+ return parts.length <= 2 ? shortened : `…/${parts.slice(-2).join("/")}`;
66
+ }
67
+
68
+ /** Filled/empty block counts for a percentage across `blocks` cells. */
69
+ export function computeGauge(percent: number | null, blocks: number): { filled: number; empty: number } {
70
+ if (blocks <= 0) return { filled: 0, empty: 0 };
71
+ if (percent === null) return { filled: 0, empty: blocks };
72
+ const clamped = Math.max(0, Math.min(100, percent));
73
+ // Floor so the bar is only full at 100%; force one block for any non-zero
74
+ // usage so a small percentage is still visible.
75
+ const filled = Math.max(clamped > 0 ? 1 : 0, Math.floor((clamped / 100) * blocks));
76
+ return { filled, empty: blocks - filled };
77
+ }
78
+
79
+ /** Gauge color for a context percentage. Neutral until it crosses a threshold. */
80
+ export function contextColor(percent: number | null): ThemeColor {
81
+ if (percent === null) return "muted";
82
+ if (percent > CONFIG.thresholds.danger) return "error";
83
+ if (percent > CONFIG.thresholds.warn) return "warning";
84
+ return "muted";
85
+ }
86
+
87
+ const THINKING_TOKENS: Record<ThinkingLevel, ThinkingColor> = {
88
+ off: "thinkingOff",
89
+ minimal: "thinkingMinimal",
90
+ low: "thinkingLow",
91
+ medium: "thinkingMedium",
92
+ high: "thinkingHigh",
93
+ xhigh: "thinkingXhigh",
94
+ max: "thinkingMax",
95
+ };
96
+
97
+ /** Truncate to `max` display columns, counting wide characters correctly. */
98
+ export function truncateLabel(label: string, max: number): string {
99
+ return stripTerminalSequences(truncateToWidth(label, max, "…"));
100
+ }
101
+
102
+ /** Theme token for a reasoning-effort level. Mirrors Pi's own mapping; the
103
+ * `thinkingOff` fallback only guards against an unknown runtime value. */
104
+ export function thinkingColor(level: ThinkingLevel): ThinkingColor {
105
+ return THINKING_TOKENS[level] ?? "thinkingOff";
106
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Width-aware rendering for one footer line.
3
+ *
4
+ * A line has a left and a right zone. On a wide terminal the right zone is
5
+ * right-aligned; when space runs out it folds next to the left zone, and then
6
+ * segments shrink and drop from the highest weight down. Terminal-width
7
+ * measurement uses `visibleWidth`, so ANSI styling and wide characters count
8
+ * correctly.
9
+ */
10
+
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { CONFIG } from "../config.ts";
14
+ import type { LineSpec, Segment } from "../types.ts";
15
+
16
+ type Zone = "left" | "right";
17
+
18
+ interface State {
19
+ segment: Segment;
20
+ zone: Zone;
21
+ form: number;
22
+ dropped: boolean;
23
+ /** Rendered before the segment; shrinks to a single space as a last resort. */
24
+ separator: string;
25
+ /** True once the separator has been collapsed to a single space. */
26
+ separatorCompact: boolean;
27
+ order: number;
28
+ }
29
+
30
+ function makeStates(spec: LineSpec): State[] {
31
+ const states: State[] = [];
32
+ let order = 0;
33
+ const push = (segment: Segment, zone: Zone): void => {
34
+ states.push({
35
+ segment,
36
+ zone,
37
+ form: 0,
38
+ dropped: false,
39
+ separator: segment.separator,
40
+ separatorCompact: false,
41
+ order: order++,
42
+ });
43
+ };
44
+ for (const segment of spec.left) push(segment, "left");
45
+ for (const segment of spec.right) push(segment, "right");
46
+ return states;
47
+ }
48
+
49
+ function composeZone(states: State[], zone: Zone): string {
50
+ let out = "";
51
+ let first = true;
52
+ for (const state of states) {
53
+ if (state.zone !== zone || state.dropped) continue;
54
+ const text = state.segment.forms[Math.min(state.form, state.segment.forms.length - 1)] ?? "";
55
+ if (!text) continue;
56
+ out += (first ? "" : state.separator) + text;
57
+ first = false;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function assemble(states: State[], width: number): string {
63
+ const left = composeZone(states, "left");
64
+ const right = composeZone(states, "right");
65
+ if (!right) return left;
66
+ if (!left) return " ".repeat(Math.max(0, width - visibleWidth(right))) + right;
67
+
68
+ const leftWidth = visibleWidth(left);
69
+ const rightWidth = visibleWidth(right);
70
+ if (leftWidth + CONFIG.minGap + rightWidth <= width) {
71
+ return left + " ".repeat(width - leftWidth - rightWidth) + right;
72
+ }
73
+ return `${left}${" ".repeat(CONFIG.minGap)}${right}`;
74
+ }
75
+
76
+ /** Advance the form of, or drop, the lowest-priority segment that can change. */
77
+ function reduceOnce(states: State[]): boolean {
78
+ let best: State | null = null;
79
+ for (const state of states) {
80
+ if (state.dropped) continue;
81
+ const atFloor = state.form >= state.segment.forms.length - 1;
82
+ if (atFloor && !state.segment.droppable) continue;
83
+ if (
84
+ !best ||
85
+ state.segment.weight > best.segment.weight ||
86
+ (state.segment.weight === best.segment.weight && state.order > best.order)
87
+ ) {
88
+ best = state;
89
+ }
90
+ }
91
+
92
+ if (!best) return false;
93
+ if (best.form < best.segment.forms.length - 1) best.form++;
94
+ else best.dropped = true;
95
+ return true;
96
+ }
97
+
98
+ /** Collapse the lowest-priority padded separator to a single space, as a last
99
+ * resort. A single space is kept so adjacent segments never collide; the
100
+ * first visible segment in each zone has no separator to collapse. */
101
+ function collapseSeparator(states: State[]): boolean {
102
+ const firstLeft = states.find((state) => state.zone === "left" && !state.dropped);
103
+ const firstRight = states.find((state) => state.zone === "right" && !state.dropped);
104
+ let best: State | null = null;
105
+ for (const state of states) {
106
+ if (state.dropped || !state.separator || state.separatorCompact) continue;
107
+ if (state === firstLeft || state === firstRight) continue;
108
+ if (
109
+ !best ||
110
+ state.segment.weight > best.segment.weight ||
111
+ (state.segment.weight === best.segment.weight && state.order > best.order)
112
+ ) {
113
+ best = state;
114
+ }
115
+ }
116
+ if (!best) return false;
117
+ best.separator = " ";
118
+ best.separatorCompact = true;
119
+ return true;
120
+ }
121
+
122
+ /** Render one line to a single string no wider than `width`. */
123
+ export function renderLine(spec: LineSpec, width: number, theme: Theme): string {
124
+ const target = Math.max(1, width);
125
+ const states = makeStates(spec);
126
+
127
+ let guard = 0;
128
+ while (visibleWidth(assemble(states, target)) > target && guard++ < states.length * 8 + 16) {
129
+ if (!reduceOnce(states)) break;
130
+ }
131
+ // Only once every segment is at its floor do we shrink separators, so the
132
+ // truncation ellipsis never replaces a separator's content and adjacent
133
+ // segments always keep at least one column of padding between them.
134
+ while (visibleWidth(assemble(states, target)) > target) {
135
+ if (!collapseSeparator(states)) break;
136
+ }
137
+
138
+ return truncateToWidth(assemble(states, target), target, theme.fg("dim", "…"));
139
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Turn a `StatusSnapshot` into the two footer lines.
3
+ *
4
+ * Line 1 — identity: left `pwd`, right `⎇ branch · ⑂ worktree · ◉ browse`.
5
+ * Line 2 — resources: left the plan/alert statuses then the context gauge and
6
+ * usage meters, right the model and thinking level.
7
+ */
8
+
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ branchSegment,
12
+ cacheSegment,
13
+ contextSegment,
14
+ costSegment,
15
+ modelSegment,
16
+ modesSegment,
17
+ pwdSegment,
18
+ browseSegment,
19
+ sessionSegment,
20
+ thinkingSegment,
21
+ tokensSegment,
22
+ windowSegment,
23
+ worktreeSegment,
24
+ } from "./segments.ts";
25
+ import type { LineSpec, Segment, StatusSnapshot } from "../types.ts";
26
+
27
+ function defined(segments: Array<Segment | null>): Segment[] {
28
+ return segments.filter((segment): segment is Segment => segment !== null);
29
+ }
30
+
31
+ /** Build the identity and resources lines for a snapshot. */
32
+ export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string | undefined): LineSpec[] {
33
+ const line1: LineSpec = {
34
+ left: defined([pwdSegment(snapshot, theme, home), sessionSegment(snapshot, theme)]),
35
+ right: defined([branchSegment(snapshot, theme), worktreeSegment(snapshot, theme), browseSegment(snapshot, theme)]),
36
+ };
37
+
38
+ const line2Left = defined([
39
+ contextSegment(snapshot, theme),
40
+ windowSegment(snapshot, theme),
41
+ costSegment(snapshot, theme),
42
+ tokensSegment(snapshot, theme),
43
+ cacheSegment(snapshot, theme),
44
+ modesSegment(snapshot, theme),
45
+ ]);
46
+
47
+ const line2Right = defined([modelSegment(snapshot, theme), thinkingSegment(snapshot, theme)]);
48
+
49
+ return [line1, { left: line2Left, right: line2Right }];
50
+ }