@gavin-hu/my-pi-agent 0.11.3 → 0.12.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 (86) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +22 -11
  73. package/extensions/wechat/bridge.ts +90 -22
  74. package/extensions/wechat/commands.ts +45 -1
  75. package/extensions/wechat/doctor.ts +1 -0
  76. package/extensions/wechat/index.ts +3 -1
  77. package/extensions/wechat/status-screen.ts +32 -2
  78. package/extensions/wechat/tools.ts +0 -2
  79. package/lib/README.md +2 -1
  80. package/lib/config.ts +43 -1
  81. package/lib/env.ts +12 -0
  82. package/lib/requirements.ts +76 -2
  83. package/lib/todo-state.ts +62 -0
  84. package/lib/tui/glyphs.ts +1 -0
  85. package/package.json +1 -1
  86. package/assets/bytefly.schema.json +0 -785
@@ -19,11 +19,25 @@ import {
19
19
  type ExtensionReport,
20
20
  type ProbeSeams,
21
21
  } from "../../../lib/requirements.ts";
22
+ import { loadNotifyConfig } from "../notify/config.ts";
22
23
  import { DEFAULT_CONFIG, type DoctorConfig } from "./config.ts";
23
24
 
24
25
  /** Seams a test may replace; everything else is built from the host. */
25
26
  export type DoctorSeamOverrides = Partial<ProbeSeams>;
26
27
 
28
+ /**
29
+ * The notify capability's configured delivery, read fresh from the document.
30
+ *
31
+ * Reading at probe time rather than from the session's snapshot means a
32
+ * corrected method shows up on the next `r` instead of only after a reload.
33
+ */
34
+ export function notifySeam(cwd: string): () => { enabled: boolean; method: string } {
35
+ return () => {
36
+ const notify = loadNotifyConfig(cwd);
37
+ return { enabled: notify.enabled, method: notify.method };
38
+ };
39
+ }
40
+
27
41
  export interface DoctorRuntime {
28
42
  /** Point the runtime at a session: its cwd and effective config. */
29
43
  configure(options: { cwd: string; config: DoctorConfig }): void;
@@ -56,6 +70,8 @@ export function createDoctorRuntime(
56
70
  dirs: { agentDir: getAgentDir(), cwd, configDirName: CONFIG_DIR_NAME },
57
71
  platform: process.platform,
58
72
  isEnabled: (extension) => isExtensionEnabled(extension) && !config.skip.includes(extension),
73
+ notify: notifySeam(cwd),
74
+ isTTY: process.stdout.isTTY === true,
59
75
  ...deps.seams,
60
76
  });
61
77
 
@@ -57,9 +57,7 @@ export function registerTools(pi: ExtensionAPI, runtime: DoctorRuntime): void {
57
57
  ],
58
58
  parameters: DoctorParams,
59
59
  outputSchema: DoctorOutput,
60
- exposure: "direct",
61
- defaultActive: true,
62
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
60
+ annotations: { readOnlyHint: true, openWorldHint: false },
63
61
 
64
62
  async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
65
63
  const args = params as DoctorArgs;
@@ -9,13 +9,14 @@
9
9
  *
10
10
  * The built-in table is DeepSeek's published yuan pricing, with the peak
11
11
  * schedule that doubles the price during peak hours. It is the default value of
12
- * `rates`, so nothing here is provider-specific: another provider's currency
13
- * and tariff are stated in `bytefly.json`.
12
+ * `rates`: `currency.code` chooses which table that is, and the peak schedule
13
+ * applies to both. Only an unmatched model differs — `CNY` contributes no cost
14
+ * for it, while `USD` adds Pi's recorded dollars.
14
15
  */
15
16
 
16
17
  import { asRecord, clampInteger, clampNumber, cleanString, loadConfigSection } from "../../../../lib/config.ts";
17
18
  import { parseWindow } from "./schedule.ts";
18
- import type { ByteflyConfig, Currency, ModelRates, PeakSchedule, PeakWindow } from "./types.ts";
19
+ import type { ByteflyConfig, Currency, CurrencyCode, ModelRates, PeakSchedule, PeakWindow } from "./types.ts";
19
20
 
20
21
  /** Monday to Friday, the days DeepSeek charges its peak rate. */
21
22
  const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5];
@@ -26,8 +27,8 @@ const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5];
26
27
  * Pi's catalog prices every call in dollars at one flat rate, which is wrong
27
28
  * twice over for DeepSeek: the bill is in yuan, and peak hours cost double.
28
29
  * These are the published peak figures; `DEFAULT_PEAK` supplies the tariff. A
29
- * `bytefly.json` entry with the same key replaces one of these, and every
30
- * other provider still falls back to Pi's recorded cost.
30
+ * `bytefly.json` entry with the same key replaces one of these. A model with no
31
+ * matching entry contributes no cost; USD mode uses Pi's recorded cost instead.
31
32
  *
32
33
  * Source: <https://api-docs.deepseek.com/quick_start/pricing>.
33
34
  */
@@ -36,6 +37,24 @@ export const DEFAULT_RATES: Readonly<Record<string, ModelRates>> = {
36
37
  "deepseek/deepseek-v4-pro": { input: 9, output: 27, cacheRead: 0.3, cacheWrite: 0, offPeakMultiplier: 0.5 },
37
38
  };
38
39
 
40
+ /**
41
+ * The same two models in DeepSeek's published dollars, per million tokens at the
42
+ * peak rate; `offPeakMultiplier` halves them off-peak. The published table has no
43
+ * cache-write line, so `cacheWrite` is 0 here exactly as in the yuan table.
44
+ *
45
+ * Source: <https://api-docs.deepseek.com/quick_start/pricing> (USD table).
46
+ */
47
+ export const DEFAULT_RATES_USD: Readonly<Record<string, ModelRates>> = {
48
+ "deepseek/deepseek-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, offPeakMultiplier: 0.5 },
49
+ "deepseek/deepseek-v4-pro": { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0, offPeakMultiplier: 0.5 },
50
+ };
51
+
52
+ /** The built-in table for each currency: `rates` are stated in the selected one. */
53
+ export const DEFAULT_RATES_BY_CURRENCY: Readonly<Record<CurrencyCode, Readonly<Record<string, ModelRates>>>> = {
54
+ CNY: DEFAULT_RATES,
55
+ USD: DEFAULT_RATES_USD,
56
+ };
57
+
39
58
  /**
40
59
  * DeepSeek's peak hours, in the zone the provider states them in: 01:00-04:00
41
60
  * and 06:00-10:00 UTC on weekdays, which is 09:00-12:00 and 14:00-18:00 in
@@ -51,17 +70,24 @@ export const DEFAULT_PEAK: PeakSchedule = {
51
70
  ],
52
71
  };
53
72
 
54
- /** Rendered-currency default: yuan, the currency `DEFAULT_RATES` is stated in. */
55
- export const DEFAULT_CURRENCY: Currency = { symbol: "¥", decimals: 2 };
73
+ /** The symbol each algorithm prints. It is not configurable, so it can never disagree with the code. */
74
+ export const CURRENCY_SYMBOLS: Readonly<Record<CurrencyCode, string>> = { CNY: "¥", USD: "$" };
75
+
76
+ /** The default precision each algorithm supplies when a config sets only its code. */
77
+ export const CURRENCY_DEFAULTS: Readonly<Record<CurrencyCode, Currency>> = {
78
+ CNY: { code: "CNY", decimals: 2 },
79
+ USD: { code: "USD", decimals: 3 },
80
+ };
81
+
82
+ /** Yuan, the currency `DEFAULT_RATES` is stated in. */
83
+ export const DEFAULT_CURRENCY: Currency = CURRENCY_DEFAULTS.CNY;
56
84
 
57
85
  export const DEFAULT_BYTEFLY_CONFIG: ByteflyConfig = {
58
86
  currency: DEFAULT_CURRENCY,
59
- fallbackToRecordedCost: true,
60
87
  rates: DEFAULT_RATES,
61
88
  peak: DEFAULT_PEAK,
62
89
  };
63
90
 
64
- const MAX_SYMBOL_LENGTH = 4;
65
91
  const MAX_DECIMALS = 4;
66
92
  const MAX_RATE = 1_000_000;
67
93
  const MAX_RATE_KEYS = 200;
@@ -77,12 +103,22 @@ function acceptsTimeZone(timezone: string): boolean {
77
103
  }
78
104
  }
79
105
 
106
+ /** A supported ISO code, or the base code for a missing/mistyped value. */
107
+ function currencyCode(raw: unknown, base: CurrencyCode): CurrencyCode {
108
+ const text = typeof raw === "string" ? raw.trim().toUpperCase() : "";
109
+ return text === "CNY" || text === "USD" ? text : base;
110
+ }
111
+
80
112
  function currencyValue(raw: unknown, base: Currency): Currency {
81
113
  const value = asRecord(raw);
82
114
  if (!value) return base;
115
+ const code = currencyCode(value.code, base.code);
116
+ // A changed code supplies its own default precision unless this layer
117
+ // overrides it; an unchanged code keeps the precision customized above it.
118
+ const display = code !== base.code ? CURRENCY_DEFAULTS[code] : base;
83
119
  return {
84
- symbol: cleanString(value.symbol, base.symbol).slice(0, MAX_SYMBOL_LENGTH),
85
- decimals: clampInteger(value.decimals, base.decimals, 0, MAX_DECIMALS),
120
+ code,
121
+ decimals: clampInteger(value.decimals, display.decimals, 0, MAX_DECIMALS),
86
122
  };
87
123
  }
88
124
 
@@ -137,11 +173,14 @@ function peakValue(raw: unknown, base: PeakSchedule): PeakSchedule {
137
173
  /** Validate a raw config object over `base`. Total: never throws, never guesses. */
138
174
  export function normalizeByteflyConfig(raw: Record<string, unknown> | undefined, base: ByteflyConfig): ByteflyConfig {
139
175
  if (!raw) return base;
176
+ const currency = currencyValue(raw.currency, base.currency);
177
+ // `rates` are stated in the selected currency, so a layer that switches the
178
+ // code starts from that currency's table instead of reinterpreting the old
179
+ // numbers in the new unit. A layer that sets both merges onto the new table.
180
+ const rateBase = currency.code === base.currency.code ? base.rates : DEFAULT_RATES_BY_CURRENCY[currency.code];
140
181
  return {
141
- currency: currencyValue(raw.currency, base.currency),
142
- fallbackToRecordedCost:
143
- typeof raw.fallbackToRecordedCost === "boolean" ? raw.fallbackToRecordedCost : base.fallbackToRecordedCost,
144
- rates: ratesValue(raw.rates, base.rates),
182
+ currency,
183
+ rates: ratesValue(raw.rates, rateBase),
145
184
  peak: peakValue(raw.peak, base.peak),
146
185
  };
147
186
  }
@@ -2,8 +2,10 @@
2
2
  * Price usage records from configured rates.
3
3
  *
4
4
  * `createPricer` owns the two things that must not be rebuilt per record: the
5
- * matching rates and the timezone formatter. A record with no matching rate
6
- * keeps Pi's recorded cost (in USD) unless the config turns that fallback off.
5
+ * matching rates and the timezone formatter. `currency.code` chooses the
6
+ * algorithm: CNY prices matched records from `rates` and `peak`, while USD
7
+ * totals Pi's recorded dollar cost and ignores both. A CNY-mode record with no
8
+ * matching rate contributes nothing: an unpriced model shows no price at all.
7
9
  */
8
10
 
9
11
  import { emptyUsage } from "../collect/entries.ts";
@@ -32,6 +34,9 @@ function tokenCost(record: UsageRecord, rates: ModelRates): number {
32
34
  * its price always describe the same set of records.
33
35
  */
34
36
  export function createPricer(config: ByteflyConfig): (records: readonly UsageRecord[]) => UsageTotals {
37
+ // Both currencies price from `rates` and the tariff; they differ only in what
38
+ // an unmatched model falls back to, and Pi's recorded cost is already dollars.
39
+ const dollars = config.currency.code === "USD";
35
40
  // Building a formatter is the expensive part, and only a schedule needs one.
36
41
  const format = config.peak.windows.length > 0 ? peakFormatter(config.peak.timezone) : null;
37
42
 
@@ -42,16 +47,24 @@ export function createPricer(config: ByteflyConfig): (records: readonly UsageRec
42
47
  totals.output += record.output;
43
48
  totals.cacheRead += record.cacheRead;
44
49
  totals.cacheWrite += record.cacheWrite;
45
- totals.cost += recordCost(config, format, record);
50
+ const rates = findRates(config.rates, record.provider, record.model);
51
+ if (rates !== undefined) {
52
+ totals.cost += ratedCost(config, format, record, rates);
53
+ continue;
54
+ }
55
+ if (dollars) totals.cost += record.recordedCost;
46
56
  }
47
57
  return totals;
48
58
  };
49
59
  }
50
60
 
51
- /** One record's cost: the configured rate, or Pi's recorded value. */
52
- function recordCost(config: ByteflyConfig, format: Intl.DateTimeFormat | null, record: UsageRecord): number {
53
- const rates = findRates(config.rates, record.provider, record.model);
54
- if (rates === undefined) return config.fallbackToRecordedCost ? record.recordedCost : 0;
61
+ /** One record's cost at its matched rates, after the peak schedule. */
62
+ function ratedCost(
63
+ config: ByteflyConfig,
64
+ format: Intl.DateTimeFormat | null,
65
+ record: UsageRecord,
66
+ rates: ModelRates,
67
+ ): number {
55
68
  const cost = tokenCost(record, rates);
56
69
  // An undated record is never discounted: we cannot tell which tariff applied.
57
70
  if (format === null || record.at === null) return cost;
@@ -6,15 +6,23 @@
6
6
  * `../types.ts`.
7
7
  */
8
8
 
9
- /** How a cost is rendered. */
9
+ /** The two pricing algorithms, named by the currency they total. */
10
+ export type CurrencyCode = "CNY" | "USD";
11
+
12
+ /** How a cost is calculated and rendered. */
10
13
  export interface Currency {
11
- /** Printed before the number, e.g. `$` or `¥`. */
12
- symbol: string;
13
- /** Digits after the decimal point. */
14
+ /**
15
+ * Which table `rates` starts from, and what an unmatched model contributes:
16
+ * both currencies price a matched record from `rates` and the peak schedule,
17
+ * while `CNY` gives an unmatched model no cost and `USD` adds Pi's recorded
18
+ * dollars for it.
19
+ */
20
+ code: CurrencyCode;
21
+ /** Digits after the decimal point. The symbol follows from `code`. */
14
22
  decimals: number;
15
23
  }
16
24
 
17
- /** Per-million-token rates for one model, expressed in the configured currency. */
25
+ /** Per-million-token rates for one model, in yuan; used by the CNY algorithm. */
18
26
  export interface ModelRates {
19
27
  input: number;
20
28
  output: number;
@@ -45,8 +53,6 @@ export interface PeakSchedule {
45
53
  /** Everything the price calculation is configured with. */
46
54
  export interface ByteflyConfig {
47
55
  currency: Currency;
48
- /** Fall back to Pi's recorded cost for a record that no rate matches. */
49
- fallbackToRecordedCost: boolean;
50
56
  /** Rates keyed by `provider/model`, matched through wildcards. */
51
57
  rates: Readonly<Record<string, ModelRates>>;
52
58
  peak: PeakSchedule;
@@ -11,7 +11,7 @@ import { shortenPath } from "../../../../lib/format.ts";
11
11
  import { gaugeRole } from "../../../../lib/tui/gauge.ts";
12
12
  import type { TuiRole } from "../../../../lib/tui/tokens.ts";
13
13
  import { CONFIG } from "../config.ts";
14
- import { DEFAULT_CURRENCY } from "../price/config.ts";
14
+ import { CURRENCY_SYMBOLS, DEFAULT_CURRENCY } from "../price/config.ts";
15
15
  import type { Currency } from "../price/types.ts";
16
16
 
17
17
  /** Theme tokens for the reasoning-effort indicator. */
@@ -31,7 +31,8 @@ export type ThinkingColor =
31
31
  * `¥<0.01` — so a small cost is never reported as free.
32
32
  */
33
33
  export function formatCost(cost: number, currency: Currency = DEFAULT_CURRENCY): string {
34
- const { symbol, decimals } = currency;
34
+ const { decimals } = currency;
35
+ const symbol = CURRENCY_SYMBOLS[currency.code];
35
36
  if (!Number.isFinite(cost) || cost <= 0) return `${symbol}${(0).toFixed(decimals)}`;
36
37
  const minimum = 10 ** -decimals;
37
38
  if (cost < minimum) return `${symbol}<${minimum.toFixed(decimals)}`;
@@ -171,13 +171,14 @@ export function windowSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
171
171
  }
172
172
 
173
173
  export function costSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
174
- if (snapshot.usage.cost === 0) return null;
174
+ const { cost } = snapshot.usage;
175
+ if (cost === 0) return null;
175
176
  return {
176
177
  id: "cost",
177
178
  weight: 2,
178
179
  droppable: true,
179
180
  separator: faint(theme, CONFIG.separators.item),
180
- forms: [paint(theme, "meta", formatCost(snapshot.usage.cost, snapshot.currency))],
181
+ forms: [paint(theme, "meta", formatCost(cost, snapshot.currency))],
181
182
  };
182
183
  }
183
184
 
@@ -32,6 +32,7 @@ export interface UsageTotals {
32
32
  output: number;
33
33
  cacheRead: number;
34
34
  cacheWrite: number;
35
+ /** Cost in the configured currency: rated in CNY mode, Pi's recorded amount in USD mode. */
35
36
  cost: number;
36
37
  }
37
38
 
@@ -17,6 +17,7 @@ import { keyHintText } from "../../../lib/tui/glyphs.ts";
17
17
  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
+ import { updateMarker } from "../update/format.ts";
20
21
  import { HEADER_CONFIG, type HelpEntry } from "./config.ts";
21
22
 
22
23
  /** The header's shape, resolved once per apply so `render` stays cheap. */
@@ -27,6 +28,8 @@ export interface HeaderView {
27
28
  halfBlocks: boolean;
28
29
  /** Columns of left margin: Pi's output pad, so the nameplate sits on the transcript margin. */
29
30
  pad: number;
31
+ /** A newer published version of this package, when one is known. */
32
+ latest?: string;
30
33
  }
31
34
 
32
35
  /** The mark block is four columns on both rows, so the identity column aligns. */
@@ -78,7 +81,7 @@ export class ByteflyHeader implements Component {
78
81
  const w = Math.max(1, width);
79
82
  const theme = this.getTheme();
80
83
  const view = this.view();
81
- const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}`;
84
+ const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}`;
82
85
  const keys = this.keySignature();
83
86
  if (
84
87
  this.cachedWidth === w &&
@@ -106,7 +109,8 @@ export class ByteflyHeader implements Component {
106
109
  }
107
110
 
108
111
  private build(w: number, theme: Theme, view: HeaderView): string[] {
109
- const { nameRole, chromeRole, detailRole, chromeTagline, chromeName, chromeVersion, help } = HEADER_CONFIG;
112
+ const { nameRole, chromeRole, detailRole, updateRole, chromeTagline, chromeName, chromeVersion, help } =
113
+ HEADER_CONFIG;
110
114
  const padWidth = Math.max(0, Math.min(Math.floor(view.pad), Math.max(0, w - 1)));
111
115
  const pad = " ".repeat(padWidth);
112
116
  const [markTop, markBottom] = markLines(theme, view.halfBlocks);
@@ -120,11 +124,18 @@ export class ByteflyHeader implements Component {
120
124
  : theme.bold(paint(theme, nameRole, HEADER_CONFIG.appName))
121
125
  : version;
122
126
  const chrome = `${paint(theme, detailRole, chromeTagline)} ${paint(theme, chromeRole, chromeName)}`;
123
- const chromeVersioned = chromeVersion ? `${chrome} ${paint(theme, detailRole, `v${chromeVersion}`)}` : chrome;
124
-
125
- // The identity rows shed as one block: chrome version, chrome line, agent.
127
+ const chromeWithVersion = chromeVersion ? `${chrome} ${paint(theme, detailRole, `v${chromeVersion}`)}` : chrome;
128
+ // The published version follows the loaded one: `v0.11.4 ↑0.12.0`.
129
+ const chromeWithMarker =
130
+ chromeVersion && view.latest
131
+ ? `${chromeWithVersion} ${paint(theme, updateRole, updateMarker(view.latest))}`
132
+ : chromeWithVersion;
133
+
134
+ // The identity rows shed as one block: the update marker first, then the
135
+ // chrome version, the chrome line, and finally the agent.
126
136
  const states: Array<readonly [string, string]> = [
127
- [agent, chromeVersioned],
137
+ [agent, chromeWithMarker],
138
+ [agent, chromeWithVersion],
128
139
  [agent, chrome],
129
140
  [agent, ""],
130
141
  ["", ""],
@@ -132,7 +143,11 @@ export class ByteflyHeader implements Component {
132
143
  const lineWidth = (cell: string): number => pad.length + MARK_WIDTH + (cell ? 1 + visibleWidth(cell) : 0);
133
144
  const [agentCell, chromeCell] = states.find(([a, c]) => Math.max(lineWidth(a), lineWidth(c)) <= w) ?? ["", ""];
134
145
 
146
+ // The header owns its leading blank: Pi mounts the built-in header inside
147
+ // `Spacer(1)`s, but the `quietStartup: true` branch that hands this header the
148
+ // screen adds none, and `setHeader` swaps only the header child.
135
149
  return [
150
+ "",
136
151
  this.identityLine(pad, markTop, agentCell, w),
137
152
  this.identityLine(pad, markBottom, chromeCell, w),
138
153
  this.rule(theme, w, pad),
@@ -35,6 +35,8 @@ export interface HeaderConfig {
35
35
  readonly chromeRole: TuiRole;
36
36
  /** Role for the versions, the tagline, and the help descriptions. */
37
37
  readonly detailRole: TuiRole;
38
+ /** Role for the update marker: an action, so the accent rather than a warning colour. */
39
+ readonly updateRole: TuiRole;
38
40
  /** Role for the rule's accent lead under the mark. */
39
41
  readonly ruleAccentRole: TuiRole;
40
42
  /** Role for the rest of the rule. */
@@ -57,6 +59,7 @@ export const HEADER_CONFIG: HeaderConfig = {
57
59
  nameRole: "label",
58
60
  chromeRole: "meta",
59
61
  detailRole: "faint",
62
+ updateRole: "accent",
60
63
  ruleAccentRole: "accent",
61
64
  ruleRole: "rule",
62
65
  chromeTagline: "enhanced by",
@@ -9,21 +9,40 @@
9
9
  * `pi config` remains the way to turn all of bytefly's chrome off. The header
10
10
  * is installed only in interactive (`tui`) sessions; every other mode leaves
11
11
  * Pi's built-in header in place, which does not exist there.
12
+ *
13
+ * The update check resolves after this install, so the header reads the newest
14
+ * known version through `UpdateSource` and `refresh()` re-applies the last
15
+ * context's header once one arrives. That is the only reason the header is ever
16
+ * installed twice.
12
17
  */
13
18
 
14
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
20
  import { isAppleTerminalSession } from "@earendil-works/pi-tui";
16
21
  import { DEFAULT_HOST_PAD } from "../pad.ts";
22
+ import { createUpdateState, type UpdateSource } from "../update/state.ts";
17
23
  import { createHeader, type HeaderView } from "./component.ts";
18
24
  import { HEADER_CONFIG } from "./config.ts";
19
25
 
20
- /** Register the install/restore lifecycle for the custom header. */
21
- export function registerHeader(pi: ExtensionAPI): void {
26
+ /** The installed header, and the way to re-install it after a change. */
27
+ export interface HeaderHandle {
28
+ /** Re-apply the last session's header, picking up the current update marker. */
29
+ refresh(): void;
30
+ }
31
+
32
+ /**
33
+ * Register the install/restore lifecycle for the custom header.
34
+ *
35
+ * `updates` is the store the update check writes, so the marker appears when a
36
+ * newer version is known. A test that does not exercise the marker may omit it.
37
+ */
38
+ export function registerHeader(pi: ExtensionAPI, updates: UpdateSource = createUpdateState()): HeaderHandle {
22
39
  // Half blocks misalign in Apple Terminal, so the mark falls back to a wordmark
23
40
  // there, mirroring Pi's own built-in header.
24
41
  const halfBlocks = !isAppleTerminalSession();
42
+ let last: ExtensionContext | undefined;
25
43
 
26
44
  const apply = (ctx: ExtensionContext): void => {
45
+ last = ctx;
27
46
  if (ctx.mode !== "tui") return;
28
47
  // Bytefly's startup TUI is on only when Pi's `quietStartup` is true, which
29
48
  // hides Pi's built-in startup header. Every other value (`false`,
@@ -36,14 +55,26 @@ export function registerHeader(pi: ExtensionAPI): void {
36
55
  // value the tool frame and the notice inset by. An older host without
37
56
  // `getSettings` keeps the default margin.
38
57
  const pad = pi.getSettings?.().outputPad ?? DEFAULT_HOST_PAD;
39
- const view = (): HeaderView => ({ showVersion: HEADER_CONFIG.showVersion, halfBlocks, pad });
58
+ const view = (): HeaderView => ({
59
+ showVersion: HEADER_CONFIG.showVersion,
60
+ halfBlocks,
61
+ pad,
62
+ latest: updates.latest(),
63
+ });
40
64
  ctx.ui.setHeader(createHeader(() => ctx.ui.theme, view));
41
65
  };
42
66
 
43
67
  pi.on("session_start", (_event, ctx) => apply(ctx));
44
68
  pi.on("session_tree", (_event, ctx) => apply(ctx));
45
69
  pi.on("session_shutdown", (_event, ctx) => {
70
+ last = undefined;
46
71
  if (ctx.mode !== "tui") return;
47
72
  ctx.ui.setHeader(undefined);
48
73
  });
74
+
75
+ return {
76
+ refresh() {
77
+ if (last) apply(last);
78
+ },
79
+ };
49
80
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * bytefly — the package's terminal chrome and diagnostics, composed from ten
2
+ * bytefly — the package's terminal chrome and diagnostics, composed from eleven
3
3
  * feature folders: `header/` (the themed startup mark and version), `footer/`
4
4
  * (the two-line, width-adaptive status bar with its price calculation),
5
5
  * `message/` (the user-message nameplate), `notice/` (the shared transcript
@@ -7,7 +7,8 @@
7
7
  * transcript turn rule), `working/` (the live elapsed counter on Pi's working
8
8
  * row), `panel/` (the unified `/bytefly` screen and its
9
9
  * `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
10
- * probe runtime), and `notify/` (OS notifications for unattended runs).
10
+ * probe runtime), `notify/` (OS notifications for unattended runs), and
11
+ * `update/` (the published-version check behind the header's update marker).
11
12
  *
12
13
  * The header, footer, turn rule, and working timer are installed only in
13
14
  * interactive (`tui`) sessions, and are always on; the user-message marker,
@@ -34,6 +35,8 @@ import { type NotifyDeps, registerNotify } from "./notify/register.ts";
34
35
  import { createPadStore } from "./pad.ts";
35
36
  import { type PanelCommandDeps, registerPanel } from "./panel/commands.ts";
36
37
  import { registerTurnSeparator } from "./separator/register.ts";
38
+ import { registerUpdate, type UpdateDeps } from "./update/register.ts";
39
+ import { createUpdateState } from "./update/state.ts";
37
40
  import { registerWorkingTimer } from "./working/register.ts";
38
41
 
39
42
  /** Host seams, injected by tests. */
@@ -44,13 +47,18 @@ export interface ByteflyDeps {
44
47
  doctor?: DoctorDeps;
45
48
  /** Seams for the notify capability. */
46
49
  notify?: NotifyDeps;
50
+ /** Seams for the published-version check. */
51
+ update?: UpdateDeps;
47
52
  }
48
53
 
49
54
  /** Register every bytefly surface; the factory only registers. */
50
55
  export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void {
51
56
  if (!isExtensionEnabled("bytefly")) return;
52
57
 
53
- registerHeader(pi);
58
+ // One store per load: the update check writes the newest published version and
59
+ // the header reads it, so the marker and every re-install agree.
60
+ const updates = createUpdateState();
61
+ const header = registerHeader(pi, updates);
54
62
  registerFooter(pi);
55
63
  registerUserMessage(pi);
56
64
  // One store per load: the tool frame and the notice record Pi's output pad and
@@ -66,4 +74,6 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
66
74
  const doctor = registerDoctor(pi, deps.doctor);
67
75
  registerPanel(pi, { doctor, discover: deps.extensions?.discover });
68
76
  registerNotify(pi, deps.notify);
77
+ // Last, so a version that arrives after startup can re-install the header.
78
+ registerUpdate(pi, updates, { ...deps.update, onUpdate: () => header.refresh() });
69
79
  }