@tinkink/oc-tps 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/assets/demo.gif +0 -0
- package/package.json +42 -0
- package/tui.tsx +233 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @tinkink/oc-tps
|
|
2
|
+
|
|
3
|
+
> **Temporary fork.** This is a private, temporary fork of
|
|
4
|
+
> [Tarquinen/oc-tps](https://github.com/Tarquinen/oc-tps), ported to the
|
|
5
|
+
> **OpenCode 2** TUI plugin API. It is not affiliated with, or endorsed by, the
|
|
6
|
+
> upstream author. Once OpenCode 2 support lands upstream, use the upstream
|
|
7
|
+
> package instead.
|
|
8
|
+
|
|
9
|
+
Displays live TPS (tokens per second), average TPS, and average TTFT (time to first token) in the OpenCode session prompt.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Requires OpenCode 2.
|
|
16
|
+
|
|
17
|
+
Add it to the `plugins` array in the global terminal client configuration:
|
|
18
|
+
|
|
19
|
+
```json title="~/.config/opencode/cli.json"
|
|
20
|
+
{
|
|
21
|
+
"plugins": ["@tinkink/oc-tps@latest"]
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Development
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
npm run typecheck
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The V2 port follows the upstream `opencode-v2` branch; see
|
|
33
|
+
[`tui.tsx`](./tui.tsx).
|
package/assets/demo.gif
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@tinkink/oc-tps",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Temporary OpenCode 2 fork of Tarquinen/oc-tps: live TPS, average TPS, and average TTFT in the session prompt.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/tinkink-net/oc-tps-v2"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
"./tui": "./tui.tsx"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"opencode": ">=2.0.0"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"registry": "https://registry.npmjs.org"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@opencode/plugin": "2.0.10"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@opentui/core": ">=0.5.10",
|
|
29
|
+
"@opentui/solid": ">=0.5.10",
|
|
30
|
+
"solid-js": ">=1.9.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@opencode/theme": "2.0.10",
|
|
34
|
+
"@types/node": "^24.0.0",
|
|
35
|
+
"typescript": "^5.9.0"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"assets/",
|
|
39
|
+
"README.md",
|
|
40
|
+
"tui.tsx"
|
|
41
|
+
]
|
|
42
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
3
|
+
import { createMemo, createSignal, type Accessor } from "solid-js"
|
|
4
|
+
|
|
5
|
+
type StreamSample = {
|
|
6
|
+
at: number
|
|
7
|
+
tokens: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type StepTiming = {
|
|
11
|
+
sessionID: string
|
|
12
|
+
requestStartAt: number
|
|
13
|
+
firstResponseAt?: number
|
|
14
|
+
lastToolCallAt?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type SessionAverage = {
|
|
18
|
+
totalTokens: number
|
|
19
|
+
totalDurationMs: number
|
|
20
|
+
totalTtftMs: number
|
|
21
|
+
stepCount: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Tracker = {
|
|
25
|
+
samples: Record<string, StreamSample[]>
|
|
26
|
+
requestStarts: Record<string, number>
|
|
27
|
+
timings: Record<string, StepTiming>
|
|
28
|
+
averages: Record<string, SessionAverage>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STREAM_WINDOW_MS = 5_000
|
|
32
|
+
const LIVE_STALE_MS = 1_500
|
|
33
|
+
const SINGLE_SAMPLE_MS = 1_000
|
|
34
|
+
|
|
35
|
+
function estimateTokens(delta: string) {
|
|
36
|
+
return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatRate(value: number) {
|
|
40
|
+
if (!Number.isFinite(value) || value <= 0) return undefined
|
|
41
|
+
if (value >= 100) return Math.round(value).toString()
|
|
42
|
+
if (value >= 10) return value.toFixed(1)
|
|
43
|
+
return value.toFixed(2)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatTtft(value: number) {
|
|
47
|
+
if (!Number.isFinite(value) || value < 0) return undefined
|
|
48
|
+
return `${value.toFixed(1)}s`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function activeDuration(samples: StreamSample[], tailAt?: number) {
|
|
52
|
+
if (samples.length === 0) return 0
|
|
53
|
+
if (samples.length === 1) {
|
|
54
|
+
const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
|
|
55
|
+
return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let duration = 0
|
|
59
|
+
for (let i = 1; i < samples.length; i++) {
|
|
60
|
+
duration += Math.max(0, samples[i].at - samples[i - 1].at)
|
|
61
|
+
}
|
|
62
|
+
if (tailAt) duration += Math.max(0, tailAt - samples[samples.length - 1].at)
|
|
63
|
+
return Math.max(duration, SINGLE_SAMPLE_MS)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function Status(props: {
|
|
67
|
+
context: Plugin.Context
|
|
68
|
+
sessionID: string
|
|
69
|
+
tracker: Tracker
|
|
70
|
+
revision: Accessor<number>
|
|
71
|
+
}) {
|
|
72
|
+
const content = createMemo(() => {
|
|
73
|
+
props.revision()
|
|
74
|
+
const totals = props.tracker.averages[props.sessionID]
|
|
75
|
+
const average = totals
|
|
76
|
+
? formatRate(totals.totalTokens / (totals.totalDurationMs / 1_000))
|
|
77
|
+
: undefined
|
|
78
|
+
const ttft = totals?.stepCount
|
|
79
|
+
? formatTtft(totals.totalTtftMs / totals.stepCount / 1_000)
|
|
80
|
+
: undefined
|
|
81
|
+
|
|
82
|
+
let live: string | undefined
|
|
83
|
+
if (props.context.data.session.status(props.sessionID) === "running") {
|
|
84
|
+
const now = Date.now()
|
|
85
|
+
const samples = (props.tracker.samples[props.sessionID] ?? []).filter(
|
|
86
|
+
(sample) => now - sample.at <= STREAM_WINDOW_MS,
|
|
87
|
+
)
|
|
88
|
+
const last = samples[samples.length - 1]
|
|
89
|
+
if (last && now - last.at <= LIVE_STALE_MS) {
|
|
90
|
+
const tokens = samples.reduce((sum, sample) => sum + sample.tokens, 0)
|
|
91
|
+
live = formatRate(tokens / (activeDuration(samples, now) / 1_000))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return `TPS ${live ?? "-"} | AVG ${average ?? "-"} | TTFT ${ttft ?? "-"}`
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<box position="absolute" right={2} bottom={2} height={1} flexDirection="row">
|
|
100
|
+
<text fg={props.context.theme.text.muted} flexShrink={0}>
|
|
101
|
+
{content()}
|
|
102
|
+
</text>
|
|
103
|
+
</box>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default Plugin.define({
|
|
108
|
+
id: "oc-tps",
|
|
109
|
+
setup(context) {
|
|
110
|
+
const tracker: Tracker = {
|
|
111
|
+
samples: {},
|
|
112
|
+
requestStarts: {},
|
|
113
|
+
timings: {},
|
|
114
|
+
averages: {},
|
|
115
|
+
}
|
|
116
|
+
const [revision, setRevision] = createSignal(0)
|
|
117
|
+
const bump = () => setRevision((value) => value + 1)
|
|
118
|
+
|
|
119
|
+
const clearLive = (sessionID: string) => {
|
|
120
|
+
if (!tracker.samples[sessionID]) return
|
|
121
|
+
delete tracker.samples[sessionID]
|
|
122
|
+
bump()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const appendSample = (sessionID: string, messageID: string, delta: string, at: number) => {
|
|
126
|
+
tracker.samples[sessionID] = [
|
|
127
|
+
...(tracker.samples[sessionID] ?? []).filter((sample) => at - sample.at <= STREAM_WINDOW_MS),
|
|
128
|
+
{ at, tokens: estimateTokens(delta) },
|
|
129
|
+
]
|
|
130
|
+
const timing = tracker.timings[messageID]
|
|
131
|
+
if (timing && timing.firstResponseAt === undefined) timing.firstResponseAt = at
|
|
132
|
+
bump()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const subscriptions = [
|
|
136
|
+
context.data.on("session.execution.started", (event) => {
|
|
137
|
+
tracker.requestStarts[event.data.sessionID] = event.created
|
|
138
|
+
}),
|
|
139
|
+
context.data.on("session.step.started", (event) => {
|
|
140
|
+
tracker.timings[event.data.assistantMessageID] = {
|
|
141
|
+
sessionID: event.data.sessionID,
|
|
142
|
+
requestStartAt: tracker.requestStarts[event.data.sessionID] ?? event.created,
|
|
143
|
+
}
|
|
144
|
+
delete tracker.requestStarts[event.data.sessionID]
|
|
145
|
+
bump()
|
|
146
|
+
}),
|
|
147
|
+
context.data.on("session.text.delta", (event) => {
|
|
148
|
+
appendSample(event.data.sessionID, event.data.assistantMessageID, event.data.delta, event.created)
|
|
149
|
+
}),
|
|
150
|
+
context.data.on("session.reasoning.delta", (event) => {
|
|
151
|
+
appendSample(event.data.sessionID, event.data.assistantMessageID, event.data.delta, event.created)
|
|
152
|
+
}),
|
|
153
|
+
context.data.on("session.tool.input.started", (event) => {
|
|
154
|
+
clearLive(event.data.sessionID)
|
|
155
|
+
const timing = tracker.timings[event.data.assistantMessageID]
|
|
156
|
+
if (!timing) return
|
|
157
|
+
timing.firstResponseAt ??= event.created
|
|
158
|
+
bump()
|
|
159
|
+
}),
|
|
160
|
+
context.data.on("session.tool.called", (event) => {
|
|
161
|
+
const timing = tracker.timings[event.data.assistantMessageID]
|
|
162
|
+
if (!timing) return
|
|
163
|
+
timing.lastToolCallAt = event.created
|
|
164
|
+
bump()
|
|
165
|
+
}),
|
|
166
|
+
context.data.on("session.step.ended", (event) => {
|
|
167
|
+
const timing = tracker.timings[event.data.assistantMessageID]
|
|
168
|
+
if (timing?.firstResponseAt !== undefined) {
|
|
169
|
+
const tokens = event.data.tokens.output + event.data.tokens.reasoning
|
|
170
|
+
const endAt = event.data.finish === "tool-calls" ? timing.lastToolCallAt ?? event.created : event.created
|
|
171
|
+
const duration = Math.max(endAt - timing.firstResponseAt, 1)
|
|
172
|
+
if (tokens > 0) {
|
|
173
|
+
const totals = tracker.averages[event.data.sessionID] ?? {
|
|
174
|
+
totalTokens: 0,
|
|
175
|
+
totalDurationMs: 0,
|
|
176
|
+
totalTtftMs: 0,
|
|
177
|
+
stepCount: 0,
|
|
178
|
+
}
|
|
179
|
+
tracker.averages[event.data.sessionID] = {
|
|
180
|
+
totalTokens: totals.totalTokens + tokens,
|
|
181
|
+
totalDurationMs: totals.totalDurationMs + duration,
|
|
182
|
+
totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
|
|
183
|
+
stepCount: totals.stepCount + 1,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
delete tracker.timings[event.data.assistantMessageID]
|
|
188
|
+
bump()
|
|
189
|
+
}),
|
|
190
|
+
context.data.on("session.step.failed", (event) => {
|
|
191
|
+
delete tracker.timings[event.data.assistantMessageID]
|
|
192
|
+
clearLive(event.data.sessionID)
|
|
193
|
+
bump()
|
|
194
|
+
}),
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
for (const type of [
|
|
198
|
+
"session.execution.succeeded",
|
|
199
|
+
"session.execution.failed",
|
|
200
|
+
"session.execution.interrupted",
|
|
201
|
+
] as const) {
|
|
202
|
+
subscriptions.push(
|
|
203
|
+
context.data.on(type, (event) => {
|
|
204
|
+
delete tracker.requestStarts[event.data.sessionID]
|
|
205
|
+
clearLive(event.data.sessionID)
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const timer = setInterval(() => {
|
|
211
|
+
const now = Date.now()
|
|
212
|
+
for (const [sessionID, samples] of Object.entries(tracker.samples)) {
|
|
213
|
+
const current = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
|
|
214
|
+
if (current.length) tracker.samples[sessionID] = current
|
|
215
|
+
else delete tracker.samples[sessionID]
|
|
216
|
+
}
|
|
217
|
+
bump()
|
|
218
|
+
}, 1_000)
|
|
219
|
+
|
|
220
|
+
context.ui.slot({
|
|
221
|
+
append: "prompt.footer",
|
|
222
|
+
render: (props) => {
|
|
223
|
+
if (!props.sessionID || props.mode !== "normal") return null
|
|
224
|
+
return <Status context={context} sessionID={props.sessionID} tracker={tracker} revision={revision} />
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
return () => {
|
|
229
|
+
subscriptions.forEach((unsubscribe) => unsubscribe())
|
|
230
|
+
clearInterval(timer)
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
})
|