opencode2-tps 0.3.0 → 0.4.0-rc.1
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 +7 -7
- package/dist/debug.js +79 -0
- package/dist/options.js +53 -0
- package/dist/tracker.js +263 -0
- package/dist/tui.js +4 -387
- package/options.schema.json +46 -0
- package/package.json +8 -6
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ When OpenCode reports terminal usage, the token count becomes exact while TPS re
|
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
15
|
-
Built against the OpenCode 2 preview. The earliest known compatible beta is `0.0.0-beta-17595
|
|
15
|
+
Built against the OpenCode 2 preview. The earliest known compatible beta is `0.0.0-beta-17595`. The TUI plugin API is still moving, so a much newer or older build may drop the indicator without an error. If the figure never appears, check your version first, then [open an issue](https://github.com/P-Theo/opencode2-tps/issues).
|
|
16
16
|
|
|
17
17
|
Install it with the CLI's plugin command, which adds the entry to `~/.config/opencode/cli.json` for you:
|
|
18
18
|
|
|
@@ -43,15 +43,15 @@ To set options, use the object form:
|
|
|
43
43
|
|
|
44
44
|
A running TUI picks up `cli.json` changes immediately. On first use it installs the package into its own cache under `~/.cache/opencode/npm/` on Linux — one millisecond-timestamped generation per spec, for example `opencode2-tps@latest/<generation>/`, with the newest generation winning.
|
|
45
45
|
|
|
46
|
-
The host reuses the newest installed generation without contacting the registry, so a restart alone may not pick up a new release.
|
|
46
|
+
The host reuses the newest installed generation without contacting the registry, so a restart alone may not pick up a new release. Delete the spec's cache directory and restart to upgrade:
|
|
47
47
|
|
|
48
48
|
```sh
|
|
49
49
|
rm -rf ~/.cache/opencode/npm/opencode2-tps@latest
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Put the range in the entry to pin a version instead — `"opencode2-tps@0.1.0"`. Every distinct entry gets its own cache directory.
|
|
53
53
|
|
|
54
|
-
The plugin ID is `opencode2.tps`.
|
|
54
|
+
The plugin ID is `opencode2.tps`. Add `"-opencode2.tps"` after it to switch it off without losing the entry and its options:
|
|
55
55
|
|
|
56
56
|
```json
|
|
57
57
|
{
|
|
@@ -71,9 +71,9 @@ The defaults are usable as they are. For the full option list, the ranges and mo
|
|
|
71
71
|
|
|
72
72
|
## How it works
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
The plugin estimates tokens from observable UTF-8 bytes at a default of 4.75 bytes per token and calculates a bounded rolling delivery rate while output streams. Complete text, reasoning, and tool-input events reconcile buffered or missed deltas without creating artificial live-rate spikes.
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
OpenCode's reported output and reasoning usage replaces the byte estimate at the end of each model step. Settled TPS divides those exact tokens by observed step spans ending at `session.step.streamed`, the host's authoritative end of the model stream, published before local tools join. Hosts that do not publish the event fall back to the final model-content boundary. Either way, local tool execution and time between model calls are excluded.
|
|
77
77
|
|
|
78
78
|
OpenCode's built-in assistant-footer t/s divides visible output tokens by the same step spans, leaving hidden reasoning out of its numerator. This plugin counts output plus reasoning, so on reasoning models its settled figure reads higher than the built-in one — those tokens were generated too.
|
|
79
79
|
|
|
@@ -85,7 +85,7 @@ For more detail, see [Architecture](docs/development.md#architecture).
|
|
|
85
85
|
|
|
86
86
|
Every output event carries the ID of the session that produced it, so each session is measured on its own.
|
|
87
87
|
|
|
88
|
-
A sub-agent streams under its own child session ID.
|
|
88
|
+
A sub-agent streams under its own child session ID. The orchestrator's number stops moving while it works and holds the average of the output the orchestrator produced before delegating. Open the sub-agent's session to watch its live throughput.
|
|
89
89
|
|
|
90
90
|
<p align="center">
|
|
91
91
|
<img src="docs/screenshots/subagent_tps.png" width="750" alt="Sub-agent session showing its own live throughput indicator" />
|
package/dist/debug.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { appendFileSync, lstatSync, mkdirSync, mkdtempSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// debug
|
|
7
|
+
//
|
|
8
|
+
// Off unless asked for: an unconfigured install must never touch disk.
|
|
9
|
+
//
|
|
10
|
+
// The log goes inside an owner-only directory instead of straight into the
|
|
11
|
+
// shared temp directory. A guessable path there (the PID is a small, enumerable
|
|
12
|
+
// number) can be pre-created by another local user as a symlink, which
|
|
13
|
+
// appendFileSync would happily follow into a file of their choosing; a
|
|
14
|
+
// world-readable log would also hand them the session IDs it records.
|
|
15
|
+
|
|
16
|
+
export const DEBUG_DIR_PREFIX = "tps-debug-";
|
|
17
|
+
const debugState = {
|
|
18
|
+
enabled: false,
|
|
19
|
+
file: ""
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** True only for a real directory that belongs to us and to no one else. */
|
|
23
|
+
function isOwnPrivateDir(path) {
|
|
24
|
+
try {
|
|
25
|
+
const stats = lstatSync(path); // lstat, not stat: a planted symlink must not pass
|
|
26
|
+
|
|
27
|
+
if (!stats.isDirectory()) return false;
|
|
28
|
+
const uid = process.getuid?.();
|
|
29
|
+
|
|
30
|
+
// Windows has no uid and a per-user temp directory, so there is nothing to check.
|
|
31
|
+
if (uid === undefined) return true;
|
|
32
|
+
return stats.uid === uid && (stats.mode & 0o777) === 0o700;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The 0700 directory to log into. Named after the PID so the process's own hot
|
|
40
|
+
* reloads keep appending to one file, and only reused when it really is ours —
|
|
41
|
+
* anything else squatting on the name gets sidestepped via mkdtemp.
|
|
42
|
+
*/
|
|
43
|
+
function debugDir() {
|
|
44
|
+
const preferred = join(tmpdir(), `${DEBUG_DIR_PREFIX}${process.pid}`);
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(preferred, {
|
|
47
|
+
mode: 0o700
|
|
48
|
+
});
|
|
49
|
+
return preferred;
|
|
50
|
+
} catch {
|
|
51
|
+
if (isOwnPrivateDir(preferred)) return preferred;
|
|
52
|
+
return mkdtempSync(`${preferred}-`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function configureDebug(enabled) {
|
|
56
|
+
debugState.enabled = enabled;
|
|
57
|
+
if (!enabled || debugState.file) return;
|
|
58
|
+
try {
|
|
59
|
+
debugState.file = join(debugDir(), "tps.log");
|
|
60
|
+
} catch {
|
|
61
|
+
debugState.enabled = false; // no usable temp directory: stay silent
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Truthy spellings only: `TPS_DEBUG=0` must not start writing to disk. */
|
|
66
|
+
export function isEnvEnabled(value) {
|
|
67
|
+
if (value === undefined) return false;
|
|
68
|
+
const normalized = value.trim().toLowerCase();
|
|
69
|
+
return normalized === "1" || normalized === "true";
|
|
70
|
+
}
|
|
71
|
+
export function mark(line) {
|
|
72
|
+
if (!debugState.enabled) return;
|
|
73
|
+
try {
|
|
74
|
+
const safeLine = line.replace(/\p{Cc}/gu, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
75
|
+
appendFileSync(debugState.file, `${new Date().toISOString()} ${safeLine}\n`);
|
|
76
|
+
} catch {
|
|
77
|
+
// debug only; never break the host
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// options
|
|
3
|
+
//
|
|
4
|
+
// `ctx.options` is host-supplied JSON (Record<string, any>), so this is a real
|
|
5
|
+
// parsing boundary: every value is validated and clamped, and anything invalid
|
|
6
|
+
// falls back to the default rather than propagating NaN into the arithmetic.
|
|
7
|
+
// `options.schema.json` documents the same enumerations, defaults, and bounds
|
|
8
|
+
// for editors and agents; `tests/options-schema.test.ts` keeps the two in
|
|
9
|
+
// agreement.
|
|
10
|
+
|
|
11
|
+
import { BYTES_PER_TOKEN_MAX, BYTES_PER_TOKEN_MIN, DEFAULT_CONFIG, formatTps } from "./tracker.js";
|
|
12
|
+
export const DISPLAY_MODES = ["both", "tokens", "tps"];
|
|
13
|
+
export const REFRESH_HZ_MIN = 1;
|
|
14
|
+
export const REFRESH_HZ_MAX = 60;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A value as it can arrive from `cli.json`: arbitrary JSON, nothing more.
|
|
18
|
+
* Named so the option boundary has a real input contract to validate against.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The option surface, exactly as documented in the README, before validation. */
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_OPTIONS = {
|
|
24
|
+
...DEFAULT_CONFIG,
|
|
25
|
+
display: "both",
|
|
26
|
+
refreshHz: 8,
|
|
27
|
+
debug: false
|
|
28
|
+
};
|
|
29
|
+
function isFiniteNumber(value) {
|
|
30
|
+
return Number.isFinite(value);
|
|
31
|
+
}
|
|
32
|
+
function isDisplayMode(value) {
|
|
33
|
+
return DISPLAY_MODES.some(mode => mode === value);
|
|
34
|
+
}
|
|
35
|
+
function clampNumber(value, fallback, min, max) {
|
|
36
|
+
if (!isFiniteNumber(value)) return fallback;
|
|
37
|
+
return Math.min(Math.max(value, min), max);
|
|
38
|
+
}
|
|
39
|
+
export function resolveOptions(raw) {
|
|
40
|
+
return {
|
|
41
|
+
display: isDisplayMode(raw.display) ? raw.display : DEFAULT_OPTIONS.display,
|
|
42
|
+
refreshHz: clampNumber(raw.refreshHz, DEFAULT_OPTIONS.refreshHz, REFRESH_HZ_MIN, REFRESH_HZ_MAX),
|
|
43
|
+
bytesPerToken: clampNumber(raw.bytesPerToken, DEFAULT_OPTIONS.bytesPerToken, BYTES_PER_TOKEN_MIN, BYTES_PER_TOKEN_MAX),
|
|
44
|
+
debug: raw.debug === true
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function formatLabel(value, display) {
|
|
48
|
+
const tokens = `${value.tokensEstimated ? "~" : ""}${value.tokens} tok`;
|
|
49
|
+
const tps = value.tps === null ? null : `~${formatTps(value.tps)} t/s`;
|
|
50
|
+
if (display === "tokens") return tokens;
|
|
51
|
+
if (display === "tps") return tps ?? "— t/s";
|
|
52
|
+
return tps === null ? tokens : `${tokens} · ${tps}`;
|
|
53
|
+
}
|
package/dist/tracker.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// tracker (UI-free)
|
|
3
|
+
//
|
|
4
|
+
// Measures the rate of the observable model stream: bytes to a rolling
|
|
5
|
+
// estimate while output arrives, exact step usage once the host reports it,
|
|
6
|
+
// and a frozen average after the run ends.
|
|
7
|
+
|
|
8
|
+
import { mark } from "./debug.js";
|
|
9
|
+
export const DEFAULT_CONFIG = {
|
|
10
|
+
bytesPerToken: 4.75
|
|
11
|
+
};
|
|
12
|
+
// The frozen final average stays visible until the next prompt starts a new run.
|
|
13
|
+
|
|
14
|
+
export const BYTES_PER_TOKEN_MIN = 1;
|
|
15
|
+
export const BYTES_PER_TOKEN_MAX = 16;
|
|
16
|
+
const LIVE_WINDOW_MS = 5_000;
|
|
17
|
+
const LIVE_STALE_MS = 1_500;
|
|
18
|
+
const LIVE_MIN_DURATION_MS = 250;
|
|
19
|
+
function estimateTokens(bytes, bytesPerToken) {
|
|
20
|
+
return Math.ceil(bytes / bytesPerToken);
|
|
21
|
+
}
|
|
22
|
+
export function formatTps(value) {
|
|
23
|
+
if (value < 10) return value.toFixed(2);
|
|
24
|
+
if (value < 100) return value.toFixed(1);
|
|
25
|
+
return Math.round(value).toString();
|
|
26
|
+
}
|
|
27
|
+
// A finished run keeps its frozen average indefinitely (it is what the composer
|
|
28
|
+
// still shows), so the map is bounded instead: past this many tracked sessions,
|
|
29
|
+
// the least recently started *finished* runs are dropped. Running ones are never
|
|
30
|
+
// touched. Entries are tiny, so this is hygiene for a long-lived TUI, not a
|
|
31
|
+
// memory fix.
|
|
32
|
+
const MAX_TRACKED_RUNS = 64;
|
|
33
|
+
export class TpsTracker {
|
|
34
|
+
// Insertion order is kept equal to run-start recency (see beginRun), which is
|
|
35
|
+
// what makes eviction from the front drop the stalest session.
|
|
36
|
+
runs = new Map();
|
|
37
|
+
constructor(config = DEFAULT_CONFIG) {
|
|
38
|
+
this.config = config;
|
|
39
|
+
}
|
|
40
|
+
state(sessionID) {
|
|
41
|
+
let st = this.runs.get(sessionID);
|
|
42
|
+
if (!st) {
|
|
43
|
+
st = {
|
|
44
|
+
phase: "ended",
|
|
45
|
+
settledTokens: 0,
|
|
46
|
+
settledDurationMs: 0,
|
|
47
|
+
tokensEstimated: false,
|
|
48
|
+
partial: false,
|
|
49
|
+
activeStep: null,
|
|
50
|
+
settledSteps: new Set(),
|
|
51
|
+
frozen: null
|
|
52
|
+
};
|
|
53
|
+
this.runs.set(sessionID, st);
|
|
54
|
+
}
|
|
55
|
+
return st;
|
|
56
|
+
}
|
|
57
|
+
beginRun(sessionID) {
|
|
58
|
+
const st = this.state(sessionID);
|
|
59
|
+
st.phase = "running";
|
|
60
|
+
st.settledTokens = 0;
|
|
61
|
+
st.settledDurationMs = 0;
|
|
62
|
+
st.tokensEstimated = false;
|
|
63
|
+
st.partial = false;
|
|
64
|
+
st.activeStep = null;
|
|
65
|
+
st.settledSteps.clear();
|
|
66
|
+
st.frozen = null;
|
|
67
|
+
// Re-insert so this session becomes the newest in iteration order. Every
|
|
68
|
+
// entry is created through here, so the cap is checked on the one path that
|
|
69
|
+
// can grow the map.
|
|
70
|
+
this.runs.delete(sessionID);
|
|
71
|
+
this.runs.set(sessionID, st);
|
|
72
|
+
this.evictStale();
|
|
73
|
+
}
|
|
74
|
+
evictStale() {
|
|
75
|
+
if (this.runs.size <= MAX_TRACKED_RUNS) return;
|
|
76
|
+
for (const [sessionID, st] of this.runs) {
|
|
77
|
+
if (this.runs.size <= MAX_TRACKED_RUNS) return;
|
|
78
|
+
if (st.phase === "running") continue;
|
|
79
|
+
this.dropSession(sessionID);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
ensureStep(sessionID, assistantMessageID, now, replace = false) {
|
|
83
|
+
const st = this.state(sessionID);
|
|
84
|
+
if (st.settledSteps.has(assistantMessageID) || st.phase === "ended" && st.frozen !== null) return null;
|
|
85
|
+
if (st.phase !== "running") this.beginRun(sessionID);
|
|
86
|
+
const running = this.state(sessionID);
|
|
87
|
+
if (running.activeStep?.assistantMessageID === assistantMessageID) return running.activeStep;
|
|
88
|
+
if (running.activeStep && !replace) return null;
|
|
89
|
+
if (running.activeStep) this.settleActiveStep(running, undefined);
|
|
90
|
+
const step = {
|
|
91
|
+
assistantMessageID,
|
|
92
|
+
startedAt: now,
|
|
93
|
+
streamedAt: null,
|
|
94
|
+
lastBoundaryAt: null,
|
|
95
|
+
observableBytes: 0,
|
|
96
|
+
blocks: new Map(),
|
|
97
|
+
samples: []
|
|
98
|
+
};
|
|
99
|
+
running.activeStep = step;
|
|
100
|
+
running.frozen = null;
|
|
101
|
+
return step;
|
|
102
|
+
}
|
|
103
|
+
beginStep(sessionID, assistantMessageID, now = Date.now()) {
|
|
104
|
+
const st = this.state(sessionID);
|
|
105
|
+
if (st.phase !== "running") {
|
|
106
|
+
if (st.settledSteps.has(assistantMessageID)) return;
|
|
107
|
+
this.beginRun(sessionID);
|
|
108
|
+
}
|
|
109
|
+
if (st.activeStep?.assistantMessageID === assistantMessageID) return;
|
|
110
|
+
this.ensureStep(sessionID, assistantMessageID, now, true);
|
|
111
|
+
}
|
|
112
|
+
beginBlock(sessionID, assistantMessageID, blockID, now) {
|
|
113
|
+
const step = this.ensureStep(sessionID, assistantMessageID, now);
|
|
114
|
+
if (!step) return;
|
|
115
|
+
if (!step.blocks.has(blockID)) step.blocks.set(blockID, {
|
|
116
|
+
streamedBytes: 0,
|
|
117
|
+
finalBytes: null
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
push(sessionID, delta, now, assistantMessageID = "implicit", blockID = "implicit") {
|
|
121
|
+
if (!delta) return;
|
|
122
|
+
const step = this.ensureStep(sessionID, assistantMessageID, now);
|
|
123
|
+
if (!step) return;
|
|
124
|
+
let block = step.blocks.get(blockID);
|
|
125
|
+
if (!block) {
|
|
126
|
+
block = {
|
|
127
|
+
streamedBytes: 0,
|
|
128
|
+
finalBytes: null
|
|
129
|
+
};
|
|
130
|
+
step.blocks.set(blockID, block);
|
|
131
|
+
}
|
|
132
|
+
if (block.finalBytes !== null) return;
|
|
133
|
+
const bytes = Buffer.byteLength(delta, "utf8");
|
|
134
|
+
block.streamedBytes += bytes;
|
|
135
|
+
step.observableBytes += bytes;
|
|
136
|
+
step.samples.push({
|
|
137
|
+
bytes,
|
|
138
|
+
timestamp: now
|
|
139
|
+
});
|
|
140
|
+
const oldest = now - LIVE_WINDOW_MS;
|
|
141
|
+
while (step.samples[0] && step.samples[0].timestamp < oldest) step.samples.shift();
|
|
142
|
+
}
|
|
143
|
+
finishBlock(sessionID, assistantMessageID, blockID, text, now) {
|
|
144
|
+
const st = this.runs.get(sessionID);
|
|
145
|
+
const step = st?.activeStep;
|
|
146
|
+
if (!step || step.assistantMessageID !== assistantMessageID) return;
|
|
147
|
+
let block = step.blocks.get(blockID);
|
|
148
|
+
if (!block) {
|
|
149
|
+
block = {
|
|
150
|
+
streamedBytes: 0,
|
|
151
|
+
finalBytes: null
|
|
152
|
+
};
|
|
153
|
+
step.blocks.set(blockID, block);
|
|
154
|
+
}
|
|
155
|
+
if (block.finalBytes !== null) return;
|
|
156
|
+
block.finalBytes = Buffer.byteLength(text, "utf8");
|
|
157
|
+
step.observableBytes += block.finalBytes - block.streamedBytes;
|
|
158
|
+
step.lastBoundaryAt = Math.max(step.lastBoundaryAt ?? now, now);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The host's authoritative end of the model stream, published after the
|
|
163
|
+
* provider stream exits and before local tools join. Assigned rather than
|
|
164
|
+
* maxed so a retried attempt reusing the message ID moves the boundary to its
|
|
165
|
+
* own completion.
|
|
166
|
+
*/
|
|
167
|
+
markStreamed(sessionID, assistantMessageID, now) {
|
|
168
|
+
const st = this.runs.get(sessionID);
|
|
169
|
+
const step = st?.activeStep;
|
|
170
|
+
if (!step || step.assistantMessageID !== assistantMessageID) return;
|
|
171
|
+
step.streamedAt = now;
|
|
172
|
+
}
|
|
173
|
+
settleActiveStep(st, generatedTokens) {
|
|
174
|
+
const step = st.activeStep;
|
|
175
|
+
if (!step) return;
|
|
176
|
+
const exact = generatedTokens !== undefined && Number.isFinite(generatedTokens) && generatedTokens >= 0;
|
|
177
|
+
st.settledTokens += exact ? generatedTokens : estimateTokens(step.observableBytes, this.config.bytesPerToken);
|
|
178
|
+
if (!exact) {
|
|
179
|
+
st.tokensEstimated = true;
|
|
180
|
+
st.partial = true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// `session.step.streamed` is the exact stream end; the last content boundary
|
|
184
|
+
// remains the fallback for hosts that do not publish it.
|
|
185
|
+
const end = step.streamedAt ?? step.lastBoundaryAt;
|
|
186
|
+
if (end !== null) st.settledDurationMs += Math.max(0, end - step.startedAt);
|
|
187
|
+
st.settledSteps.add(step.assistantMessageID);
|
|
188
|
+
st.activeStep = null;
|
|
189
|
+
}
|
|
190
|
+
finishStep(sessionID, assistantMessageID, generatedTokens, _now) {
|
|
191
|
+
const st = this.runs.get(sessionID);
|
|
192
|
+
if (st?.activeStep?.assistantMessageID !== assistantMessageID) return;
|
|
193
|
+
this.settleActiveStep(st, generatedTokens);
|
|
194
|
+
}
|
|
195
|
+
finish(sessionID, _now) {
|
|
196
|
+
const st = this.runs.get(sessionID);
|
|
197
|
+
if (!st || st.phase === "ended") return;
|
|
198
|
+
if (st.activeStep) this.settleActiveStep(st, undefined);
|
|
199
|
+
st.phase = "ended";
|
|
200
|
+
const tokens = st.settledTokens;
|
|
201
|
+
if (tokens <= 0) {
|
|
202
|
+
this.evictStale();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const tps = st.settledDurationMs > 0 ? tokens / (st.settledDurationMs / 1000) : null;
|
|
206
|
+
st.frozen = {
|
|
207
|
+
tps,
|
|
208
|
+
tokens,
|
|
209
|
+
tokensEstimated: st.tokensEstimated,
|
|
210
|
+
partial: st.partial
|
|
211
|
+
};
|
|
212
|
+
mark(`finish sid=${sessionID} tokens=${tokens} observedMs=${st.settledDurationMs} tps=${tps?.toFixed(1) ?? "n/a"}`);
|
|
213
|
+
this.evictStale();
|
|
214
|
+
}
|
|
215
|
+
dropSession(sessionID) {
|
|
216
|
+
this.runs.delete(sessionID);
|
|
217
|
+
}
|
|
218
|
+
evict(sessionID) {
|
|
219
|
+
this.dropSession(sessionID);
|
|
220
|
+
}
|
|
221
|
+
hasRunning(now = Date.now()) {
|
|
222
|
+
for (const st of this.runs.values()) {
|
|
223
|
+
const last = st.activeStep?.samples.at(-1);
|
|
224
|
+
if (st.phase === "running" && last && now < last.timestamp + LIVE_STALE_MS) return true;
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
liveTps(step, now) {
|
|
229
|
+
const last = step.samples.at(-1);
|
|
230
|
+
if (!last) return null;
|
|
231
|
+
const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS);
|
|
232
|
+
const oldest = effectiveNow - LIVE_WINDOW_MS;
|
|
233
|
+
const samples = step.samples.filter(sample => sample.timestamp >= oldest);
|
|
234
|
+
const first = samples[0];
|
|
235
|
+
if (!first) return null;
|
|
236
|
+
const bytes = samples.reduce((total, sample) => total + sample.bytes, 0);
|
|
237
|
+
const durationMs = Math.max(effectiveNow - first.timestamp, LIVE_MIN_DURATION_MS);
|
|
238
|
+
return estimateTokens(bytes, this.config.bytesPerToken) / (durationMs / 1000);
|
|
239
|
+
}
|
|
240
|
+
value(sessionID, now) {
|
|
241
|
+
const st = this.runs.get(sessionID);
|
|
242
|
+
if (!st) return null;
|
|
243
|
+
if (st.frozen) return {
|
|
244
|
+
...st.frozen,
|
|
245
|
+
frozen: true,
|
|
246
|
+
tpsEstimated: true
|
|
247
|
+
};
|
|
248
|
+
if (st.phase !== "running") return null;
|
|
249
|
+
const active = st.activeStep;
|
|
250
|
+
const activeTokens = active ? estimateTokens(active.observableBytes, this.config.bytesPerToken) : 0;
|
|
251
|
+
const tokens = st.settledTokens + activeTokens;
|
|
252
|
+
if (tokens <= 0) return null;
|
|
253
|
+
const settledTps = st.settledDurationMs > 0 ? st.settledTokens / (st.settledDurationMs / 1000) : null;
|
|
254
|
+
return {
|
|
255
|
+
tps: active ? this.liveTps(active, now) ?? settledTps : settledTps,
|
|
256
|
+
tokens,
|
|
257
|
+
frozen: false,
|
|
258
|
+
tokensEstimated: st.tokensEstimated || active !== null,
|
|
259
|
+
tpsEstimated: true,
|
|
260
|
+
partial: st.partial
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
package/dist/tui.js
CHANGED
|
@@ -7,393 +7,9 @@ import { createComponent as _$createComponent } from "@opentui/solid";
|
|
|
7
7
|
/** @jsxImportSource @opentui/solid */
|
|
8
8
|
|
|
9
9
|
import { createMemo, createSignal, Show } from "solid-js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// debug
|
|
16
|
-
//
|
|
17
|
-
// Off unless asked for: an unconfigured install must never touch disk.
|
|
18
|
-
//
|
|
19
|
-
// The log goes inside an owner-only directory instead of straight into the
|
|
20
|
-
// shared temp directory. A guessable path there (the PID is a small, enumerable
|
|
21
|
-
// number) can be pre-created by another local user as a symlink, which
|
|
22
|
-
// appendFileSync would happily follow into a file of their choosing; a
|
|
23
|
-
// world-readable log would also hand them the session IDs it records.
|
|
24
|
-
|
|
25
|
-
export const DEBUG_DIR_PREFIX = "tps-debug-";
|
|
26
|
-
const debugState = {
|
|
27
|
-
enabled: false,
|
|
28
|
-
file: ""
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/** True only for a real directory that belongs to us and to no one else. */
|
|
32
|
-
function isOwnPrivateDir(path) {
|
|
33
|
-
try {
|
|
34
|
-
const stats = lstatSync(path); // lstat, not stat: a planted symlink must not pass
|
|
35
|
-
if (!stats.isDirectory()) return false;
|
|
36
|
-
const uid = process.getuid?.();
|
|
37
|
-
// Windows has no uid and a per-user temp directory, so there is nothing to check.
|
|
38
|
-
if (uid === undefined) return true;
|
|
39
|
-
return stats.uid === uid && (stats.mode & 0o777) === 0o700;
|
|
40
|
-
} catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The 0700 directory to log into. Named after the PID so the process's own hot
|
|
47
|
-
* reloads keep appending to one file, and only reused when it really is ours —
|
|
48
|
-
* anything else squatting on the name gets sidestepped via mkdtemp.
|
|
49
|
-
*/
|
|
50
|
-
function debugDir() {
|
|
51
|
-
const preferred = join(tmpdir(), `${DEBUG_DIR_PREFIX}${process.pid}`);
|
|
52
|
-
try {
|
|
53
|
-
mkdirSync(preferred, {
|
|
54
|
-
mode: 0o700
|
|
55
|
-
});
|
|
56
|
-
return preferred;
|
|
57
|
-
} catch {
|
|
58
|
-
if (isOwnPrivateDir(preferred)) return preferred;
|
|
59
|
-
return mkdtempSync(`${preferred}-`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
function configureDebug(enabled) {
|
|
63
|
-
debugState.enabled = enabled;
|
|
64
|
-
if (!enabled || debugState.file) return;
|
|
65
|
-
try {
|
|
66
|
-
debugState.file = join(debugDir(), "tps.log");
|
|
67
|
-
} catch {
|
|
68
|
-
debugState.enabled = false; // no usable temp directory: stay silent
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Truthy spellings only: `TPS_DEBUG=0` must not start writing to disk. */
|
|
73
|
-
export function isEnvEnabled(value) {
|
|
74
|
-
if (value === undefined) return false;
|
|
75
|
-
const normalized = value.trim().toLowerCase();
|
|
76
|
-
return normalized === "1" || normalized === "true";
|
|
77
|
-
}
|
|
78
|
-
function mark(line) {
|
|
79
|
-
if (!debugState.enabled) return;
|
|
80
|
-
try {
|
|
81
|
-
const safeLine = line.replace(/\p{Cc}/gu, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
82
|
-
appendFileSync(debugState.file, `${new Date().toISOString()} ${safeLine}\n`);
|
|
83
|
-
} catch {
|
|
84
|
-
// debug only; never break the host
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// tuning
|
|
90
|
-
|
|
91
|
-
export const DEFAULT_CONFIG = {
|
|
92
|
-
bytesPerToken: 4.75
|
|
93
|
-
};
|
|
94
|
-
// The frozen final average stays visible until the next prompt starts a new run.
|
|
95
|
-
|
|
96
|
-
const BYTES_PER_TOKEN_MIN = 1;
|
|
97
|
-
const BYTES_PER_TOKEN_MAX = 16;
|
|
98
|
-
const LIVE_WINDOW_MS = 5_000;
|
|
99
|
-
const LIVE_STALE_MS = 1_500;
|
|
100
|
-
const LIVE_MIN_DURATION_MS = 250;
|
|
101
|
-
function estimateTokens(bytes, bytesPerToken) {
|
|
102
|
-
return Math.ceil(bytes / bytesPerToken);
|
|
103
|
-
}
|
|
104
|
-
function formatTps(value) {
|
|
105
|
-
if (value < 10) return value.toFixed(2);
|
|
106
|
-
if (value < 100) return value.toFixed(1);
|
|
107
|
-
return Math.round(value).toString();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
// tracker (UI-free)
|
|
112
|
-
|
|
113
|
-
// A finished run keeps its frozen average indefinitely (it is what the composer
|
|
114
|
-
// still shows), so the map is bounded instead: past this many tracked sessions,
|
|
115
|
-
// the least recently started *finished* runs are dropped. Running ones are never
|
|
116
|
-
// touched. Entries are tiny, so this is hygiene for a long-lived TUI, not a
|
|
117
|
-
// memory fix.
|
|
118
|
-
const MAX_TRACKED_RUNS = 64;
|
|
119
|
-
export class TpsTracker {
|
|
120
|
-
// Insertion order is kept equal to run-start recency (see beginRun), which is
|
|
121
|
-
// what makes eviction from the front drop the stalest session.
|
|
122
|
-
runs = new Map();
|
|
123
|
-
constructor(config = DEFAULT_CONFIG) {
|
|
124
|
-
this.config = config;
|
|
125
|
-
}
|
|
126
|
-
state(sessionID) {
|
|
127
|
-
let st = this.runs.get(sessionID);
|
|
128
|
-
if (!st) {
|
|
129
|
-
st = {
|
|
130
|
-
phase: "ended",
|
|
131
|
-
settledTokens: 0,
|
|
132
|
-
settledDurationMs: 0,
|
|
133
|
-
tokensEstimated: false,
|
|
134
|
-
partial: false,
|
|
135
|
-
activeStep: null,
|
|
136
|
-
settledSteps: new Set(),
|
|
137
|
-
frozen: null
|
|
138
|
-
};
|
|
139
|
-
this.runs.set(sessionID, st);
|
|
140
|
-
}
|
|
141
|
-
return st;
|
|
142
|
-
}
|
|
143
|
-
beginRun(sessionID) {
|
|
144
|
-
const st = this.state(sessionID);
|
|
145
|
-
st.phase = "running";
|
|
146
|
-
st.settledTokens = 0;
|
|
147
|
-
st.settledDurationMs = 0;
|
|
148
|
-
st.tokensEstimated = false;
|
|
149
|
-
st.partial = false;
|
|
150
|
-
st.activeStep = null;
|
|
151
|
-
st.settledSteps.clear();
|
|
152
|
-
st.frozen = null;
|
|
153
|
-
// Re-insert so this session becomes the newest in iteration order. Every
|
|
154
|
-
// entry is created through here, so the cap is checked on the one path that
|
|
155
|
-
// can grow the map.
|
|
156
|
-
this.runs.delete(sessionID);
|
|
157
|
-
this.runs.set(sessionID, st);
|
|
158
|
-
this.evictStale();
|
|
159
|
-
}
|
|
160
|
-
evictStale() {
|
|
161
|
-
if (this.runs.size <= MAX_TRACKED_RUNS) return;
|
|
162
|
-
for (const [sessionID, st] of this.runs) {
|
|
163
|
-
if (this.runs.size <= MAX_TRACKED_RUNS) return;
|
|
164
|
-
if (st.phase === "running") continue;
|
|
165
|
-
this.dropSession(sessionID);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
ensureStep(sessionID, assistantMessageID, now, replace = false) {
|
|
169
|
-
const st = this.state(sessionID);
|
|
170
|
-
if (st.settledSteps.has(assistantMessageID) || st.phase === "ended" && st.frozen !== null) return null;
|
|
171
|
-
if (st.phase !== "running") this.beginRun(sessionID);
|
|
172
|
-
const running = this.state(sessionID);
|
|
173
|
-
if (running.activeStep?.assistantMessageID === assistantMessageID) return running.activeStep;
|
|
174
|
-
if (running.activeStep && !replace) return null;
|
|
175
|
-
if (running.activeStep) this.settleActiveStep(running, undefined);
|
|
176
|
-
const step = {
|
|
177
|
-
assistantMessageID,
|
|
178
|
-
startedAt: now,
|
|
179
|
-
streamedAt: null,
|
|
180
|
-
lastBoundaryAt: null,
|
|
181
|
-
observableBytes: 0,
|
|
182
|
-
blocks: new Map(),
|
|
183
|
-
samples: []
|
|
184
|
-
};
|
|
185
|
-
running.activeStep = step;
|
|
186
|
-
running.frozen = null;
|
|
187
|
-
return step;
|
|
188
|
-
}
|
|
189
|
-
beginStep(sessionID, assistantMessageID, now = Date.now()) {
|
|
190
|
-
const st = this.state(sessionID);
|
|
191
|
-
if (st.phase !== "running") {
|
|
192
|
-
if (st.settledSteps.has(assistantMessageID)) return;
|
|
193
|
-
this.beginRun(sessionID);
|
|
194
|
-
}
|
|
195
|
-
if (st.activeStep?.assistantMessageID === assistantMessageID) return;
|
|
196
|
-
this.ensureStep(sessionID, assistantMessageID, now, true);
|
|
197
|
-
}
|
|
198
|
-
beginBlock(sessionID, assistantMessageID, blockID, now) {
|
|
199
|
-
const step = this.ensureStep(sessionID, assistantMessageID, now);
|
|
200
|
-
if (!step) return;
|
|
201
|
-
if (!step.blocks.has(blockID)) step.blocks.set(blockID, {
|
|
202
|
-
streamedBytes: 0,
|
|
203
|
-
finalBytes: null
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
push(sessionID, delta, now, assistantMessageID = "implicit", blockID = "implicit") {
|
|
207
|
-
if (!delta) return;
|
|
208
|
-
const step = this.ensureStep(sessionID, assistantMessageID, now);
|
|
209
|
-
if (!step) return;
|
|
210
|
-
let block = step.blocks.get(blockID);
|
|
211
|
-
if (!block) {
|
|
212
|
-
block = {
|
|
213
|
-
streamedBytes: 0,
|
|
214
|
-
finalBytes: null
|
|
215
|
-
};
|
|
216
|
-
step.blocks.set(blockID, block);
|
|
217
|
-
}
|
|
218
|
-
if (block.finalBytes !== null) return;
|
|
219
|
-
const bytes = Buffer.byteLength(delta, "utf8");
|
|
220
|
-
block.streamedBytes += bytes;
|
|
221
|
-
step.observableBytes += bytes;
|
|
222
|
-
step.samples.push({
|
|
223
|
-
bytes,
|
|
224
|
-
timestamp: now
|
|
225
|
-
});
|
|
226
|
-
const oldest = now - LIVE_WINDOW_MS;
|
|
227
|
-
while (step.samples[0] && step.samples[0].timestamp < oldest) step.samples.shift();
|
|
228
|
-
}
|
|
229
|
-
finishBlock(sessionID, assistantMessageID, blockID, text, now) {
|
|
230
|
-
const st = this.runs.get(sessionID);
|
|
231
|
-
const step = st?.activeStep;
|
|
232
|
-
if (!step || step.assistantMessageID !== assistantMessageID) return;
|
|
233
|
-
let block = step.blocks.get(blockID);
|
|
234
|
-
if (!block) {
|
|
235
|
-
block = {
|
|
236
|
-
streamedBytes: 0,
|
|
237
|
-
finalBytes: null
|
|
238
|
-
};
|
|
239
|
-
step.blocks.set(blockID, block);
|
|
240
|
-
}
|
|
241
|
-
if (block.finalBytes !== null) return;
|
|
242
|
-
block.finalBytes = Buffer.byteLength(text, "utf8");
|
|
243
|
-
step.observableBytes += block.finalBytes - block.streamedBytes;
|
|
244
|
-
step.lastBoundaryAt = Math.max(step.lastBoundaryAt ?? now, now);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* The host's authoritative end of the model stream, published after the
|
|
249
|
-
* provider stream exits and before local tools join. Assigned rather than
|
|
250
|
-
* maxed so a retried attempt reusing the message ID moves the boundary to its
|
|
251
|
-
* own completion.
|
|
252
|
-
*/
|
|
253
|
-
markStreamed(sessionID, assistantMessageID, now) {
|
|
254
|
-
const st = this.runs.get(sessionID);
|
|
255
|
-
const step = st?.activeStep;
|
|
256
|
-
if (!step || step.assistantMessageID !== assistantMessageID) return;
|
|
257
|
-
step.streamedAt = now;
|
|
258
|
-
}
|
|
259
|
-
settleActiveStep(st, generatedTokens) {
|
|
260
|
-
const step = st.activeStep;
|
|
261
|
-
if (!step) return;
|
|
262
|
-
const exact = generatedTokens !== undefined && Number.isFinite(generatedTokens) && generatedTokens >= 0;
|
|
263
|
-
st.settledTokens += exact ? generatedTokens : estimateTokens(step.observableBytes, this.config.bytesPerToken);
|
|
264
|
-
if (!exact) {
|
|
265
|
-
st.tokensEstimated = true;
|
|
266
|
-
st.partial = true;
|
|
267
|
-
}
|
|
268
|
-
// `session.step.streamed` is the exact stream end; the last content boundary
|
|
269
|
-
// remains the fallback for hosts that do not publish it.
|
|
270
|
-
const end = step.streamedAt ?? step.lastBoundaryAt;
|
|
271
|
-
if (end !== null) st.settledDurationMs += Math.max(0, end - step.startedAt);
|
|
272
|
-
st.settledSteps.add(step.assistantMessageID);
|
|
273
|
-
st.activeStep = null;
|
|
274
|
-
}
|
|
275
|
-
finishStep(sessionID, assistantMessageID, generatedTokens, _now) {
|
|
276
|
-
const st = this.runs.get(sessionID);
|
|
277
|
-
if (st?.activeStep?.assistantMessageID !== assistantMessageID) return;
|
|
278
|
-
this.settleActiveStep(st, generatedTokens);
|
|
279
|
-
}
|
|
280
|
-
finish(sessionID, _now) {
|
|
281
|
-
const st = this.runs.get(sessionID);
|
|
282
|
-
if (!st || st.phase === "ended") return;
|
|
283
|
-
if (st.activeStep) this.settleActiveStep(st, undefined);
|
|
284
|
-
st.phase = "ended";
|
|
285
|
-
const tokens = st.settledTokens;
|
|
286
|
-
if (tokens <= 0) {
|
|
287
|
-
this.evictStale();
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
const tps = st.settledDurationMs > 0 ? tokens / (st.settledDurationMs / 1000) : null;
|
|
291
|
-
st.frozen = {
|
|
292
|
-
tps,
|
|
293
|
-
tokens,
|
|
294
|
-
tokensEstimated: st.tokensEstimated,
|
|
295
|
-
partial: st.partial
|
|
296
|
-
};
|
|
297
|
-
mark(`finish sid=${sessionID} tokens=${tokens} observedMs=${st.settledDurationMs} tps=${tps?.toFixed(1) ?? "n/a"}`);
|
|
298
|
-
this.evictStale();
|
|
299
|
-
}
|
|
300
|
-
dropSession(sessionID) {
|
|
301
|
-
this.runs.delete(sessionID);
|
|
302
|
-
}
|
|
303
|
-
evict(sessionID) {
|
|
304
|
-
this.dropSession(sessionID);
|
|
305
|
-
}
|
|
306
|
-
hasRunning(now = Date.now()) {
|
|
307
|
-
for (const st of this.runs.values()) {
|
|
308
|
-
const last = st.activeStep?.samples.at(-1);
|
|
309
|
-
if (st.phase === "running" && last && now < last.timestamp + LIVE_STALE_MS) return true;
|
|
310
|
-
}
|
|
311
|
-
return false;
|
|
312
|
-
}
|
|
313
|
-
liveTps(step, now) {
|
|
314
|
-
const last = step.samples.at(-1);
|
|
315
|
-
if (!last) return null;
|
|
316
|
-
const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS);
|
|
317
|
-
const oldest = effectiveNow - LIVE_WINDOW_MS;
|
|
318
|
-
const samples = step.samples.filter(sample => sample.timestamp >= oldest);
|
|
319
|
-
const first = samples[0];
|
|
320
|
-
if (!first) return null;
|
|
321
|
-
const bytes = samples.reduce((total, sample) => total + sample.bytes, 0);
|
|
322
|
-
const durationMs = Math.max(effectiveNow - first.timestamp, LIVE_MIN_DURATION_MS);
|
|
323
|
-
return estimateTokens(bytes, this.config.bytesPerToken) / (durationMs / 1000);
|
|
324
|
-
}
|
|
325
|
-
value(sessionID, now) {
|
|
326
|
-
const st = this.runs.get(sessionID);
|
|
327
|
-
if (!st) return null;
|
|
328
|
-
if (st.frozen) return {
|
|
329
|
-
...st.frozen,
|
|
330
|
-
frozen: true,
|
|
331
|
-
tpsEstimated: true
|
|
332
|
-
};
|
|
333
|
-
if (st.phase !== "running") return null;
|
|
334
|
-
const active = st.activeStep;
|
|
335
|
-
const activeTokens = active ? estimateTokens(active.observableBytes, this.config.bytesPerToken) : 0;
|
|
336
|
-
const tokens = st.settledTokens + activeTokens;
|
|
337
|
-
if (tokens <= 0) return null;
|
|
338
|
-
const settledTps = st.settledDurationMs > 0 ? st.settledTokens / (st.settledDurationMs / 1000) : null;
|
|
339
|
-
return {
|
|
340
|
-
tps: active ? this.liveTps(active, now) ?? settledTps : settledTps,
|
|
341
|
-
tokens,
|
|
342
|
-
frozen: false,
|
|
343
|
-
tokensEstimated: st.tokensEstimated || active !== null,
|
|
344
|
-
tpsEstimated: true,
|
|
345
|
-
partial: st.partial
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// ---------------------------------------------------------------------------
|
|
351
|
-
// options
|
|
352
|
-
//
|
|
353
|
-
// `ctx.options` is host-supplied JSON (Record<string, any>), so this is a real
|
|
354
|
-
// parsing boundary: every value is validated and clamped, and anything invalid
|
|
355
|
-
// falls back to the default rather than propagating NaN into the arithmetic.
|
|
356
|
-
|
|
357
|
-
const DISPLAY_MODES = ["both", "tokens", "tps"];
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* A value as it can arrive from `cli.json`: arbitrary JSON, nothing more.
|
|
361
|
-
* Named so the option boundary has a real input contract to validate against.
|
|
362
|
-
*/
|
|
363
|
-
|
|
364
|
-
/** The option surface, exactly as documented in the README, before validation. */
|
|
365
|
-
|
|
366
|
-
export const DEFAULT_OPTIONS = {
|
|
367
|
-
...DEFAULT_CONFIG,
|
|
368
|
-
display: "both",
|
|
369
|
-
refreshHz: 8,
|
|
370
|
-
debug: false
|
|
371
|
-
};
|
|
372
|
-
function isFiniteNumber(value) {
|
|
373
|
-
return Number.isFinite(value);
|
|
374
|
-
}
|
|
375
|
-
function isDisplayMode(value) {
|
|
376
|
-
return DISPLAY_MODES.some(mode => mode === value);
|
|
377
|
-
}
|
|
378
|
-
function clampNumber(value, fallback, min, max) {
|
|
379
|
-
if (!isFiniteNumber(value)) return fallback;
|
|
380
|
-
return Math.min(Math.max(value, min), max);
|
|
381
|
-
}
|
|
382
|
-
export function resolveOptions(raw) {
|
|
383
|
-
return {
|
|
384
|
-
display: isDisplayMode(raw.display) ? raw.display : DEFAULT_OPTIONS.display,
|
|
385
|
-
refreshHz: clampNumber(raw.refreshHz, DEFAULT_OPTIONS.refreshHz, 1, 60),
|
|
386
|
-
bytesPerToken: clampNumber(raw.bytesPerToken, DEFAULT_OPTIONS.bytesPerToken, BYTES_PER_TOKEN_MIN, BYTES_PER_TOKEN_MAX),
|
|
387
|
-
debug: raw.debug === true
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
export function formatLabel(value, display) {
|
|
391
|
-
const tokens = `${value.tokensEstimated ? "~" : ""}${value.tokens} tok`;
|
|
392
|
-
const tps = value.tps === null ? null : `~${formatTps(value.tps)} t/s`;
|
|
393
|
-
if (display === "tokens") return tokens;
|
|
394
|
-
if (display === "tps") return tps ?? "— t/s";
|
|
395
|
-
return tps === null ? tokens : `${tokens} · ${tps}`;
|
|
396
|
-
}
|
|
10
|
+
import { configureDebug, isEnvEnabled, mark } from "./debug.js";
|
|
11
|
+
import { TpsTracker } from "./tracker.js";
|
|
12
|
+
import { formatLabel, resolveOptions } from "./options.js";
|
|
397
13
|
|
|
398
14
|
// ---------------------------------------------------------------------------
|
|
399
15
|
// plugin
|
|
@@ -452,6 +68,7 @@ const definition = {
|
|
|
452
68
|
return;
|
|
453
69
|
}
|
|
454
70
|
const running = tracker.hasRunning(Date.now());
|
|
71
|
+
|
|
455
72
|
// The observable live rate decays only through a short stale tail. Opaque
|
|
456
73
|
// provider work after that is not charged to a numerator we cannot see.
|
|
457
74
|
if (dirty || running) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/P-Theo/opencode2-tps/main/options.schema.json",
|
|
4
|
+
"title": "opencode2-tps plugin options",
|
|
5
|
+
"description": "Options for the opencode2-tps OpenCode 2 TUI plugin, as they appear in the plugin entry's `options` object in `~/.config/opencode/cli.json` (or `$XDG_CONFIG_HOME/opencode/cli.json`). The runtime parser is tolerant — an unusable value falls back to the documented default and an out-of-range number is clamped — but agents should write canonical values and validate against this schema. Unknown keys are rejected here on purpose: the runtime ignores them silently, so a typo in an option is otherwise invisible.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"display": {
|
|
10
|
+
"enum": ["both", "tokens", "tps"],
|
|
11
|
+
"default": "both",
|
|
12
|
+
"description": "Which parts of the label to show."
|
|
13
|
+
},
|
|
14
|
+
"refreshHz": {
|
|
15
|
+
"type": "number",
|
|
16
|
+
"minimum": 1,
|
|
17
|
+
"maximum": 60,
|
|
18
|
+
"default": 8,
|
|
19
|
+
"description": "How often the label updates while a session streams, clamped to 1-60."
|
|
20
|
+
},
|
|
21
|
+
"bytesPerToken": {
|
|
22
|
+
"type": "number",
|
|
23
|
+
"minimum": 1,
|
|
24
|
+
"maximum": 16,
|
|
25
|
+
"default": 4.75,
|
|
26
|
+
"description": "Bytes per token used for live and partial-output estimates, clamped to 1-16. Completed steps use OpenCode's reported token usage instead."
|
|
27
|
+
},
|
|
28
|
+
"debug": {
|
|
29
|
+
"type": "boolean",
|
|
30
|
+
"default": false,
|
|
31
|
+
"description": "Writes a debug log. Only `true` enables it."
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"examples": [
|
|
35
|
+
{
|
|
36
|
+
"display": "tps",
|
|
37
|
+
"refreshHz": 12
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"display": "both",
|
|
41
|
+
"refreshHz": 8,
|
|
42
|
+
"bytesPerToken": 4.75,
|
|
43
|
+
"debug": false
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode2-tps",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-rc.1",
|
|
4
4
|
"description": "Live token-throughput indicator for the OpenCode 2 TUI prompt composer",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "P-Theo",
|
|
@@ -21,10 +21,12 @@
|
|
|
21
21
|
"throughput"
|
|
22
22
|
],
|
|
23
23
|
"exports": {
|
|
24
|
-
"./tui": "./dist/tui.js"
|
|
24
|
+
"./tui": "./dist/tui.js",
|
|
25
|
+
"./options.schema.json": "./options.schema.json"
|
|
25
26
|
},
|
|
26
27
|
"files": [
|
|
27
|
-
"dist"
|
|
28
|
+
"dist",
|
|
29
|
+
"options.schema.json"
|
|
28
30
|
],
|
|
29
31
|
"peerDependencies": {
|
|
30
32
|
"@opentui/solid": ">=0.5.4",
|
|
@@ -53,16 +55,16 @@
|
|
|
53
55
|
"@opentui/solid": "^0.5.10",
|
|
54
56
|
"@oxlint/plugins": "^1.78.0",
|
|
55
57
|
"@types/bun": "^1.3.14",
|
|
56
|
-
"@types/node": "^
|
|
58
|
+
"@types/node": "^26.0.0",
|
|
57
59
|
"babel-preset-solid": "^1.9.12",
|
|
58
60
|
"oxlint": "^1.78.0",
|
|
59
61
|
"solid-js": "^1.9.0",
|
|
60
62
|
"typescript": "^5.9.0"
|
|
61
63
|
},
|
|
62
64
|
"scripts": {
|
|
63
|
-
"build": "node build.mjs",
|
|
65
|
+
"build": "node scripts/build.mjs",
|
|
64
66
|
"check": "tsc --noEmit",
|
|
65
|
-
"check:compatibility": "node check-compatibility.mjs",
|
|
67
|
+
"check:compatibility": "node scripts/check-compatibility.mjs",
|
|
66
68
|
"lint": "oxlint",
|
|
67
69
|
"test": "bun test",
|
|
68
70
|
"prepack": "npm run lint && npm run check && npm test && npm run build"
|