@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 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
+ ![Demo](./assets/demo.gif)
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).
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
+ })