@serkanalgur/opencodev2-slim 2.0.2 → 2.0.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 CHANGED
@@ -141,6 +141,13 @@ Note: Compression is performed by the AI assistant using the `compress` tool. Th
141
141
 
142
142
  ## Changelog
143
143
 
144
+ ### 2.0.3
145
+
146
+ - Fix v2 API compatibility issues
147
+ - Remove namespace from tool registration
148
+ - Handle both v1 and v2 message part types
149
+ - Make context hooks synchronous
150
+
144
151
  ### 2.0.2
145
152
 
146
153
  - Update README documentation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencodev2-slim",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Smart context management plugin for OpenCode v2 - semantic compression, cost-aware pruning, adaptive thresholds",
5
5
  "keywords": [
6
6
  "opencode",
@@ -23,8 +23,7 @@
23
23
  "author": "serkanalgur",
24
24
  "type": "module",
25
25
  "exports": {
26
- ".": "./src/index.ts",
27
- "./tui": "./src/tui.tsx"
26
+ ".": "./src/index.ts"
28
27
  },
29
28
  "main": "index.js",
30
29
  "directories": {
@@ -48,40 +47,17 @@
48
47
  "devDependencies": {
49
48
  "@opencode/plugin": "^2.0.0",
50
49
  "@opencode-ai/sdk": "^1.18.29",
51
- "@opencode-ai/plugin": "^1.18.29",
52
- "@opentui/core": "^0.5.10",
53
- "@opentui/keymap": "^0.5.10",
54
- "@opentui/solid": "^0.5.10",
55
50
  "@types/node": "^22.0.0",
56
51
  "prettier": "^3.4.0",
57
- "solid-js": "1.9.12",
58
52
  "tsx": "^4.19.0",
59
53
  "typescript": "^5.7.0"
60
54
  },
61
- "peerDependencies": {
62
- "@opencode/plugin": ">=2.0.0",
63
- "@opencode-ai/sdk": ">=1.18.29",
64
- "@opencode-ai/plugin": ">=1.18.29",
65
- "@opentui/core": ">=0.5.10",
66
- "@opentui/keymap": ">=0.5.10",
67
- "@opentui/solid": ">=0.5.10",
68
- "solid-js": ">=1.0.0"
69
- },
70
55
  "peerDependenciesMeta": {
71
- "@opencode-ai/plugin": {
72
- "optional": true
73
- },
74
- "@opentui/core": {
75
- "optional": true
76
- },
77
- "@opentui/keymap": {
78
- "optional": true
79
- },
80
- "@opentui/solid": {
81
- "optional": true
82
- },
83
- "solid-js": {
56
+ "@opencode-ai/sdk": {
84
57
  "optional": true
85
58
  }
59
+ },
60
+ "peerDependencies": {
61
+ "@opencode/plugin": ">=2.0.0"
86
62
  }
87
63
  }
package/src/index.ts CHANGED
@@ -43,17 +43,21 @@ function buildCompressionSummary(messages: MessageWithParts[], focus: string): s
43
43
 
44
44
  for (const msg of messages) {
45
45
  for (const part of msg.parts) {
46
- if (part.type === "tool") {
46
+ if (part.type === "tool-call") {
47
47
  const toolPart = part as any
48
48
  toolCalls.push(
49
- `${toolPart.tool}: ${JSON.stringify(toolPart.state?.input || {}).slice(0, 100)}`,
49
+ `${toolPart.name}: ${JSON.stringify(toolPart.input || {}).slice(0, 100)}`,
50
50
  )
51
- if (toolPart.state?.status === "error") {
52
- errors.push(toolPart.state.error?.slice(0, 200) || "Unknown error")
51
+ }
52
+ if (part.type === "tool-result") {
53
+ const toolPart = part as any
54
+ if (toolPart.result?.type === "error") {
55
+ errors.push(String(toolPart.result.value).slice(0, 200) || "Unknown error")
53
56
  }
54
57
  }
55
58
  if (part.type === "text") {
56
- const text = (part as any).text
59
+ const textPart = part as any
60
+ const text = textPart.text || ""
57
61
  if (
58
62
  text.includes("decided") ||
59
63
  text.includes("chose") ||
@@ -86,22 +90,23 @@ function buildCompressionSummary(messages: MessageWithParts[], focus: string): s
86
90
  return lines.join("\n")
87
91
  }
88
92
 
93
+ function wrapAsMessageWithParts(msg: any): MessageWithParts {
94
+ return {
95
+ info: msg.info || { id: msg.id || "", role: msg.role, sessionID: "", time: { created: Date.now() } },
96
+ parts: msg.parts || msg.content || [],
97
+ }
98
+ }
99
+
89
100
  // ─── Plugin Entry ───────────────────────────────────────────────────────────
90
101
 
91
102
  export default Plugin.define({
92
103
  id: "opencodev2-slim",
93
104
  async setup(ctx) {
94
- // Load and create default config if needed
95
105
  createDefaultConfig()
96
106
  const globalConfig = loadConfig()
97
107
 
98
108
  // ─── Register Compress Tool ───────────────────────────────────────
99
109
  await ctx.tool.transform((editor) => {
100
- editor.namespace({
101
- name: "slim",
102
- description: "Smart context management tools",
103
- })
104
-
105
110
  editor.add({
106
111
  name: "compress",
107
112
  description: getCompressToolDescription(),
@@ -150,9 +155,7 @@ export default Plugin.define({
150
155
  }
151
156
  const mode = args.mode || "auto"
152
157
  const keepRecent = args.keepRecent || 5
153
-
154
- // Get session ID from context or fallback
155
- const sessionId = (context as any).sessionID || ""
158
+ const sessionId = context.sessionID
156
159
  const config = getConfig(sessionId)
157
160
  const state = getState(sessionId, config)
158
161
 
@@ -163,12 +166,10 @@ export default Plugin.define({
163
166
  return { content: "No messages found in session" }
164
167
  }
165
168
 
166
- const messageWithParts: MessageWithParts[] = messages.map((m: any) => ({
167
- info: m.info || m,
168
- parts: m.parts || [],
169
- }))
169
+ const messageWithParts: MessageWithParts[] = messages.map(
170
+ (m: any) => wrapAsMessageWithParts(m),
171
+ )
170
172
 
171
- // Determine what to compress
172
173
  let targetIndices: number[] = []
173
174
  let inputTokens = 0
174
175
 
@@ -193,7 +194,6 @@ export default Plugin.define({
193
194
  }
194
195
  }
195
196
  } else {
196
- // Auto mode
197
197
  for (let i = 0; i < messageWithParts.length - keepRecent; i++) {
198
198
  const msg = messageWithParts[i]
199
199
  const text = getMessageText(msg) + getToolResultContent(msg)
@@ -210,7 +210,6 @@ export default Plugin.define({
210
210
 
211
211
  const targetMessages = targetIndices.map((i) => messageWithParts[i])
212
212
  const summary = buildCompressionSummary(targetMessages, args.focus)
213
-
214
213
  const outputTokens = await countTokens(summary)
215
214
  const ratio = inputTokens > 0 ? 1 - outputTokens / inputTokens : 0
216
215
 
@@ -256,7 +255,7 @@ export default Plugin.define({
256
255
  additionalProperties: false,
257
256
  },
258
257
  execute: async (_input, context) => {
259
- const sessionId = (context as any).sessionID || ""
258
+ const sessionId = context.sessionID
260
259
  const config = getConfig(sessionId)
261
260
  const state = getState(sessionId, config)
262
261
 
@@ -267,23 +266,18 @@ export default Plugin.define({
267
266
  return { content: "No messages found in session" }
268
267
  }
269
268
 
270
- const messageWithParts: MessageWithParts[] = messages.map((m: any) => ({
271
- info: m.info || m,
272
- parts: m.parts || [],
273
- }))
274
-
275
- const modelId = (context as any).model?.id || "unknown"
269
+ const messageWithParts: MessageWithParts[] = messages.map(
270
+ (m: any) => wrapAsMessageWithParts(m),
271
+ )
276
272
 
277
273
  const panelData = await buildPanelData(
278
274
  sessionId,
279
275
  messageWithParts,
280
276
  state,
281
277
  config,
282
- modelId,
283
278
  )
284
279
 
285
280
  const panel = renderPanel(panelData)
286
-
287
281
  return { content: panel }
288
282
  } catch (error) {
289
283
  return {
@@ -294,92 +288,85 @@ export default Plugin.define({
294
288
  })
295
289
  })
296
290
 
297
- // ─── System Prompt Hook ──────────────────────────────────────────
291
+ // ─── System Prompt Hook (sync) ───────────────────────────────────
298
292
  await ctx.session.hook("context", (event) => {
299
- const sessionId = (event as any).sessionID || ""
293
+ const sessionId = event.sessionID
300
294
  const config = getConfig(sessionId)
301
- if (!config.enabled || !config.compress.enabled) {
302
- return
303
- }
295
+ if (!config.enabled || !config.compress.enabled) return
304
296
 
305
297
  const state = getState(sessionId, config)
298
+ state.modelContextLimit = 200000 // default; updated by tool calls
306
299
 
307
- // Track model context limit
308
- if ((event as any).model?.limit?.context) {
309
- state.modelContextLimit = (event as any).model.limit.context
310
- }
311
-
312
- // Add system prompt
313
- const systemPrompt = getSystemPrompt()
314
- event.system.push({ type: "text", text: systemPrompt })
300
+ event.system.push({ type: "text", text: getSystemPrompt() })
315
301
  })
316
302
 
317
- // ─── Messages Transform Hook ─────────────────────────────────────
318
- await ctx.session.hook("context", async (event) => {
319
- const config = getConfig("")
320
- if (!config.enabled) {
321
- return
322
- }
303
+ // ─── Messages Transform Hook (sync) ──────────────────────────────
304
+ await ctx.session.hook("context", (event) => {
305
+ const sessionId = event.sessionID
306
+ const config = getConfig(sessionId)
307
+ if (!config.enabled) return
323
308
 
324
- const sessionId = (event as any).sessionID || ""
325
309
  const state = getState(sessionId, config)
326
310
 
327
- // Apply pruning strategies to messages
328
- if (event.messages && Array.isArray(event.messages)) {
329
- const prunedMessages = pruneMessages(
330
- event.messages as any,
331
- config,
332
- event.messages.length,
333
- )
334
-
335
- // Replace messages
336
- event.messages.length = 0
337
- event.messages.push(...(prunedMessages as any))
311
+ // Apply pruning strategies
312
+ const pruned = pruneMessages(
313
+ event.messages.map((m: any) => wrapAsMessageWithParts(m)),
314
+ config,
315
+ event.messages.length,
316
+ )
317
+
318
+ // Replace messages in-place
319
+ event.messages.length = 0
320
+ for (const msg of pruned) {
321
+ event.messages.push(msg as any)
322
+ }
338
323
 
339
- // Check if compression nudge is needed
340
- let totalTokens = 0
341
- for (const msg of event.messages) {
342
- const text = getMessageText(msg as any) + getToolResultContent(msg as any)
343
- totalTokens += await countTokens(text)
324
+ // Quick token estimate (sync, ~4 chars per token)
325
+ let totalTokens = 0
326
+ for (const msg of event.messages) {
327
+ const content = (msg as any).content
328
+ if (Array.isArray(content)) {
329
+ for (const part of content) {
330
+ if (part.type === "text" && part.text) {
331
+ totalTokens += Math.ceil(part.text.length / 4)
332
+ }
333
+ }
344
334
  }
335
+ }
345
336
 
346
- state.currentTokenCount = totalTokens
347
-
348
- const maxTokens = resolveTokenLimit(
349
- config.compress.maxContextLimit,
350
- state.modelContextLimit,
351
- )
352
- const minTokens = resolveTokenLimit(
353
- config.compress.minContextLimit,
354
- state.modelContextLimit,
355
- )
356
-
357
- const shouldComp = shouldCompress(
337
+ state.currentTokenCount = totalTokens
338
+
339
+ const maxTokens = resolveTokenLimit(
340
+ config.compress.maxContextLimit,
341
+ state.modelContextLimit,
342
+ )
343
+ const minTokens = resolveTokenLimit(
344
+ config.compress.minContextLimit,
345
+ state.modelContextLimit,
346
+ )
347
+
348
+ const shouldComp = shouldCompress(
349
+ totalTokens,
350
+ maxTokens,
351
+ minTokens,
352
+ state.lastCompressionTime,
353
+ config.compress.nudgeFrequency,
354
+ event.messages.length,
355
+ )
356
+
357
+ if (shouldComp.compress && !state.manualMode) {
358
+ const nudgeMessage = getNudgeMessage(
359
+ shouldComp.reason,
358
360
  totalTokens,
359
361
  maxTokens,
360
- minTokens,
361
- state.lastCompressionTime,
362
- config.compress.nudgeFrequency,
363
- event.messages.length,
364
362
  )
365
-
366
- if (shouldComp.compress && !state.manualMode) {
367
- const nudgeMessage = getNudgeMessage(
368
- shouldComp.reason,
369
- totalTokens,
370
- maxTokens,
371
- )
372
- event.messages.push({
373
- info: {
374
- role: "assistant",
375
- sessionID: sessionId,
376
- } as any,
377
- parts: [{ type: "text", text: nudgeMessage }],
378
- } as any)
379
- }
380
-
381
- saveSessionState(state, config.persistence.directory)
363
+ event.messages.push({
364
+ role: "assistant",
365
+ content: [{ type: "text", text: nudgeMessage }],
366
+ } as any)
382
367
  }
368
+
369
+ saveSessionState(state, config.persistence.directory)
383
370
  })
384
371
 
385
372
  // ─── Event Subscription ──────────────────────────────────────────
@@ -387,8 +374,8 @@ export default Plugin.define({
387
374
  void (async () => {
388
375
  for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
389
376
  if (event.type === "session.created") {
390
- const properties = (event as any).properties || {}
391
- const sessionId = properties.sessionID || ""
377
+ const props = (event as any).properties || {}
378
+ const sessionId = props.sessionID || ""
392
379
  const config = getConfig(sessionId)
393
380
  sessionConfigs.set(sessionId, config)
394
381
  getState(sessionId, config)
@@ -399,7 +386,6 @@ export default Plugin.define({
399
386
  // ─── Cleanup ─────────────────────────────────────────────────────
400
387
  return () => {
401
388
  eventController.abort()
402
- // Save all states on dispose
403
389
  for (const [sessionId, state] of sessionStates.entries()) {
404
390
  const config = getConfig(sessionId)
405
391
  saveSessionState(state, config.persistence.directory)
@@ -10,7 +10,6 @@ async function getTokenizer() {
10
10
  const mod = await import("@anthropic-ai/tokenizer")
11
11
  tokenizer = mod
12
12
  } catch {
13
- // Fallback: estimate ~4 chars per token
14
13
  return null
15
14
  }
16
15
  }
@@ -40,9 +39,8 @@ export function getMessageText(msg: MessageWithParts): string {
40
39
 
41
40
  for (const part of msg.parts) {
42
41
  if (part.type === "text") {
43
- const textPart = part as any
44
- if (textPart.text) {
45
- texts.push(textPart.text)
42
+ if (part.text) {
43
+ texts.push(part.text)
46
44
  }
47
45
  }
48
46
  }
@@ -54,10 +52,15 @@ export function getToolResultContent(msg: MessageWithParts): string {
54
52
  const results: string[] = []
55
53
 
56
54
  for (const part of msg.parts) {
57
- if (part.type === "tool") {
58
- const toolPart = part as any
59
- if (toolPart.state?.type === "result" && toolPart.state?.output) {
60
- results.push(String(toolPart.state.output).slice(0, 500))
55
+ // v1 SDK format: type === "tool"
56
+ if (part.type === "tool" && part.state?.type === "result" && part.state?.output) {
57
+ results.push(String(part.state.output).slice(0, 500))
58
+ }
59
+ // v2 AI format: type === "tool-result"
60
+ if (part.type === "tool-result" && part.result) {
61
+ const val = part.result.value
62
+ if (val !== undefined && val !== null) {
63
+ results.push(String(val).slice(0, 500))
61
64
  }
62
65
  }
63
66
  }
@@ -67,9 +70,13 @@ export function getToolResultContent(msg: MessageWithParts): string {
67
70
 
68
71
  export function getToolName(msg: MessageWithParts): string | null {
69
72
  for (const part of msg.parts) {
73
+ // v1 SDK format
70
74
  if (part.type === "tool") {
71
- const toolPart = part as any
72
- return toolPart.tool || null
75
+ return part.tool || null
76
+ }
77
+ // v2 AI format
78
+ if (part.type === "tool-call") {
79
+ return part.name || null
73
80
  }
74
81
  }
75
82
  return null
@@ -96,7 +103,6 @@ export function shouldCompress(
96
103
 
97
104
  // Soft limit: recommend compression
98
105
  if (currentTokens >= minTokens) {
99
- // Check if enough time has passed since last compression
100
106
  if (minutesSinceLast >= nudgeFrequency) {
101
107
  return {
102
108
  compress: true,
package/src/lib/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Message, Part } from "@opencode-ai/sdk/v2"
1
+ import type { Message } from "@opencode-ai/sdk/v2"
2
2
 
3
3
  // ─── Config Types ───────────────────────────────────────────────────────────
4
4
 
@@ -90,7 +90,7 @@ export interface ToolCallInfo {
90
90
 
91
91
  export interface MessageWithParts {
92
92
  info: Message
93
- parts: Part[]
93
+ parts: any[]
94
94
  }
95
95
 
96
96
  export interface CompressionRange {
@@ -1,90 +0,0 @@
1
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
- import type { SlimConfig } from "../types"
3
- import { readFileSync, existsSync } from "fs"
4
- import { join } from "path"
5
- import { homedir } from "os"
6
- import { parse } from "jsonc-parser/lib/esm/main.js"
7
-
8
- const DEFAULT_CONFIG: SlimConfig = {
9
- enabled: true,
10
- debug: false,
11
- compress: {
12
- enabled: true,
13
- permission: "allow",
14
- maxContextLimit: "80%",
15
- minContextLimit: "40%",
16
- nudgeFrequency: 5,
17
- protectUserMessages: false,
18
- protectedTools: ["task", "skill", "todowrite", "todoread"],
19
- },
20
- strategies: {
21
- deduplication: {
22
- enabled: true,
23
- protectedTools: [],
24
- },
25
- purgeErrors: {
26
- enabled: true,
27
- turns: 4,
28
- protectedTools: [],
29
- },
30
- },
31
- adaptive: {
32
- enabled: true,
33
- learningRate: 0.1,
34
- minCompressionRatio: 0.3,
35
- },
36
- costAware: {
37
- enabled: true,
38
- cacheBoostFactor: 0.5,
39
- },
40
- persistence: {
41
- enabled: true,
42
- directory: join(homedir(), ".config", "opencode", "slim"),
43
- },
44
- }
45
-
46
- function deepMerge(base: SlimConfig, override: Partial<SlimConfig>): SlimConfig {
47
- return {
48
- ...base,
49
- ...override,
50
- compress: { ...base.compress, ...override.compress },
51
- strategies: {
52
- deduplication: { ...base.strategies.deduplication, ...override.strategies?.deduplication },
53
- purgeErrors: { ...base.strategies.purgeErrors, ...override.strategies?.purgeErrors },
54
- },
55
- adaptive: { ...base.adaptive, ...override.adaptive },
56
- costAware: { ...base.costAware, ...override.costAware },
57
- persistence: { ...base.persistence, ...override.persistence },
58
- }
59
- }
60
-
61
- export function loadConfig(_api: TuiPluginApi): SlimConfig {
62
- let config = { ...DEFAULT_CONFIG }
63
-
64
- const globalDir = process.env.XDG_CONFIG_HOME
65
- ? join(process.env.XDG_CONFIG_HOME, "opencode")
66
- : join(homedir(), ".config", "opencode")
67
-
68
- const globalPath = join(globalDir, "slim.jsonc")
69
- const globalPathJson = join(globalDir, "slim.json")
70
-
71
- const configPath = existsSync(globalPath)
72
- ? globalPath
73
- : existsSync(globalPathJson)
74
- ? globalPathJson
75
- : null
76
-
77
- if (configPath) {
78
- try {
79
- const content = readFileSync(configPath, "utf-8")
80
- const parsed = parse(content)
81
- if (parsed) {
82
- config = deepMerge(config, parsed)
83
- }
84
- } catch {
85
- // Use defaults
86
- }
87
- }
88
-
89
- return config
90
- }
@@ -1,467 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
-
3
- import { createSignal, onMount, For, Show } from "solid-js"
4
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
5
- import type { SlimConfig } from "../types"
6
- import type { Message, Part, AssistantMessage } from "@opencode-ai/sdk/v2"
7
-
8
- // ─── Types ──────────────────────────────────────────────────────────────────
9
-
10
- interface PanelData {
11
- currentTokens: number
12
- maxTokens: number
13
- usagePercent: number
14
- status: "healthy" | "warning" | "critical"
15
- userMessages: number
16
- assistantMessages: number
17
- toolCalls: number
18
- toolResults: number
19
- compressionCount: number
20
- averageRatio: number
21
- totalTokensSaved: number
22
- estimatedCost: number
23
- costSaved: number
24
- model: string
25
- recommendations: string[]
26
- }
27
-
28
- // ─── UI Components ──────────────────────────────────────────────────────────
29
-
30
- function SlimFrame(props: {
31
- api: TuiPluginApi
32
- title: string
33
- eyebrow: string
34
- children: any
35
- onBack?: () => void
36
- }) {
37
- const theme = props.api.theme.current
38
-
39
- return (
40
- <box paddingLeft={3} paddingRight={3} paddingBottom={1} gap={1}>
41
- <box flexDirection="row" justifyContent="space-between">
42
- <box flexDirection="column">
43
- <text fg={theme.primary}>
44
- <b>{props.eyebrow}</b>
45
- </text>
46
- <text fg={theme.text}>
47
- <b>{props.title}</b>
48
- </text>
49
- </box>
50
- <text fg={theme.textMuted} onMouseUp={() => props.api.ui.dialog.clear()}>
51
- esc
52
- </text>
53
- </box>
54
- <box height={1} border={["bottom"]} borderColor={theme.borderSubtle} />
55
- {props.children}
56
- <box flexDirection="row" justifyContent="flex-end" paddingTop={1}>
57
- <box
58
- paddingLeft={2}
59
- paddingRight={2}
60
- backgroundColor={theme.primary}
61
- onMouseUp={() => props.api.ui.dialog.clear()}
62
- >
63
- <text fg={theme.selectedListItemText}>close</text>
64
- </box>
65
- </box>
66
- </box>
67
- )
68
- }
69
-
70
- function Card(props: { theme: any; title: string; children: any }) {
71
- const accent = props.theme.primary
72
- return (
73
- <box
74
- flexDirection="column"
75
- paddingLeft={2}
76
- paddingRight={2}
77
- paddingTop={1}
78
- paddingBottom={1}
79
- backgroundColor={props.theme.backgroundElement}
80
- border={["left"]}
81
- borderColor={accent}
82
- gap={1}
83
- >
84
- <text fg={accent}>
85
- <b>{props.title}</b>
86
- </text>
87
- {props.children}
88
- </box>
89
- )
90
- }
91
-
92
- function Metric(props: { theme: any; label: string; value: string; hint?: string }) {
93
- return (
94
- <box flexDirection="row" gap={2}>
95
- <box width={24}>
96
- <text fg={props.theme.textMuted}>{props.label}</text>
97
- </box>
98
- <box flexDirection="row" gap={1} flexGrow={1}>
99
- <text fg={props.theme.text}>
100
- <b>{props.value}</b>
101
- </text>
102
- {props.hint ? <text fg={props.theme.textMuted}>{props.hint}</text> : null}
103
- </box>
104
- </box>
105
- )
106
- }
107
-
108
- function Progress(props: {
109
- theme: any
110
- label: string
111
- value: number
112
- total: number
113
- color: "primary" | "accent" | "success" | "warning" | "error"
114
- detail: string
115
- }) {
116
- const width = 32
117
- const filled =
118
- props.total > 0 ? Math.max(0, Math.round((props.value / props.total) * width)) : 0
119
- const empty = Math.max(0, width - filled)
120
- return (
121
- <box flexDirection="column" gap={0}>
122
- <box flexDirection="row" gap={2}>
123
- <box width={20}>
124
- <text fg={props.theme.text}>{props.label}</text>
125
- </box>
126
- <box flexDirection="row" gap={1} flexGrow={1}>
127
- <text fg={props.theme.text}>
128
- <b>{props.total > 0 ? `${Math.round((props.value / props.total) * 100)}%` : "0%"}</b>
129
- </text>
130
- <text fg={props.theme.textMuted}>{props.detail}</text>
131
- </box>
132
- </box>
133
- <box flexDirection="row">
134
- <text fg={props.theme[props.color]}>{"█".repeat(filled)}</text>
135
- <text fg={props.theme.borderSubtle}>{"░".repeat(empty)}</text>
136
- </box>
137
- </box>
138
- )
139
- }
140
-
141
- // ─── Panel Dialog ───────────────────────────────────────────────────────────
142
-
143
- export function PanelDialog(props: { api: TuiPluginApi; config: SlimConfig }) {
144
- const theme = props.api.theme.current
145
- const [data, setData] = createSignal<PanelData | null>(null)
146
- const [loading, setLoading] = createSignal(true)
147
- const [activeTab, setActiveTab] = createSignal<"context" | "stats" | "help">("context")
148
-
149
- onMount(async () => {
150
- try {
151
- const panelData = await fetchPanelData(props.api, props.config)
152
- setData(panelData)
153
- } catch (error) {
154
- console.error("Failed to load panel data:", error)
155
- } finally {
156
- setLoading(false)
157
- }
158
- })
159
-
160
- const formatTokens = (tokens: number): string => {
161
- if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
162
- if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
163
- return String(tokens)
164
- }
165
-
166
- return (
167
- <SlimFrame api={props.api} title="Context Panel" eyebrow="SLIM">
168
- {/* Tab Navigation */}
169
- <box flexDirection="row" gap={1} marginBottom={1}>
170
- <TabButton
171
- theme={theme}
172
- label="Context"
173
- active={activeTab() === "context"}
174
- onClick={() => setActiveTab("context")}
175
- />
176
- <TabButton
177
- theme={theme}
178
- label="Stats"
179
- active={activeTab() === "stats"}
180
- onClick={() => setActiveTab("stats")}
181
- />
182
- <TabButton
183
- theme={theme}
184
- label="Help"
185
- active={activeTab() === "help"}
186
- onClick={() => setActiveTab("help")}
187
- />
188
- </box>
189
-
190
- {/* Content */}
191
- <Show
192
- when={!loading()}
193
- fallback={
194
- <box padding={2}>
195
- <text fg={theme.textMuted}>Loading...</text>
196
- </box>
197
- }
198
- >
199
- <Show
200
- when={data()}
201
- fallback={
202
- <box padding={2}>
203
- <text fg={theme.error}>Failed to load panel data</text>
204
- </box>
205
- }
206
- >
207
- {activeTab() === "context" && (
208
- <ContextTab data={data()!} theme={theme} formatTokens={formatTokens} />
209
- )}
210
- {activeTab() === "stats" && (
211
- <StatsTab data={data()!} theme={theme} formatTokens={formatTokens} />
212
- )}
213
- {activeTab() === "help" && <HelpTab theme={theme} />}
214
- </Show>
215
- </Show>
216
- </SlimFrame>
217
- )
218
- }
219
-
220
- // ─── Tab Button ─────────────────────────────────────────────────────────────
221
-
222
- function TabButton(props: {
223
- theme: any
224
- label: string
225
- active: boolean
226
- onClick: () => void
227
- }) {
228
- return (
229
- <box
230
- paddingLeft={1}
231
- paddingRight={1}
232
- backgroundColor={props.active ? props.theme.primary : props.theme.backgroundElement}
233
- onMouseUp={props.onClick}
234
- >
235
- <text fg={props.active ? props.theme.background : props.theme.text}>
236
- {props.label}
237
- </text>
238
- </box>
239
- )
240
- }
241
-
242
- // ─── Context Tab ────────────────────────────────────────────────────────────
243
-
244
- function ContextTab(props: { data: PanelData; theme: any; formatTokens: (n: number) => string }) {
245
- const statusIcon = () =>
246
- props.data.status === "healthy" ? "🟢" : props.data.status === "warning" ? "🟡" : "🔴"
247
-
248
- const statusColor = () =>
249
- props.data.status === "healthy"
250
- ? props.theme.success
251
- : props.data.status === "warning"
252
- ? props.theme.warning
253
- : props.theme.error
254
-
255
- return (
256
- <box flexDirection="column" gap={1}>
257
- <Card theme={props.theme} title="Status">
258
- <Metric
259
- theme={props.theme}
260
- label="Status"
261
- value={`${statusIcon()} ${props.data.status.toUpperCase()}`}
262
- />
263
- <Progress
264
- theme={props.theme}
265
- label="Context"
266
- value={props.data.currentTokens}
267
- total={props.data.maxTokens}
268
- color={props.data.status === "critical" ? "error" : props.data.status === "warning" ? "warning" : "primary"}
269
- detail={`${props.formatTokens(props.data.currentTokens)} / ${props.formatTokens(props.data.maxTokens)} tokens`}
270
- />
271
- </Card>
272
-
273
- <Card theme={props.theme} title="Messages">
274
- <Metric theme={props.theme} label="User" value={`${props.data.userMessages}`} />
275
- <Metric theme={props.theme} label="Assistant" value={`${props.data.assistantMessages}`} />
276
- <Metric theme={props.theme} label="Tool calls" value={`${props.data.toolCalls}`} />
277
- <Metric theme={props.theme} label="Results" value={`${props.data.toolResults}`} />
278
- </Card>
279
-
280
- <Card theme={props.theme} title="Recommendations">
281
- <box flexDirection="column">
282
- <For each={props.data.recommendations}>
283
- {(rec) => <text>• {rec}</text>}
284
- </For>
285
- </box>
286
- </Card>
287
- </box>
288
- )
289
- }
290
-
291
- // ─── Stats Tab ──────────────────────────────────────────────────────────────
292
-
293
- function StatsTab(props: { data: PanelData; theme: any; formatTokens: (n: number) => string }) {
294
- return (
295
- <box flexDirection="column" gap={1}>
296
- <Card theme={props.theme} title="Compression">
297
- <Metric theme={props.theme} label="Count" value={`${props.data.compressionCount}`} />
298
- <Metric
299
- theme={props.theme}
300
- label="Avg ratio"
301
- value={`${(props.data.averageRatio * 100).toFixed(1)}%`}
302
- />
303
- <Metric
304
- theme={props.theme}
305
- label="Tokens saved"
306
- value={props.formatTokens(props.data.totalTokensSaved)}
307
- />
308
- </Card>
309
-
310
- <Card theme={props.theme} title="Cost">
311
- <Metric
312
- theme={props.theme}
313
- label="Current"
314
- value={`$${props.data.estimatedCost.toFixed(4)}`}
315
- />
316
- <Metric
317
- theme={props.theme}
318
- label="Saved"
319
- value={`$${props.data.costSaved.toFixed(4)}`}
320
- />
321
- <Metric theme={props.theme} label="Model" value={props.data.model} />
322
- </Card>
323
- </box>
324
- )
325
- }
326
-
327
- // ─── Help Tab ───────────────────────────────────────────────────────────────
328
-
329
- function HelpTab(props: { theme: any }) {
330
- return (
331
- <box flexDirection="column" gap={1}>
332
- <Card theme={props.theme} title="Commands">
333
- <text>/panel - Open this panel</text>
334
- <text>/compress [focus] - Run compression</text>
335
- </Card>
336
-
337
- <Card theme={props.theme} title="Tool Usage">
338
- <text>compress({"{ focus: \"old exploration\" }"})</text>
339
- <text>compress({"{ focus: \"tasks\", mode: \"range\", start: 0, end: 50 }"})</text>
340
- <text>compress({"{ focus: \"database\", mode: \"topic\", topic: \"db\" }"})</text>
341
- </Card>
342
-
343
- <Card theme={props.theme} title="Configuration">
344
- <text>~/.config/opencode/slim.jsonc</text>
345
- </Card>
346
- </box>
347
- )
348
- }
349
-
350
- // ─── Data Fetching ──────────────────────────────────────────────────────────
351
-
352
- async function fetchPanelData(
353
- api: TuiPluginApi,
354
- config: SlimConfig,
355
- ): Promise<PanelData> {
356
- const currentRoute = api.route.current
357
-
358
- let sessionId: string | undefined
359
- if (currentRoute.name === "session") {
360
- sessionId = currentRoute.params?.sessionID as string | undefined
361
- }
362
-
363
- if (!sessionId) {
364
- return {
365
- currentTokens: 0,
366
- maxTokens: resolveTokenLimit(config.compress.maxContextLimit, 200000),
367
- usagePercent: 0,
368
- status: "healthy",
369
- userMessages: 0,
370
- assistantMessages: 0,
371
- toolCalls: 0,
372
- toolResults: 0,
373
- compressionCount: 0,
374
- averageRatio: 0,
375
- totalTokensSaved: 0,
376
- estimatedCost: 0,
377
- costSaved: 0,
378
- model: "unknown",
379
- recommendations: ["No active session. Start a conversation to see context usage."],
380
- }
381
- }
382
-
383
- const messages = api.state.session.messages(sessionId)
384
-
385
- // Calculate token usage from messages
386
- let currentTokens = 0
387
- let userMessages = 0
388
- let assistantMessages = 0
389
- let toolCalls = 0
390
- let toolResults = 0
391
- let model = "unknown"
392
-
393
- for (const msg of messages) {
394
- // Get role from message
395
- const role = msg.role
396
- if (role === "user") {
397
- userMessages++
398
- } else if (role === "assistant") {
399
- assistantMessages++
400
- // Get model from assistant message
401
- const assistantMsg = msg as AssistantMessage
402
- if (assistantMsg.providerID && assistantMsg.modelID) {
403
- model = `${assistantMsg.providerID}/${assistantMsg.modelID}`
404
- }
405
- }
406
-
407
- // Get parts for this message
408
- const parts = api.state.part(msg.id)
409
-
410
- for (const part of parts) {
411
- if (part.type === "text") {
412
- const textPart = part as any
413
- currentTokens += Math.ceil((textPart.text?.length || 0) / 4)
414
- } else if (part.type === "tool") {
415
- const toolPart = part as any
416
- const status = toolPart.state?.type
417
- if (status === "completed") {
418
- toolResults++
419
- } else if (status === "pending" || status === "running") {
420
- toolCalls++
421
- }
422
- }
423
- }
424
- }
425
-
426
- const maxTokens = resolveTokenLimit(config.compress.maxContextLimit, 200000)
427
- const usagePercent = (currentTokens / maxTokens) * 100
428
-
429
- let statusLevel: "healthy" | "warning" | "critical" = "healthy"
430
- if (usagePercent > 90) statusLevel = "critical"
431
- else if (usagePercent > 70) statusLevel = "warning"
432
-
433
- const recommendations: string[] = []
434
- if (usagePercent > 80) {
435
- recommendations.push("Context usage is high. Consider compressing older messages.")
436
- }
437
- if (usagePercent > 90) {
438
- recommendations.push("Context nearly full! Run compress immediately.")
439
- }
440
- if (recommendations.length === 0) {
441
- recommendations.push("Context is healthy. No action needed.")
442
- }
443
-
444
- return {
445
- currentTokens,
446
- maxTokens,
447
- usagePercent,
448
- status: statusLevel,
449
- userMessages,
450
- assistantMessages,
451
- toolCalls,
452
- toolResults,
453
- compressionCount: 0,
454
- averageRatio: 0,
455
- totalTokensSaved: 0,
456
- estimatedCost: (currentTokens / 1000) * 0.003,
457
- costSaved: 0,
458
- model,
459
- recommendations,
460
- }
461
- }
462
-
463
- function resolveTokenLimit(value: number | string, contextLimit: number): number {
464
- if (typeof value === "number") return value
465
- const percent = parseFloat(value.replace("%", "")) / 100
466
- return Math.floor(contextLimit * percent)
467
- }
@@ -1,12 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
-
3
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
4
- import type { SlimConfig } from "../types"
5
- import { PanelDialog } from "./dialogs"
6
-
7
- export function openPanelModal(api: TuiPluginApi, config: SlimConfig): void {
8
- api.ui.dialog.setSize("xlarge")
9
- api.ui.dialog.replace(() => (
10
- <PanelDialog api={api} config={config} />
11
- ))
12
- }
package/src/tui.tsx DELETED
@@ -1,61 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
-
3
- import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
4
- import type { SlimConfig } from "./lib/types"
5
- import { loadConfig } from "./lib/tui/data"
6
- import { openPanelModal } from "./lib/tui/modals"
7
-
8
- /**
9
- * Register slash commands using the v2 keymap API.
10
- * This plugin is v2-only.
11
- */
12
- function registerSlashCommands(api: any, config: SlimConfig): void {
13
- // v2 way: keymap.registerLayer
14
- if (api.keymap && typeof api.keymap.registerLayer === "function") {
15
- api.keymap.registerLayer({
16
- mode: "global",
17
- priority: 10,
18
- commands: [
19
- {
20
- name: "slim.panel",
21
- title: "Open Slim Panel",
22
- group: "Slim",
23
- slash: { name: "panel" },
24
- enabled: () => true,
25
- suggested: true,
26
- run: () => {
27
- openPanelModal(api, config)
28
- },
29
- },
30
- {
31
- name: "slim.compress",
32
- title: "Compress Context",
33
- group: "Slim",
34
- slash: { name: "compress" },
35
- enabled: () => true,
36
- suggested: true,
37
- run: () => {
38
- api.ui.toast({
39
- title: "Slim",
40
- message:
41
- "Use the compress tool: compress({ focus: 'your focus' })",
42
- variant: "info",
43
- })
44
- },
45
- },
46
- ],
47
- })
48
- }
49
- }
50
-
51
- const tui: TuiPluginModule["tui"] = async (api) => {
52
- const config = loadConfig(api as any)
53
- if (!config.enabled) return
54
-
55
- registerSlashCommands(api, config)
56
- }
57
-
58
- export default {
59
- id: "opencodev2-slim",
60
- tui,
61
- } satisfies TuiPluginModule