@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.
- package/README.md +73 -158
- package/package.json +1 -1
- package/src/commands.ts +38 -120
- package/src/config.ts +10 -6
- package/src/events.ts +34 -34
- package/src/help.ts +160 -0
- package/src/index.ts +46 -10
- package/src/presets.ts +40 -31
- package/src/registry/index.ts +5 -7
- package/src/rendering/icons.ts +125 -107
- package/src/rendering/renderer.ts +198 -79
- package/src/rendering/theme.ts +56 -29
- package/src/segments/compactor.ts +21 -10
- package/src/segments/core.ts +134 -67
- package/src/segments/kanboard.ts +24 -8
- package/src/segments/mcp.ts +25 -8
- package/src/segments/memory.ts +17 -11
- package/src/segments/notify.ts +16 -5
- package/src/segments/ralph.ts +33 -17
- package/src/segments/status-ext.ts +18 -13
- package/src/segments/workflow.ts +44 -21
- package/src/tps-tracker.ts +204 -0
- package/src/tui/settings-tui.ts +389 -157
- package/src/types.ts +51 -12
package/src/segments/notify.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
];
|
package/src/segments/ralph.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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)
|
|
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,
|
|
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
|
|
107
|
-
{ id: "total_iterations", label: "Total
|
|
108
|
-
{ id: "loop_status", label: "
|
|
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" → "⚡
|
|
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: "
|
|
22
|
-
workflow: { short: "
|
|
23
|
-
ralph: { short: "
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
];
|
package/src/segments/workflow.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
26
|
+
/** Map a workflow command name to a semantic color for category differentiation */
|
|
28
27
|
function getWorkflowSemanticColor(command: string): SemanticColor {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 =
|
|
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 = `${
|
|
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: "
|
|
98
|
-
{ id: "sandbox_level", label: "Sandbox
|
|
99
|
-
{ id: "command_duration", label: "
|
|
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();
|