@pi-unipi/footer 0.1.2 → 0.1.4

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.
@@ -6,8 +6,9 @@
6
6
  */
7
7
 
8
8
  import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../types.js";
9
- import { applyColor } from "../rendering/theme.js";
9
+ import { applyColor, mutedPlaceholder } from "../rendering/theme.js";
10
10
  import { getIcon } from "../rendering/icons.js";
11
+ import { isSegmentEnabled } from "../config.js";
11
12
 
12
13
  function withIcon(segmentId: string, text: string): string {
13
14
  const icon = getIcon(segmentId);
@@ -23,7 +24,12 @@ function getNotifyData(ctx: FooterSegmentContext): Record<string, unknown> {
23
24
  function renderPlatformsEnabledSegment(ctx: FooterSegmentContext): RenderedSegment {
24
25
  const data = getNotifyData(ctx);
25
26
  const platforms = data.platforms as string[] | undefined;
26
- if (!platforms || platforms.length === 0) return { content: "", visible: false };
27
+ if (!platforms || platforms.length === 0) {
28
+ if (isSegmentEnabled("notify", "platforms_enabled")) {
29
+ return { content: mutedPlaceholder("NTF OFF"), visible: true };
30
+ }
31
+ return { content: "", visible: false };
32
+ }
27
33
 
28
34
  const content = withIcon("platformsEnabled", platforms.join(","));
29
35
  return { content: applyColor("notify", content, ctx.theme, ctx.colors), visible: true };
@@ -32,7 +38,12 @@ function renderPlatformsEnabledSegment(ctx: FooterSegmentContext): RenderedSegme
32
38
  function renderLastSentSegment(ctx: FooterSegmentContext): RenderedSegment {
33
39
  const data = getNotifyData(ctx);
34
40
  const timestamp = data.timestamp as string | undefined;
35
- if (!timestamp) return { content: "", visible: false };
41
+ if (!timestamp) {
42
+ if (isSegmentEnabled("notify", "last_sent")) {
43
+ return { content: mutedPlaceholder("NTF 0"), visible: true };
44
+ }
45
+ return { content: "", visible: false };
46
+ }
36
47
 
37
48
  // Show relative time
38
49
  const sent = new Date(timestamp);
@@ -45,6 +56,6 @@ function renderLastSentSegment(ctx: FooterSegmentContext): RenderedSegment {
45
56
  }
46
57
 
47
58
  export const NOTIFY_SEGMENTS: FooterSegment[] = [
48
- { id: "platforms_enabled", label: "Platforms", icon: "", render: renderPlatformsEnabledSegment, defaultShow: true },
49
- { id: "last_sent", label: "Last Sent", icon: "", render: renderLastSentSegment, defaultShow: true },
59
+ { id: "platforms_enabled", label: "Platforms", shortLabel: "NTF", description: "Active notification platforms", zone: "center", icon: "", render: renderPlatformsEnabledSegment, defaultShow: true },
60
+ { id: "last_sent", label: "Last Sent", shortLabel: "LST", description: "Time of last notification sent", zone: "center", icon: "", render: renderLastSentSegment, defaultShow: true },
50
61
  ];
@@ -11,11 +11,11 @@
11
11
  */
12
12
 
13
13
  import type { FooterSegment, FooterSegmentContext, RenderedSegment, SemanticColor } from "../types.js";
14
- import { applyColor } from "../rendering/theme.js";
14
+ import { applyColor, mutedPlaceholder } from "../rendering/theme.js";
15
15
  import { getIcon } from "../rendering/icons.js";
16
+ import { isSegmentEnabled } from "../config.js";
17
+
16
18
 
17
- /** Nerd Font icon for ralph: 󰼉 */
18
- const RALPH_ICON = "\udb81\udf09";
19
19
 
20
20
  /** Green dot indicator (with explicit ANSI codes) */
21
21
  const GREEN_DOT = "\x1b[38;5;82m●\x1b[0m";
@@ -48,10 +48,18 @@ function renderActiveLoopsSegment(ctx: FooterSegmentContext): RenderedSegment {
48
48
  const maxIterations = data.maxIterations as number | undefined;
49
49
 
50
50
  // Always show when there's ralph data (even when off, to show red dot)
51
- if (!active && !name && iteration === undefined) return { content: "", visible: false };
51
+ if (!active && !name && iteration === undefined) {
52
+ // Show muted placeholder when enabled but no data
53
+ if (isSegmentEnabled("ralph", "active_loops")) {
54
+ return { content: mutedPlaceholder("🔁 RL OFF"), visible: true };
55
+ }
56
+ return { content: "", visible: false };
57
+ }
52
58
 
53
59
  const dot = active ? GREEN_DOT : RED_DOT;
54
60
 
61
+ const ralphIcon = getIcon("activeLoops");
62
+
55
63
  if (active) {
56
64
  // Active: green dot + iteration stats
57
65
  const iterStr = iteration !== undefined
@@ -59,15 +67,11 @@ function renderActiveLoopsSegment(ctx: FooterSegmentContext): RenderedSegment {
59
67
  : "";
60
68
  const nameStr = name ? ` ${name}` : "";
61
69
  // Color the icon and text parts, keep dot's own color
62
- const iconAndText = `${RALPH_ICON} ${iterStr}${nameStr}`;
63
- const coloredText = colorText(ctx, "ralphOn", iconAndText);
64
- // Insert the dot after the icon
65
- const content = `${RALPH_ICON} ${dot} ${colorText(ctx, "ralphOn", `${iterStr}${nameStr}`)}`;
70
+ const content = `${ralphIcon} ${dot} ${colorText(ctx, "ralphOn", `${iterStr}${nameStr}`)}`;
66
71
  return { content, visible: true };
67
72
  } else {
68
73
  // Off/inactive: red dot
69
- const content = `${RALPH_ICON} ${dot}`;
70
- return { content: `${colorText(ctx, "ralphOff", RALPH_ICON)} ${dot}`, visible: true };
74
+ return { content: `${colorText(ctx, "ralphOff", ralphIcon)} ${dot}`, visible: true };
71
75
  }
72
76
  }
73
77
 
@@ -76,13 +80,19 @@ function renderTotalIterationsSegment(ctx: FooterSegmentContext): RenderedSegmen
76
80
  const active = data.active === true;
77
81
  const lastIteration = data.lastIteration as Record<string, unknown> | undefined;
78
82
  const iteration = data.iteration ?? lastIteration?.iteration;
79
- if (iteration === undefined || iteration === null) return { content: "", visible: false };
83
+ if (iteration === undefined || iteration === null) {
84
+ if (isSegmentEnabled("ralph", "total_iterations")) {
85
+ return { content: mutedPlaceholder("🔁 RL 0"), visible: true };
86
+ }
87
+ return { content: "", visible: false };
88
+ }
80
89
  const maxIterations = data.maxIterations;
81
90
  const display = maxIterations ? `${iteration}/${maxIterations}` : `${iteration}`;
82
91
 
92
+ const ralphIcon = getIcon("activeLoops");
83
93
  const dot = active ? GREEN_DOT : RED_DOT;
84
94
  const semantic: SemanticColor = active ? "ralphOn" : "ralphOff";
85
- const content = `${colorText(ctx, semantic, RALPH_ICON)} ${dot} ${colorText(ctx, semantic, display)}`;
95
+ const content = `${colorText(ctx, semantic, ralphIcon)} ${dot} ${colorText(ctx, semantic, display)}`;
86
96
  return { content, visible: true };
87
97
  }
88
98
 
@@ -90,20 +100,26 @@ function renderLoopStatusSegment(ctx: FooterSegmentContext): RenderedSegment {
90
100
  const data = getRalphData(ctx);
91
101
  const status = data.status as string | undefined;
92
102
  const name = data.name as string | undefined;
93
- if (!status && !name) return { content: "", visible: false };
103
+ if (!status && !name) {
104
+ if (isSegmentEnabled("ralph", "loop_status")) {
105
+ return { content: mutedPlaceholder("🔁 RL OFF"), visible: true };
106
+ }
107
+ return { content: "", visible: false };
108
+ }
94
109
 
110
+ const ralphIcon = getIcon("activeLoops");
95
111
  const dot = status === "active" ? GREEN_DOT : status === "completed" ? GREEN_DOT : RED_DOT;
96
112
  const statusIcon = status === "active" ? "▶" : status === "paused" ? "⏸" : status === "completed" ? "✓" : "";
97
113
  const display = name ? `${statusIcon} ${name}` : `${statusIcon}`;
98
114
 
99
115
  const active = status === "active" || status === "completed";
100
116
  const semantic: SemanticColor = active ? "ralphOn" : "ralphOff";
101
- const content = `${colorText(ctx, semantic, RALPH_ICON)} ${dot} ${colorText(ctx, semantic, display)}`;
117
+ const content = `${colorText(ctx, semantic, ralphIcon)} ${dot} ${colorText(ctx, semantic, display)}`;
102
118
  return { content, visible: true };
103
119
  }
104
120
 
105
121
  export const RALPH_SEGMENTS: FooterSegment[] = [
106
- { id: "active_loops", label: "Active Loops", icon: "", render: renderActiveLoopsSegment, defaultShow: true },
107
- { id: "total_iterations", label: "Total Iterations", icon: "", render: renderTotalIterationsSegment, defaultShow: true },
108
- { id: "loop_status", label: "Loop Status", icon: "", render: renderLoopStatusSegment, defaultShow: true },
122
+ { id: "active_loops", label: "Loops", shortLabel: "RL", description: "Active Ralph loops", zone: "center", icon: "", render: renderActiveLoopsSegment, defaultShow: true },
123
+ { id: "total_iterations", label: "Iterations", shortLabel: "ITR", description: "Total Ralph loop iterations", zone: "center", icon: "", render: renderTotalIterationsSegment, defaultShow: true },
124
+ { id: "loop_status", label: "Status", shortLabel: "STS", description: "Current loop status", zone: "center", icon: "", render: renderLoopStatusSegment, defaultShow: true },
109
125
  ];
@@ -7,7 +7,7 @@
7
7
  * Status keys from packages:
8
8
  * "unipi-workflow" → "⚡ wf:brainstorm ✓ rl" (active command shown)
9
9
  * "ralph" → "rl:loop-name 3/50"
10
- * "unipi-memory" → "⚡ mem 75p/101all"
10
+ * "unipi-memory" → "⚡ MEM 75p/101all"
11
11
  * "subagents" → various
12
12
  */
13
13
 
@@ -15,20 +15,20 @@ import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../ty
15
15
  import { getIcon } from "../rendering/icons.js";
16
16
  import { loadFooterSettings } from "../config.js";
17
17
  import { getSeparator } from "../rendering/separators.js";
18
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
18
19
 
19
20
  /** Map status keys to short display names and segment IDs for icons */
20
21
  const STATUS_DISPLAY: Record<string, { short: string; segmentId: string }> = {
21
- "unipi-workflow": { short: "wf", segmentId: "currentCommand" },
22
- workflow: { short: "wf", segmentId: "currentCommand" },
23
- ralph: { short: "rl", segmentId: "activeLoops" },
24
- "unipi-memory": { short: "mem", segmentId: "projectCount" },
25
- memory: { short: "mem", segmentId: "projectCount" },
26
- compactor: { short: "cmp", segmentId: "compactions" },
27
- mcp: { short: "mcp", segmentId: "serversTotal" },
28
- notify: { short: "ntf", segmentId: "platformsEnabled" },
29
- kanboard: { short: "kb", segmentId: "docsCount" },
30
- info: { short: "info", segmentId: "extensionStatuses" },
31
- subagents: { short: "sa", segmentId: "extensionStatuses" },
22
+ "unipi-workflow": { short: "WF", segmentId: "currentCommand" },
23
+ workflow: { short: "WF", segmentId: "currentCommand" },
24
+ ralph: { short: "RL", segmentId: "activeLoops" },
25
+ memory: { short: "MEM", segmentId: "projectCount" },
26
+ compactor: { short: "CMP", segmentId: "compactions" },
27
+ mcp: { short: "MCP", segmentId: "serversTotal" },
28
+ notify: { short: "NTF", segmentId: "platformsEnabled" },
29
+ kanboard: { short: "KB", segmentId: "docsCount" },
30
+ info: { short: "INF", segmentId: "extensionStatuses" },
31
+ subagents: { short: "SA", segmentId: "extensionStatuses" },
32
32
  };
33
33
 
34
34
  /** Get the separator character for the current settings */
@@ -110,10 +110,15 @@ function renderExtensionStatusesSegment(ctx: FooterSegmentContext): RenderedSegm
110
110
 
111
111
  if (parts.length === 0) return { content: "", visible: false };
112
112
 
113
+ // Clamp total content to terminal width to prevent TUI crash
113
114
  const content = parts.join(` ${sep} `);
115
+ const maxW = ctx.width > 0 ? ctx.width : 120;
116
+ if (visibleWidth(content) > maxW) {
117
+ return { content: truncateToWidth(content, maxW, "…"), visible: true };
118
+ }
114
119
  return { content, visible: true };
115
120
  }
116
121
 
117
122
  export const STATUS_EXT_SEGMENTS: FooterSegment[] = [
118
- { id: "extension_statuses", label: "Extensions", icon: "", render: renderExtensionStatusesSegment, defaultShow: true },
123
+ { id: "extension_statuses", label: "Extensions", shortLabel: "EXT", description: "Extension statuses overview", zone: "center", icon: "", render: renderExtensionStatusesSegment, defaultShow: true },
119
124
  ];
@@ -10,8 +10,7 @@ import type { FooterSegment, FooterSegmentContext, RenderedSegment, SemanticColo
10
10
  import { applyColor } from "../rendering/theme.js";
11
11
  import { getIcon } from "../rendering/icons.js";
12
12
 
13
- /** Nerd Font icon for workflow: */
14
- const WORKFLOW_ICON = "\uf52e";
13
+
15
14
 
16
15
  function withIcon(segmentId: string, text: string): string {
17
16
  const icon = getIcon(segmentId);
@@ -24,21 +23,43 @@ function getWorkflowData(ctx: FooterSegmentContext): Record<string, unknown> {
24
23
  return data as Record<string, unknown>;
25
24
  }
26
25
 
27
- /** Map a workflow command name to a semantic color for slight differentiation */
26
+ /** Map a workflow command name to a semantic color for category differentiation */
28
27
  function getWorkflowSemanticColor(command: string): SemanticColor {
29
- const commandLower = command.toLowerCase();
30
-
31
- if (commandLower.includes("brainstorm")) return "workflowBrainstorm";
32
- if (commandLower.includes("plan")) return "workflowPlan";
33
- if (commandLower.includes("work") && !commandLower.includes("network") && !commandLower.includes("framework")) return "workflowWork";
34
- if (commandLower.includes("review")) return "workflowReview";
35
- if (commandLower.includes("auto")) return "workflowAuto";
36
- if (commandLower.includes("fix") || commandLower.includes("debug")) return "workflowWork";
37
- if (commandLower.includes("quick")) return "workflowOther";
38
- if (commandLower.includes("document")) return "workflowPlan";
39
- if (commandLower.includes("consolidate")) return "workflowOther";
40
-
41
- return "workflow";
28
+ const c = command.toLowerCase();
29
+
30
+ // Red: brainstorm, debug, gather-context, quick-fix, quick-work, chore-create
31
+ if (c.includes("brainstorm") || c.includes("debug") || c.includes("gather-context") ||
32
+ c.includes("quick-fix") || c.includes("quick-work") || c.includes("chore-create")) {
33
+ return "workflowBrainstorm";
34
+ }
35
+
36
+ // Orange: chore-execute, plan
37
+ if (c.includes("chore-exec") || c.includes("plan")) {
38
+ return "workflowChoreExec";
39
+ }
40
+
41
+ // Yellow: work
42
+ if (c.includes("work") && !c.includes("network") && !c.includes("framework") && !c.includes("worktree")) {
43
+ return "workflowWork";
44
+ }
45
+
46
+ // Green: review-work, review
47
+ if (c.includes("review")) {
48
+ return "workflowReview";
49
+ }
50
+
51
+ // Blue: worktree-*
52
+ if (c.includes("worktree")) {
53
+ return "worktree" as SemanticColor;
54
+ }
55
+
56
+ // Auto
57
+ if (c.includes("auto")) {
58
+ return "workflowAuto";
59
+ }
60
+
61
+ // Default: idle/none
62
+ return "workflowNone";
42
63
  }
43
64
 
44
65
  function renderCurrentCommandSegment(ctx: FooterSegmentContext): RenderedSegment {
@@ -46,15 +67,17 @@ function renderCurrentCommandSegment(ctx: FooterSegmentContext): RenderedSegment
46
67
  const active = data.active === true;
47
68
  const command = data.command as string | undefined;
48
69
 
70
+ const workflowIcon = getIcon("currentCommand");
71
+
49
72
  // No workflow — show dash
50
73
  if (!command) {
51
- const content = `${WORKFLOW_ICON} -`;
74
+ const content = withIcon("currentCommand", "-");
52
75
  return { content: applyColor("workflow", content, ctx.theme, ctx.colors), visible: true };
53
76
  }
54
77
 
55
78
  const statusPrefix = active ? "▶" : "✓";
56
79
  const semanticColor = getWorkflowSemanticColor(command);
57
- const content = `${WORKFLOW_ICON} ${statusPrefix} ${command}`;
80
+ const content = `${workflowIcon} ${statusPrefix} ${command}`;
58
81
  return { content: applyColor(semanticColor, content, ctx.theme, ctx.colors), visible: true };
59
82
  }
60
83
 
@@ -94,7 +117,7 @@ function formatDuration(ms: number): string {
94
117
  }
95
118
 
96
119
  export const WORKFLOW_SEGMENTS: FooterSegment[] = [
97
- { id: "current_command", label: "Current Command", icon: "", render: renderCurrentCommandSegment, defaultShow: true },
98
- { id: "sandbox_level", label: "Sandbox Level", icon: "", render: renderSandboxLevelSegment, defaultShow: false },
99
- { id: "command_duration", label: "Command Duration", icon: "", render: renderCommandDurationSegment, defaultShow: true },
120
+ { id: "current_command", label: "Command", shortLabel: "WRK", description: "Active workflow command", zone: "left", icon: "", render: renderCurrentCommandSegment, defaultShow: true },
121
+ { id: "sandbox_level", label: "Sandbox", shortLabel: "SBX", description: "Sandbox permission level", zone: "center", icon: "", render: renderSandboxLevelSegment, defaultShow: false },
122
+ { id: "command_duration", label: "Duration", shortLabel: "CDUR", description: "Current command duration", zone: "center", icon: "", render: renderCommandDurationSegment, defaultShow: true },
100
123
  ];
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @pi-unipi/footer — TPS (Tokens Per Second) tracker
3
+ *
4
+ * Per-message TPS calculation for live generation rate display.
5
+ * Tracks individual assistant messages with start/stop timestamps
6
+ * to measure generation rate excluding idle/tool-execution time.
7
+ */
8
+
9
+ /** Per-message TPS record */
10
+ interface MessageTpsRecord {
11
+ /** Message index in the session */
12
+ messageIndex: number;
13
+ /** Output tokens for this message */
14
+ outputTokens: number;
15
+ /** When generation started (Date.now()) */
16
+ startedAt: number;
17
+ /** When generation completed (Date.now()), 0 if still generating */
18
+ completedAt: number;
19
+ /** Computed TPS for this message */
20
+ tps: number;
21
+ }
22
+
23
+ /**
24
+ * Tracks per-message TPS and computes live/session metrics.
25
+ *
26
+ * Usage: Call `onMessageUpdate()` whenever output tokens change.
27
+ * The tracker records generation start/stop per message and computes
28
+ * live TPS from the current message and session averages excluding idle time.
29
+ */
30
+ export class TpsTracker {
31
+ /** Per-message records */
32
+ private records: MessageTpsRecord[] = [];
33
+
34
+ /** Highest message index seen so far (for dedup) */
35
+ private lastSeenMessageCount = 0;
36
+
37
+ /** Total output tokens across all completed messages */
38
+ private totalOutput = 0;
39
+
40
+ /**
41
+ * Update with the latest message data from the session.
42
+ * Call this on every tick (e.g. 1s interval) with the current state.
43
+ *
44
+ * @param messageIndex - Index of the assistant message (0-based, sequential)
45
+ * @param outputTokens - Output tokens for this message
46
+ * @param hasStopReason - Whether this message has completed (has stopReason)
47
+ */
48
+ onMessageUpdate(messageIndex: number, outputTokens: number, hasStopReason: boolean): void {
49
+ const now = Date.now();
50
+
51
+ // New message — create a record
52
+ if (messageIndex >= this.records.length) {
53
+ // Fill gaps if indices jump
54
+ while (this.records.length < messageIndex) {
55
+ this.records.push({
56
+ messageIndex: this.records.length,
57
+ outputTokens: 0,
58
+ startedAt: 0,
59
+ completedAt: 0,
60
+ tps: 0,
61
+ });
62
+ }
63
+
64
+ if (hasStopReason && outputTokens > 0) {
65
+ // Fast message: already completed on first sighting
66
+ // Estimate duration: floor of 1 second, or outputTokens/100, whichever is smaller
67
+ const estimatedDuration = Math.max(1, outputTokens / 100);
68
+ const tps = outputTokens / estimatedDuration;
69
+
70
+ this.records.push({
71
+ messageIndex,
72
+ outputTokens,
73
+ startedAt: now - estimatedDuration * 1000,
74
+ completedAt: now,
75
+ tps,
76
+ });
77
+ this.totalOutput += outputTokens;
78
+ } else {
79
+ // Just started — mark start time
80
+ this.records.push({
81
+ messageIndex,
82
+ outputTokens,
83
+ startedAt: now,
84
+ completedAt: 0,
85
+ tps: 0,
86
+ });
87
+ }
88
+ this.lastSeenMessageCount = messageIndex + 1;
89
+ return;
90
+ }
91
+
92
+ // Update existing message
93
+ const record = this.records[messageIndex];
94
+ if (!record) return;
95
+
96
+ record.outputTokens = outputTokens;
97
+
98
+ if (record.completedAt === 0 && hasStopReason) {
99
+ // Message just completed
100
+ record.completedAt = now;
101
+ const durationSec = (record.completedAt - record.startedAt) / 1000;
102
+ record.tps = durationSec > 0 ? outputTokens / durationSec : outputTokens;
103
+ this.totalOutput += outputTokens;
104
+ } else if (record.completedAt === 0) {
105
+ // Still generating — update output tokens (live TPS computed on demand)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Get the live TPS from the currently generating message.
111
+ * Returns the instantaneous rate based on tokens generated so far
112
+ * in the current message divided by elapsed time.
113
+ */
114
+ getLiveTps(): number {
115
+ // Find the last record that's still generating
116
+ for (let i = this.records.length - 1; i >= 0; i--) {
117
+ const record = this.records[i];
118
+ if (record.completedAt === 0 && record.startedAt > 0) {
119
+ // Currently generating
120
+ const elapsedSec = (Date.now() - record.startedAt) / 1000;
121
+ if (elapsedSec <= 0) return 0;
122
+ return record.outputTokens / elapsedSec;
123
+ }
124
+ }
125
+ // No active generation — return the last completed message's TPS
126
+ if (this.records.length > 0) {
127
+ const last = this.records[this.records.length - 1];
128
+ return last.tps;
129
+ }
130
+ return 0;
131
+ }
132
+
133
+ /**
134
+ * Get the session average TPS, excluding idle/tool-execution time.
135
+ * Computed as total output tokens / total generation time.
136
+ */
137
+ getSessionAvgTps(): number {
138
+ let totalTokens = 0;
139
+ let totalDurationSec = 0;
140
+
141
+ for (const record of this.records) {
142
+ if (record.completedAt > 0 && record.startedAt > 0) {
143
+ totalTokens += record.outputTokens;
144
+ totalDurationSec += (record.completedAt - record.startedAt) / 1000;
145
+ }
146
+ }
147
+
148
+ // Include currently generating message in average
149
+ for (let i = this.records.length - 1; i >= 0; i--) {
150
+ if (this.records[i].completedAt === 0 && this.records[i].startedAt > 0) {
151
+ totalTokens += this.records[i].outputTokens;
152
+ totalDurationSec += (Date.now() - this.records[i].startedAt) / 1000;
153
+ break;
154
+ }
155
+ }
156
+
157
+ if (totalDurationSec <= 0) return 0;
158
+ return totalTokens / totalDurationSec;
159
+ }
160
+
161
+ /**
162
+ * Whether the model is currently streaming tokens.
163
+ * True if the latest message has started but not completed.
164
+ */
165
+ isStreaming(): boolean {
166
+ if (this.records.length === 0) return false;
167
+ const last = this.records[this.records.length - 1];
168
+ return last.startedAt > 0 && last.completedAt === 0;
169
+ }
170
+
171
+ /**
172
+ * Whether the model was recently generating tokens.
173
+ * Kept for backward compatibility with renderer.
174
+ */
175
+ isGenerating(): boolean {
176
+ return this.isStreaming();
177
+ }
178
+
179
+ /**
180
+ * Get total output tokens for the session.
181
+ */
182
+ getTotalOutput(): number {
183
+ // Include tokens from incomplete messages too
184
+ let total = this.totalOutput;
185
+ for (const record of this.records) {
186
+ if (record.completedAt === 0 && record.startedAt > 0) {
187
+ total += record.outputTokens;
188
+ }
189
+ }
190
+ return total;
191
+ }
192
+
193
+ /**
194
+ * Reset the tracker (e.g., on session shutdown).
195
+ */
196
+ reset(): void {
197
+ this.records = [];
198
+ this.lastSeenMessageCount = 0;
199
+ this.totalOutput = 0;
200
+ }
201
+ }
202
+
203
+ /** Singleton TPS tracker instance */
204
+ export const tpsTracker = new TpsTracker();