osdy-pi 1.11.14 → 1.11.16

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.
package/README.md CHANGED
@@ -418,7 +418,7 @@ Animation is enabled by default with an intro animation. Configure it through `O
418
418
 
419
419
  In the extended framed editor, the active thinking level is rendered in bold in the top-right model metadata so it remains easy to scan.
420
420
 
421
- The full header and mascot render side by side only when the available width and height permit it without an additional width-limited mascot reduction. Otherwise, only the centered mascot renders—there is no scaled or stacked logo. Small and compact modes trim only fully empty mascot-art and tone-map margins before applying one proportional width-and-height scale; mascot width starts near four-fifths of the available width. The fallback mascot uses the available terminal rows, capped at 32 content rows, without reserving rows for a logo. The small-mode footer places the model and styled bare thinking level above usage, path/branch, and dynamic extension statuses. Pi Lens's footer status is hidden in small mode, but Pi Lens continues running. Usage includes input/output/cache-read/cache-write tokens, cost, and context. Extension statuses are supplied dynamically by Pi/extensions and may include Osdy Pi, MCP, or LSP; they are not hardcoded.
421
+ The full header and mascot render side by side only when the available width and height permit it without an additional width-limited mascot reduction. Otherwise, only the centered mascot renders—there is no scaled or stacked logo. Small and compact modes trim only fully empty mascot-art and tone-map margins before applying one proportional width-and-height scale; mascot width starts near four-fifths of the available width. The fallback mascot uses the available terminal rows, capped at 32 content rows, without reserving rows for a logo. The small-mode footer places the model and styled bare thinking level above usage, path/branch, and dynamic extension statuses. Pi Lens's footer status is hidden in small mode, but Pi Lens continues running. Usage includes response speed, cache-read/cache-write tokens when space permits, cost, and context. Extension statuses are supplied dynamically by Pi/extensions and may include Osdy Pi, MCP, or LSP; they are not hardcoded.
422
422
 
423
423
  The enabled state, editor mode, working-tree visibility preference, header, and mascot persist globally across Pi reloads and sessions, shared by all projects. They are saved in `$PI_CODING_AGENT_DIR/extensions/osdy-pi/settings.json`, or `~/.pi/agent/extensions/osdy-pi/settings.json` when `PI_CODING_AGENT_DIR` is unset. Existing settings without the new visual fields safely default to `osdy-theme` and `current`; the former `raccoon` mascot value migrates to `bts`. The selected editor mode and working-tree placement are restored when the terminal moves normal → small → normal.
424
424
 
@@ -511,7 +511,15 @@ Pi renders the colored user card and assistant Markdown natively. User messages
511
511
 
512
512
  The default `auto` editor mode preserves the responsive behavior: it uses the framed editor when space permits and Pi's native editor on small terminals. Select `simple` for Pi's native editor at every width, or `extended` to request the framed editor explicitly: `/osdy-pi editor auto|extended|simple`. Simple mode actually unmounts the custom editor component rather than hiding it. Small terminals always use Pi's native editor, including when `extended` is selected. The legacy commands remain compatible where feasible: `on` maps to `extended`, `off` maps to `simple`, and `toggle` switches between extended and simple.
513
513
 
514
- In auto or extended mode at a non-small width, the framed editor shows the model and thinking level in its title and session usage in its footer. For an account-profile launch, the left title shows the active profile name instead of `Osdy-Pi`. When the native editor is effective (simple mode or any small terminal), the Osdy footer instead shows model, active profile when present, thinking, and usage rows before its path/branch and status rows. It uses the currently active Pi/Osdy theme palette; no separate editor theme selector exists. Usage covers input, output, cache read, cache write when present, cost, and context. If Pi supports autocomplete, the editor uses Pi's native autocomplete rendering while the completion UI is visible.
514
+ In auto or extended mode at a non-small width, the framed editor shows the model and thinking level in its title and session usage in its footer. For an account-profile launch, the left title shows the active profile name instead of `Osdy-Pi`. When the native editor is effective (simple mode or any small terminal), the Osdy footer instead shows model, active profile when present, thinking, and usage rows before its path/branch and status rows. It uses the currently active Pi/Osdy theme palette; no separate editor theme selector exists. Usage shows response speed instead of cumulative input/output totals, alongside cache read/write when present, cost, and context. If Pi supports autocomplete, the editor uses Pi's native autocomplete rendering while the completion UI is visible.
515
+
516
+ ### Response speed
517
+
518
+ The editor usage line (or native-mode footer) shows **`≈42.3 tok/s` while streaming**, estimated as accumulated streamed UTF-16 characters / 4 divided by seconds since the first nonempty output delta. Text, thinking, and tool-call argument deltas count once; snapshots and block-end events do not. This language-dependent heuristic is **not a tokenizer count or server-measured decode speed**. It updates on stream events, with no additional timer or editor remount.
519
+
520
+ On successful message end, the label becomes **`42.3 tok/s`**: finalized output usage divided by the observed first-output-to-message-end interval. Output already includes reported reasoning tokens; input/cache tokens and subsequent tool execution are excluded. This is an observed response average, not server telemetry. Each assistant message starts a new reading. The completed reading remains until the next response; session replacement/reload clears it.
521
+
522
+ **`tok/s …`** means pending or insufficient timing while streaming. **`tok/s —`** means idle or an unavailable reading after completion/interruption: missing/zero output usage, no output deltas, intervals under 100 ms, invalid timing, aborts, or errors. Narrow layouts simplify context and clip safely. Status and local analytics retain their existing token accounting; speed is volatile and never saved.
515
523
 
516
524
  While work is active, Osdy Pi centers an activity label and compact animated glyph above the editor:
517
525
 
@@ -56,7 +56,7 @@ void test("formats the selected single-line usage shape", () => {
56
56
  percent: 60,
57
57
  contextWindow: 272_000,
58
58
  }),
59
- "tok ↑0 ↓0 · $0.0000 · ctx <warning>[██████░░░░]</warning> 60%/272.0k",
59
+ "tok/s — · $0.0000 · ctx <warning>[██████░░░░]</warning> 60%/272.0k",
60
60
  );
61
61
  });
62
62
 
@@ -76,14 +76,16 @@ void test("selects the full or compact usage line at the ANSI-aware width bounda
76
76
  contextWindow: 272_000,
77
77
  };
78
78
  const full =
79
- "tok ↑0 ↓0 · $0.0000 · ctx \u001B[33m[██████░░░░]\u001B[39m 60%/272.0k";
79
+ "tok/s — · $0.0000 · ctx \u001B[33m[██████░░░░]\u001B[39m 60%/272.0k";
80
80
 
81
- assert.equal(visibleWidth(full), 49);
82
- assert.equal(formatContextUsage(ansiTheme, usage, 49), full);
83
- assert.equal(
84
- formatContextUsage(ansiTheme, usage, 48),
85
- "$0.0000 · ctx \u001B[33m[██████░░░░]\u001B[39m 60%",
86
- );
81
+ const width = visibleWidth(full);
82
+ assert.equal(formatContextUsage(ansiTheme, usage, width), full);
83
+ const compact = formatContextUsage(ansiTheme, usage, width - 1);
84
+ assert.match(compact, /tok\/s —/);
85
+ assert.ok(visibleWidth(compact) <= width - 1);
86
+ for (let columns = 0; columns < width; columns++) {
87
+ assert.ok(visibleWidth(formatContextUsage(ansiTheme, usage, columns)) <= columns);
88
+ }
87
89
  });
88
90
 
89
91
  void test("formats a ten-cell green context bar at the inclusive 30% threshold", () => {
@@ -97,7 +99,7 @@ void test("formats a ten-cell green context bar at the inclusive 30% threshold",
97
99
  percent: 30,
98
100
  contextWindow: 272_000,
99
101
  }),
100
- "tok ↑0 ↓0 · $0.0000 · ctx <success>[███░░░░░░░]</success> 30%/272.0k",
102
+ "tok/s — · $0.0000 · ctx <success>[███░░░░░░░]</success> 30%/272.0k",
101
103
  );
102
104
  });
103
105
 
@@ -112,7 +114,7 @@ void test("uses warning and error colors for higher rounded usage", () => {
112
114
  percent: 30.5,
113
115
  contextWindow: 200_000,
114
116
  }),
115
- "tok ↑1.5k ↓2.5m R12 W34 · $1.2000 · ctx <warning>[███░░░░░░░]</warning> 31%/200.0k",
117
+ "tok/s — R12 W34 · $1.2000 · ctx <warning>[███░░░░░░░]</warning> 31%/200.0k",
116
118
  );
117
119
  assert.equal(
118
120
  formatContextUsage(theme, {
@@ -124,7 +126,7 @@ void test("uses warning and error colors for higher rounded usage", () => {
124
126
  percent: 80.5,
125
127
  contextWindow: 200_000,
126
128
  }),
127
- "tok ↑0 ↓0 · $1.2000 · ctx <error>[████████░░]</error> 81%/200.0k",
129
+ "tok/s — · $1.2000 · ctx <error>[████████░░]</error> 81%/200.0k",
128
130
  );
129
131
  });
130
132
 
@@ -139,7 +141,7 @@ void test("clamps usage and safely represents missing context usage", () => {
139
141
  percent: -10,
140
142
  contextWindow: 200_000,
141
143
  }),
142
- "tok ↑0 ↓0 · $0.0000 · ctx <success>[░░░░░░░░░░]</success> 0%/200.0k",
144
+ "tok/s — · $0.0000 · ctx <success>[░░░░░░░░░░]</success> 0%/200.0k",
143
145
  );
144
146
  assert.equal(
145
147
  formatContextUsage(theme, {
@@ -151,7 +153,7 @@ void test("clamps usage and safely represents missing context usage", () => {
151
153
  percent: undefined,
152
154
  contextWindow: 272_000,
153
155
  }),
154
- "tok ↑0 ↓0 · $0.0000 · ctx <success>[░░░░░░░░░░]</success> ?%/272.0k",
156
+ "tok/s — · $0.0000 · ctx <success>[░░░░░░░░░░]</success> ?%/272.0k",
155
157
  );
156
158
  assert.equal(
157
159
  formatContextUsage(theme, {
@@ -163,6 +165,6 @@ void test("clamps usage and safely represents missing context usage", () => {
163
165
  percent: undefined,
164
166
  contextWindow: undefined,
165
167
  }),
166
- "tok ↑0 ↓0 · $0.0000 · ctx <success>[░░░░░░░░░░]</success> ?%/?",
168
+ "tok/s — · $0.0000 · ctx <success>[░░░░░░░░░░]</success> ?%/?",
167
169
  );
168
170
  });
@@ -1,4 +1,5 @@
1
- import { visibleWidth } from "@earendil-works/pi-tui";
1
+ import type { GenerationReading } from "./generation-meter.js";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
3
  import { shortNumber } from "./format.js";
3
4
 
4
5
  export type ContextUsageDisplayData = {
@@ -7,6 +8,7 @@ export type ContextUsageDisplayData = {
7
8
  cacheRead: number;
8
9
  cacheWrite: number;
9
10
  cost: number;
11
+ generation?: GenerationReading | undefined;
10
12
  percent: number | undefined;
11
13
  contextWindow: number | undefined;
12
14
  };
@@ -53,8 +55,17 @@ export function formatContextUsage(
53
55
  usageTone(displayPercent),
54
56
  `[${usageBar(displayPercent)}]`,
55
57
  );
56
- const full = `tok ↑${shortNumber(usage.input)} ↓${shortNumber(usage.output)}${cacheLabel} · $${usage.cost.toFixed(4)} · ctx ${bar} ${percentageLabel}%/${contextWindowLabel}`;
58
+ const generation = usage.generation;
59
+ const rate = generation?.kind === "complete" || generation?.kind === "streaming"
60
+ ? generation.tokensPerSecond : null;
61
+ const speed = rate !== null && Number.isFinite(rate) && rate > 0
62
+ ? `${generation?.kind === "streaming" ? "≈" : ""}${rate.toFixed(1)} tok/s`
63
+ : generation?.kind === "streaming" ? "tok/s …" : "tok/s —";
64
+ const full = `${speed}${cacheLabel} · $${usage.cost.toFixed(4)} · ctx ${bar} ${percentageLabel}%/${contextWindowLabel}`;
57
65
  if (availableWidth === undefined || visibleWidth(full) <= availableWidth)
58
66
  return full;
59
- return `$${usage.cost.toFixed(4)} · ctx ${bar} ${percentageLabel}%`;
67
+ const compact = `${speed}${cacheLabel} · $${usage.cost.toFixed(4)} · ctx ${bar} ${percentageLabel}%`;
68
+ if (visibleWidth(compact) <= availableWidth) return compact;
69
+ const minimal = `${speed} · $${usage.cost.toFixed(4)} · ctx ${percentageLabel}%`;
70
+ return truncateToWidth(minimal, Math.max(0, availableWidth), "");
60
71
  }
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ // @ts-expect-error Native test runner resolves TypeScript source imports.
4
+ import { createGenerationMeter } from "./generation-meter.ts";
5
+
6
+ void test("estimates characters, then uses finalized output over first-output-to-end time", () => {
7
+ let now = 0;
8
+ const meter = createGenerationMeter(() => now);
9
+ assert.deepEqual(meter.start(), { kind: "streaming", tokensPerSecond: null });
10
+ now = 5000; // Request latency is not generation time.
11
+ meter.delta("abcd");
12
+ now = 6000;
13
+ assert.deepEqual(meter.delta("abcdefghijkl"), { kind: "streaming", tokensPerSecond: 4 });
14
+ now = 7000;
15
+ assert.deepEqual(meter.finish(100, "stop"), { kind: "complete", tokensPerSecond: 50 });
16
+ now = 9000;
17
+ assert.deepEqual(meter.delta("ignored"), { kind: "complete", tokensPerSecond: 50 });
18
+ assert.deepEqual(meter.start(), { kind: "streaming", tokensPerSecond: null });
19
+ assert.deepEqual(meter.reset(), { kind: "idle" });
20
+ assert.deepEqual(meter.finish(100, "stop"), { kind: "idle" });
21
+ });
22
+
23
+ void test("empty output, short/invalid intervals, invalid usage and interrupted responses are unavailable", () => {
24
+ for (const duration of [0, 99, -1, Number.NaN, Infinity]) {
25
+ let now = 0;
26
+ const meter = createGenerationMeter(() => now);
27
+ meter.start();
28
+ meter.delta("abcd");
29
+ now = duration;
30
+ assert.equal(meter.delta("").kind, "streaming");
31
+ assert.deepEqual(meter.finish(20, "stop"), { kind: "unavailable" });
32
+ }
33
+ for (const output of [undefined, 0, -1, 1.5, Number.NaN, Infinity]) {
34
+ let now = 0;
35
+ const meter = createGenerationMeter(() => now);
36
+ meter.start(); meter.delta("abcd"); now = 1000;
37
+ assert.deepEqual(meter.finish(output, "stop"), { kind: "unavailable" });
38
+ }
39
+ for (const reason of ["aborted", "error", "pending", "deferred", undefined]) {
40
+ let now = 0;
41
+ const meter = createGenerationMeter(() => now);
42
+ meter.start(); meter.delta("abcd"); now = 1000;
43
+ assert.deepEqual(meter.finish(20, reason), { kind: "unavailable" });
44
+ }
45
+ const meter = createGenerationMeter(() => 1000);
46
+ meter.start(); meter.delta("");
47
+ assert.deepEqual(meter.finish(20, "toolUse"), { kind: "unavailable" });
48
+ meter.start();
49
+ assert.deepEqual(meter.interrupt(), { kind: "unavailable" });
50
+ });
51
+
52
+ void test("clock regression invalidates the response, and normal length/tool-use endings are valid", () => {
53
+ for (const reason of ["length", "toolUse"]) {
54
+ let now = 1000;
55
+ const meter = createGenerationMeter(() => now);
56
+ meter.start(); meter.delta("abcd"); now = 2000;
57
+ assert.deepEqual(meter.finish(20, reason), { kind: "complete", tokensPerSecond: 20 });
58
+ assert.deepEqual(meter.interrupt(), { kind: "complete", tokensPerSecond: 20 });
59
+ meter.start(); meter.delta("abcd"); now = 1900; meter.delta("efgh"); now = 3000;
60
+ assert.deepEqual(meter.finish(20, reason), { kind: "unavailable" });
61
+ }
62
+ });
@@ -0,0 +1,69 @@
1
+ export type GenerationReading =
2
+ | { kind: "idle" | "unavailable" }
3
+ | { kind: "streaming"; tokensPerSecond: number | null }
4
+ | { kind: "complete"; tokensPerSecond: number };
5
+
6
+ export interface GenerationMeter {
7
+ start(): GenerationReading;
8
+ delta(text: string): GenerationReading;
9
+ finish(output: number | undefined, stopReason: string | undefined): GenerationReading;
10
+ interrupt(): GenerationReading;
11
+ reset(): GenerationReading;
12
+ }
13
+
14
+ /** Volatile, per-message measurement. No partial-message references or timers.
15
+ * Live UTF-16 characters / 4 is a heuristic, never tokenizer/server telemetry.
16
+ */
17
+ export function createGenerationMeter(now: () => number = () => performance.now()): GenerationMeter {
18
+ let reading: GenerationReading = { kind: "idle" };
19
+ let firstOutput: number | undefined;
20
+ let lastTime: number | undefined;
21
+ let invalidClock = false;
22
+ let characters = 0;
23
+ const sample = (): number => {
24
+ const time = now();
25
+ if (!Number.isFinite(time) || (lastTime !== undefined && time < lastTime)) invalidClock = true;
26
+ lastTime = time;
27
+ return time;
28
+ };
29
+ const rate = (tokens: number, time: number): number | null => {
30
+ const elapsed = firstOutput === undefined ? 0 : time - firstOutput;
31
+ // Sub-100ms observations are too short to present a useful rate.
32
+ if (invalidClock || elapsed < 100 || !Number.isFinite(elapsed)) return null;
33
+ const value = tokens / (elapsed / 1000);
34
+ return Number.isFinite(value) && value > 0 ? value : null;
35
+ };
36
+ const reset = (): GenerationReading => {
37
+ firstOutput = undefined;
38
+ lastTime = undefined;
39
+ invalidClock = false;
40
+ characters = 0;
41
+ return reading = { kind: "idle" };
42
+ };
43
+ return {
44
+ reset,
45
+ start() {
46
+ reset();
47
+ return reading = { kind: "streaming", tokensPerSecond: null };
48
+ },
49
+ delta(text) {
50
+ if (reading.kind !== "streaming" || !text) return reading;
51
+ const time = sample();
52
+ firstOutput ??= time;
53
+ characters += text.length;
54
+ return reading = { kind: "streaming", tokensPerSecond: rate(characters / 4, time) };
55
+ },
56
+ finish(output, stopReason) {
57
+ if (reading.kind !== "streaming") return reading;
58
+ const time = sample();
59
+ const completed = stopReason === "stop" || stopReason === "length" || stopReason === "toolUse";
60
+ const value = completed && output !== undefined && Number.isSafeInteger(output) && output > 0
61
+ ? rate(output, time) : null;
62
+ return reading = value === null ? { kind: "unavailable" } : { kind: "complete", tokensPerSecond: value };
63
+ },
64
+ interrupt() {
65
+ if (reading.kind === "streaming") reading = { kind: "unavailable" };
66
+ return reading;
67
+ },
68
+ };
69
+ }
@@ -50,7 +50,7 @@ registerHooks({
50
50
  type ShortcutRegistration = Parameters<ExtensionAPI["registerShortcut"]>[1];
51
51
 
52
52
  async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset-ui-runtime-")),
53
- options: Pick<NonNullable<Parameters<typeof registerOsdyPi>[1]>, "fetchCodexUsage"> = {}) {
53
+ options: Pick<NonNullable<Parameters<typeof registerOsdyPi>[1]>, "fetchCodexUsage" | "generationClock"> = {}) {
54
54
  const { registerOsdyPi: registerResetRuntime } = await import("./runtime.js");
55
55
  const previousDir = process.env.PI_CODING_AGENT_DIR;
56
56
  const previousFetch = globalThis.fetch;
@@ -69,6 +69,10 @@ async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset
69
69
  let beforeAuth: () => Promise<void> = () => Promise.resolve();
70
70
  let onRender: (view: string) => void = () => {};
71
71
  let panel: Component | undefined;
72
+ let footer: Component | undefined;
73
+ let renders = 0;
74
+ let mounts = 0;
75
+ const tui = { terminal: { columns: 120, rows: 40 }, requestRender: () => { renders++; }, hasOverlay: () => false } as unknown as TUI;
72
76
  const shortcuts = new Map<string, ShortcutRegistration>();
73
77
  const views: string[] = [];
74
78
  let quotaReads = 0;
@@ -99,15 +103,18 @@ async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset
99
103
  on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) => {
100
104
  const list = events.get(name) ?? []; list.push(handler); events.set(name, list);
101
105
  }, registerFlag: () => {}, registerMessageRenderer: () => {}, registerEntryRenderer: () => {},
102
- events: { on: () => () => {} }, exec: () => { throw new Error("external execution forbidden"); },
106
+ getThinkingLevel: () => "off", events: { on: () => () => {} }, exec: () => { throw new Error("external execution forbidden"); },
103
107
  } as unknown as ExtensionAPI;
104
108
  const ctx = { cwd: root, hasUI: true, mode: "tui", model: { provider: "openai-codex", id: "synthetic" }, isIdle: () => true,
105
- sessionManager: { getSessionId: () => session }, modelRegistry: { getProviderAuth: async (provider: string) => {
109
+ getContextUsage: () => undefined,
110
+ sessionManager: { getSessionId: () => session, getEntries: () => [] }, modelRegistry: { getProviderAuth: async (provider: string) => {
106
111
  assert.equal(provider, "openai-codex");
107
112
  const token = `header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: account } })).toString("base64url")}.signature`;
108
113
  await beforeAuth();
109
114
  return { auth: { apiKey: token } };
110
- } }, ui: { getEditorComponent: () => undefined, setEditorComponent: () => {}, setHeader: () => {}, setFooter: () => {},
115
+ } }, ui: { getEditorComponent: () => undefined, setEditorComponent: () => { mounts++; },
116
+ setHeader: (factory?: (tui: TUI, theme: Theme) => Component & { dispose?(): void }) => { factory?.(tui, ctx.ui.theme).dispose?.(); },
117
+ setFooter: (factory?: (tui: TUI, theme: Theme, data: unknown) => Component) => { footer = factory?.(tui, ctx.ui.theme, {}); },
111
118
  setWidget: () => {}, setWorkingVisible: () => {}, notify: (text: string) => { notices.push(text); },
112
119
  theme: { name: "dark", appearance: "dark", fg: (_color: string, text: string) => text }, getAllThemes: () => [], setTheme: () => ({ success: false }),
113
120
  select: (title: string, options: string[]) => {
@@ -145,7 +152,11 @@ async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset
145
152
  }) } });
146
153
  await lifecycle("session_start");
147
154
  } catch (error) { globalThis.fetch = previousFetch; if (previousDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousDir; throw error; }
148
- return { root, posts, dialogs, notices, views, expiry, quotaReads: () => quotaReads, overlays: () => overlays,
155
+ return { root, posts, dialogs, notices, views, expiry,
156
+ emit: async (name: string, event: unknown) => { for (const handler of events.get(name) ?? []) await handler(event, ctx); },
157
+ enable: () => commands.get("osdy-pi")!.handler("enable", ctx),
158
+ footer: () => footer?.render(120).join("\n") ?? "", renders: () => renders, mounts: () => mounts,
159
+ quotaReads: () => quotaReads, overlays: () => overlays,
149
160
  open: (surface = "usage") => { nextAction = true; return commands.get(surface)!.handler("", ctx); },
150
161
  view: (shortcut = false) => { nextAction = false; return shortcut
151
162
  ? shortcuts.get("ctrl+alt+u")!.handler(ctx) : commands.get("usage")!.handler("", ctx); },
@@ -163,6 +174,60 @@ async function resetRuntimeFixture(root = mkdtempSync(join(tmpdir(), "osdy-reset
163
174
  };
164
175
  }
165
176
 
177
+ void test("generation lifecycle uses delta-only estimates and final event usage without remounting", async () => {
178
+ let now = 0;
179
+ const f = await resetRuntimeFixture(undefined, { generationClock: () => now });
180
+ const message = { role: "assistant", usage: { output: 80, reasoning: 40 }, stopReason: "stop" };
181
+ const delta = (type: string, text: string) => f.emit("message_update", {
182
+ message, assistantMessageEvent: { type, delta: text, partial: message },
183
+ });
184
+ try {
185
+ await f.enable();
186
+ const mounts = f.mounts();
187
+ await f.emit("message_start", { message });
188
+ assert.match(f.footer(), /tok\/s …/);
189
+ now = 5000; await delta("thinking_delta", "abcd");
190
+ now = 6000; await delta("text_delta", "abcd");
191
+ await delta("toolcall_delta", "abcd");
192
+ assert.match(f.footer(), /≈3\.0 tok\/s/);
193
+ await delta("text_end", "not counted");
194
+ assert.match(f.footer(), /≈3\.0 tok\/s/);
195
+ now = 7000; await f.emit("message_end", { message });
196
+ assert.match(f.footer(), /40\.0 tok\/s/);
197
+ assert.doesNotMatch(f.footer(), /≈|tok ↑/);
198
+ await f.emit("message_start", { message: { role: "toolResult" } });
199
+ assert.match(f.footer(), /40\.0 tok\/s/);
200
+ await f.emit("agent_end", {});
201
+ assert.match(f.footer(), /40\.0 tok\/s/);
202
+ assert.equal(f.mounts(), mounts);
203
+ assert.ok(f.renders() > 0);
204
+ await f.emit("message_start", { message });
205
+ await delta("text_delta", "abcd"); now += 1000;
206
+ await f.emit("message_end", { message: { ...message, stopReason: "aborted" } });
207
+ assert.match(f.footer(), /tok\/s —/);
208
+ await f.emit("message_start", { message });
209
+ await delta("text_delta", "abcd"); now += 1000;
210
+ await delta("error", "ignored");
211
+ assert.match(f.footer(), /tok\/s —/);
212
+ await f.emit("message_end", { message });
213
+ assert.match(f.footer(), /tok\/s —/);
214
+ await f.emit("message_start", { message });
215
+ await delta("text_delta", "abcd"); now += 1000;
216
+ await f.emit("agent_end", {});
217
+ assert.match(f.footer(), /tok\/s —/);
218
+ await f.emit("message_start", { message });
219
+ await delta("text_delta", "abcd"); now += 1000;
220
+ await f.replace(); await f.enable();
221
+ await f.emit("message_end", { message });
222
+ assert.match(f.footer(), /tok\/s —/);
223
+ await f.emit("message_start", { message });
224
+ await delta("text_delta", "abcd"); now += 1000;
225
+ await f.shutdown();
226
+ await f.emit("message_end", { message });
227
+ assert.match(f.footer(), /tok\/s —/);
228
+ } finally { await f.close(); }
229
+ });
230
+
166
231
  void test("registered active surfaces select and Cancel-first confirm before exactly one mocked POST", async () => {
167
232
  for (const surface of ["usage", "osdyConfig"]) {
168
233
  const f = await resetRuntimeFixture();
@@ -5,6 +5,7 @@ import type {
5
5
  ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { homedir } from "node:os";
8
+ import { createGenerationMeter, type GenerationReading } from "./generation-meter.js";
8
9
  import { createCodexUsageRefresh, type CodexUsageRefreshClock } from "./codex-usage-refresh.js";
9
10
  import { createControlCenterTodo } from "./control-center-todo.js";
10
11
  import { createControlCenterAgents } from "./control-center-agents.js";
@@ -1122,6 +1123,7 @@ export async function registerOsdyPi(
1122
1123
  /** Tests inject a synthetic agent-data root; production uses the SDK root. */
1123
1124
  agentDir?: () => string;
1124
1125
  codexUsageClock?: CodexUsageRefreshClock;
1126
+ generationClock?: () => number;
1125
1127
  fetchCodexUsage?: CodexUsageFetcher;
1126
1128
  accountFactory?: typeof bindControlCenterAccount;
1127
1129
  editorSettingsStore?: ReturnType<typeof createEditorSettingsStore>;
@@ -1140,6 +1142,12 @@ export async function registerOsdyPi(
1140
1142
  workingTreeEnabled: true,
1141
1143
  workingTreePlacement: "aboveEditor",
1142
1144
  };
1145
+ const generationMeter = createGenerationMeter(dependencies.generationClock);
1146
+ state.generation = generationMeter.reset();
1147
+ const presentGeneration = (reading: GenerationReading): void => {
1148
+ state.generation = reading;
1149
+ state.tui?.requestRender();
1150
+ };
1143
1151
  const workingState: WorkingWidgetState = {
1144
1152
  active: false,
1145
1153
  activity: "thinking",
@@ -1306,11 +1314,29 @@ export async function registerOsdyPi(
1306
1314
  audioRouter,
1307
1315
  );
1308
1316
 
1317
+ pi.on("message_start", (event) => {
1318
+ if (sessionContext && event.message.role === "assistant") presentGeneration(generationMeter.start());
1319
+ });
1320
+ pi.on("message_update", (event) => {
1321
+ if (!sessionContext || event.message.role !== "assistant") return;
1322
+ const update = event.assistantMessageEvent;
1323
+ if (update.type === "text_delta" || update.type === "thinking_delta" || update.type === "toolcall_delta") {
1324
+ // Count only deltas, not mutable partial snapshots or authoritative block ends.
1325
+ presentGeneration(generationMeter.delta(update.delta));
1326
+ } else if (update.type === "error") presentGeneration(generationMeter.interrupt());
1327
+ });
1328
+ pi.on("message_end", (event) => {
1329
+ if (sessionContext && event.message.role === "assistant") {
1330
+ // message_end precedes persistence. Output already includes reasoning tokens.
1331
+ presentGeneration(generationMeter.finish(event.message.usage?.output, event.message.stopReason));
1332
+ }
1333
+ });
1309
1334
  pi.on("agent_start", () => {
1310
1335
  controller.onAgentStart();
1311
1336
  audioRouter.onAgentStart();
1312
1337
  });
1313
1338
  pi.on("agent_end", (_event, ctx) => {
1339
+ presentGeneration(generationMeter.interrupt());
1314
1340
  controller.onAgentEnd();
1315
1341
  audioRouter.onAgentEnd(ctx);
1316
1342
  if (state.enabled) clearGentleShellChangesWidget(ctx);
@@ -1346,6 +1372,7 @@ export async function registerOsdyPi(
1346
1372
  });
1347
1373
  pi.on("session_shutdown", () => {
1348
1374
  usageSettingsReady = false;
1375
+ presentGeneration(generationMeter.reset());
1349
1376
  idleUsageRefresh.stop();
1350
1377
  runtimeGeneration++;
1351
1378
  for (const abort of resetAborts) abort.abort();
@@ -1363,6 +1390,7 @@ export async function registerOsdyPi(
1363
1390
  sessionContext = undefined;
1364
1391
  });
1365
1392
  pi.on("session_start", async (_event, ctx) => {
1393
+ presentGeneration(generationMeter.reset());
1366
1394
  controller.onShutdown();
1367
1395
  idleUsageRefresh.stop();
1368
1396
  codexUsageAbort?.abort();
@@ -6,6 +6,7 @@ type EditorFactory = Exclude<
6
6
  undefined
7
7
  >;
8
8
  import type { CodexUsageSnapshot } from "./codex-usage.js";
9
+ import type { GenerationReading } from "./generation-meter.js";
9
10
 
10
11
  export type AnimationMode = "off" | "intro" | "continuous";
11
12
 
@@ -58,6 +59,8 @@ export type CodexUsageState =
58
59
 
59
60
  export type OsdyState = {
60
61
  codexUsage: CodexUsageState;
62
+ /** Volatile response reading; never persisted as usage or settings. */
63
+ generation?: GenerationReading;
61
64
  enabled: boolean;
62
65
  /** Actual mounted editor, including while a desired change is deferred. */
63
66
  editorEffective: boolean;
@@ -1,4 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { KeybindingsManager } from "../../node_modules/@earendil-works/pi-coding-agent/dist/core/keybindings.js";
4
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
5
+ import type { OsdyState } from "./types.js";
2
6
  import { createHash } from "node:crypto";
3
7
  import { existsSync, readFileSync } from "node:fs";
4
8
  import { registerHooks } from "node:module";
@@ -394,6 +398,47 @@ void test("places a managed profile beside the model in simple mode and preserve
394
398
  );
395
399
  });
396
400
 
401
+ void test("one real editor instance and native footer read live/final speed with ANSI-safe resizing", () => {
402
+ const color = (text: string) => `\u001b[33m${text}\u001b[39m`;
403
+ const theme = { fg: (_name: string, text: string) => color(text), bold: (text: string) => text };
404
+ const state: OsdyState = {
405
+ codexUsage: { kind: "idle" }, enabled: true, editorEffective: true, editorMode: "extended",
406
+ fallbackEditorFactory: undefined, headerVariant: "osdy-theme", mascot: "current", smallMode: false,
407
+ tui: undefined, workingTreeEnabled: false, workingTreePlacement: "aboveEditor",
408
+ generation: { kind: "streaming", tokensPerSecond: 42.3 },
409
+ };
410
+ const ctx = { cwd: "/synthetic", ui: { theme }, model: { provider: "test", id: "test", contextWindow: 200000 },
411
+ getContextUsage: () => ({ percent: 60 }), sessionManager: { getEntries: () => [] },
412
+ } as unknown as ExtensionContext;
413
+ const pi = { getThinkingLevel: () => "off" } as ExtensionAPI;
414
+ const tui = { terminal: { rows: 40, columns: 120 }, requestRender: () => {} } as unknown as TUI;
415
+ const editorTheme: EditorTheme = { borderColor: color, selectList: {
416
+ selectedPrefix: color, selectedText: color, description: color, scrollInfo: color, noMatch: color,
417
+ } };
418
+ const editor = ui.createEditorComponent(pi, ctx, state)(tui, editorTheme, new KeybindingsManager());
419
+ for (const reading of [
420
+ { kind: "streaming", tokensPerSecond: 42.3 }, { kind: "complete", tokensPerSecond: 50 },
421
+ { kind: "streaming", tokensPerSecond: null }, { kind: "unavailable" },
422
+ ] as const) {
423
+ state.generation = reading;
424
+ const expected = reading.kind === "complete" ? "50.0 tok/s" : reading.kind === "unavailable" ? "tok/s —"
425
+ : reading.tokensPerSecond === null ? "tok/s …" : "≈42.3 tok/s";
426
+ assert.ok(editor.render(120).join("\n").includes(expected));
427
+ for (const width of [4, 15, 30, 48, 80, 120]) {
428
+ assert.ok(editor.render(width).every(line => visibleWidth(line) <= width));
429
+ }
430
+ state.editorEffective = false;
431
+ const footer = ui.createFooterComponent(pi, ctx, state, {
432
+ getGitBranch: () => null, getExtensionStatuses: () => new Map(),
433
+ getAvailableProviderCount: () => 1, onBranchChange: () => () => {},
434
+ }, theme);
435
+ assert.ok(footer.render(120).join("\n").includes(expected));
436
+ assert.doesNotMatch(footer.render(120).join("\n"), /tok ↑/);
437
+ assert.ok(footer.render(30).every(line => visibleWidth(line) <= 30));
438
+ state.editorEffective = true;
439
+ }
440
+ });
441
+
397
442
  void test("wires themed Codex quota bars below both native footer and extended editor", () => {
398
443
  const source = readFileSync(new URL("./ui.ts", import.meta.url), "utf8");
399
444
  assert.match(source, /renderCompactCodexQuotaBars/);
@@ -343,7 +343,7 @@ class OsdyFooter implements Component {
343
343
  ? renderCompactCodexQuotaBars(this.theme, codexSnapshot, width)
344
344
  : [];
345
345
  const usageLine = truncateToWidth(
346
- formatContextUsage(this.theme, contextUsageData(this.ctx), width),
346
+ formatContextUsage(this.theme, { ...contextUsageData(this.ctx), generation: this.state.generation }, width),
347
347
  width,
348
348
  ellipsis,
349
349
  );
@@ -490,7 +490,7 @@ export function createEditorComponent(
490
490
  const topRight = ctx.ui.theme.fg("muted", layout.topRight);
491
491
  const bottomLeft = formatContextUsage(
492
492
  ctx.ui.theme,
493
- contextUsageData(ctx),
493
+ { ...contextUsageData(ctx), generation: state.generation },
494
494
  innerWidth,
495
495
  );
496
496
  lines[0] = `${borderColor("╭")}${fitBorder(topLeft, topRight, editorWidth - 2, borderColor)}${borderColor("╮")}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osdy-pi",
3
- "version": "1.11.14",
3
+ "version": "1.11.16",
4
4
  "description": "Osdy Pi package: themes, header, and custom editor for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -83,7 +83,7 @@
83
83
  "typescript": "^5.9.3",
84
84
  "typescript-eslint": "^8.46.3"
85
85
  },
86
- "releaseSource": "375d9ec96ba08e538070ee492c60b0ce45bb0bc4",
86
+ "releaseSource": "a5c411e0d9537ce6fee513fc81e2a9a3d66b7142",
87
87
  "repository": {
88
88
  "type": "git",
89
89
  "url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"
@@ -47,10 +47,6 @@
47
47
  "searchMatchText": "text",
48
48
  "customMessageBg": "surface0",
49
49
  "customMessageText": "text",
50
- "assistantMessageBg": "surface0",
51
- "assistantMessageAccentBg": "blue",
52
- "assistantMessageText": "text",
53
- "userMessageAccentBg": "#FFFFFF",
54
50
  "userMessageBg": "#35415c",
55
51
  "userMessageText": "text",
56
52
  "customMessageLabel": "blue",
@@ -47,10 +47,6 @@
47
47
  "searchMatchText": "text",
48
48
  "customMessageBg": "surface0",
49
49
  "customMessageText": "text",
50
- "assistantMessageBg": "surface0",
51
- "assistantMessageAccentBg": "blue",
52
- "assistantMessageText": "text",
53
- "userMessageAccentBg": "#FFFFFF",
54
50
  "userMessageBg": "#becddb",
55
51
  "userMessageText": "text",
56
52
  "customMessageLabel": "blue",
@@ -47,10 +47,6 @@
47
47
  "searchMatchText": "text",
48
48
  "customMessageBg": "surface0",
49
49
  "customMessageText": "text",
50
- "assistantMessageBg": "surface0",
51
- "assistantMessageAccentBg": "blue",
52
- "assistantMessageText": "text",
53
- "userMessageAccentBg": "#FFFFFF",
54
50
  "userMessageBg": "#293851",
55
51
  "userMessageText": "text",
56
52
  "customMessageLabel": "blue",
@@ -47,10 +47,6 @@
47
47
  "searchMatchText": "text",
48
48
  "customMessageBg": "surface0",
49
49
  "customMessageText": "text",
50
- "assistantMessageBg": "surface0",
51
- "assistantMessageAccentBg": "blue",
52
- "assistantMessageText": "text",
53
- "userMessageAccentBg": "#FFFFFF",
54
50
  "userMessageBg": "#29334b",
55
51
  "userMessageText": "text",
56
52
  "customMessageLabel": "blue",
@@ -32,10 +32,6 @@
32
32
  "selectedBg": "surface2",
33
33
  "customMessageBg": "#1C1230",
34
34
  "customMessageText": "textSoft",
35
- "assistantMessageBg": "#1C1230",
36
- "assistantMessageAccentBg": "pink",
37
- "assistantMessageText": "textSoft",
38
- "userMessageAccentBg": "#FFFFFF",
39
35
  "userMessageBg": "#292045",
40
36
  "userMessageText": "textSoft",
41
37
  "customMessageLabel": "pink",
@@ -36,10 +36,6 @@
36
36
  "searchMatchText": "foreground",
37
37
  "customMessageBg": "light",
38
38
  "customMessageText": "foreground",
39
- "assistantMessageBg": "light",
40
- "assistantMessageAccentBg": "purple",
41
- "assistantMessageText": "foreground",
42
- "userMessageAccentBg": "#FFFFFF",
43
39
  "userMessageBg": "#343e57",
44
40
  "userMessageText": "foreground",
45
41
  "customMessageLabel": "cyan",
@@ -44,10 +44,6 @@
44
44
  "searchMatchText": "text",
45
45
  "customMessageBg": "raised",
46
46
  "customMessageText": "text",
47
- "assistantMessageBg": "raised",
48
- "assistantMessageAccentBg": "blue",
49
- "assistantMessageText": "text",
50
- "userMessageAccentBg": "#FFFFFF",
51
47
  "userMessageBg": "#283536",
52
48
  "userMessageText": "text",
53
49
  "customMessageLabel": "cyan",
@@ -45,10 +45,6 @@
45
45
  "searchMatchText": "text",
46
46
  "customMessageBg": "raised",
47
47
  "customMessageText": "text",
48
- "assistantMessageBg": "raised",
49
- "assistantMessageAccentBg": "blue",
50
- "assistantMessageText": "text",
51
- "userMessageAccentBg": "#FFFFFF",
52
48
  "userMessageBg": "#d8ca9e",
53
49
  "userMessageText": "text",
54
50
  "customMessageLabel": "blue",
@@ -44,10 +44,6 @@
44
44
  "searchMatchText": "text",
45
45
  "customMessageBg": "raised",
46
46
  "customMessageText": "text",
47
- "assistantMessageBg": "raised",
48
- "assistantMessageAccentBg": "blue",
49
- "assistantMessageText": "text",
50
- "userMessageAccentBg": "#FFFFFF",
51
47
  "userMessageBg": "#303b53",
52
48
  "userMessageText": "text",
53
49
  "customMessageLabel": "cyan",
@@ -33,10 +33,6 @@
33
33
  "searchMatchText": "accent",
34
34
  "customMessageBg": "",
35
35
  "customMessageText": "text",
36
- "assistantMessageBg": "subtle",
37
- "assistantMessageAccentBg": "orange",
38
- "assistantMessageText": "text",
39
- "userMessageAccentBg": "#FFFFFF",
40
36
  "userMessageBg": "#33312d",
41
37
  "userMessageText": "text",
42
38
  "customMessageLabel": "secondary",
@@ -36,10 +36,6 @@
36
36
  "searchMatchText": "matrixInk0",
37
37
  "customMessageBg": "matrixInk2",
38
38
  "customMessageText": "rainGreenHi",
39
- "assistantMessageBg": "matrixInk2",
40
- "assistantMessageAccentBg": "rainGreen",
41
- "assistantMessageText": "rainGreenHi",
42
- "userMessageAccentBg": "#FFFFFF",
43
39
  "userMessageBg": "#193925",
44
40
  "userMessageText": "rainGreenHi",
45
41
  "customMessageLabel": "rainCyan",
@@ -44,10 +44,6 @@
44
44
  "searchMatchText": "pureWhite",
45
45
  "customMessageBg": "plasmaSurface",
46
46
  "customMessageText": "violetText",
47
- "assistantMessageBg": "plasmaSurface",
48
- "assistantMessageAccentBg": "cyan",
49
- "assistantMessageText": "violetText",
50
- "userMessageAccentBg": "#FFFFFF",
51
47
  "userMessageBg": "#18384b",
52
48
  "userMessageText": "cyanText",
53
49
  "customMessageLabel": "cyanSoft",
@@ -37,10 +37,6 @@
37
37
  "searchMatchText": "text",
38
38
  "customMessageBg": "elevated",
39
39
  "customMessageText": "text",
40
- "assistantMessageBg": "elevated",
41
- "assistantMessageAccentBg": "accentPink",
42
- "assistantMessageText": "text",
43
- "userMessageAccentBg": "#FFFFFF",
44
40
  "userMessageBg": "#372639",
45
41
  "userMessageText": "text",
46
42
  "customMessageLabel": "brightPink",
@@ -36,10 +36,6 @@
36
36
  "searchMatchText": "background",
37
37
  "customMessageBg": "elevated",
38
38
  "customMessageText": "text",
39
- "assistantMessageBg": "elevated",
40
- "assistantMessageAccentBg": "blue",
41
- "assistantMessageText": "text",
42
- "userMessageAccentBg": "#FFFFFF",
43
39
  "userMessageBg": "#283b59",
44
40
  "userMessageText": "text",
45
41
  "customMessageLabel": "cyan",