@serkanalgur/opencode-slim 1.2.0 → 1.3.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 CHANGED
@@ -1,15 +1,22 @@
1
1
  # opencode-slim
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@serkanalgur/opencode-slim.svg)](https://www.npmjs.com/package/@serkanalgur/opencode-slim)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
3
6
  Smart context management plugin for OpenCode. Optimizes token usage through semantic compression, cost-aware pruning, and adaptive thresholds.
4
7
 
5
8
  ## Features
6
9
 
10
+ - **TUI Panel** - Rich context usage visualization with status indicators
11
+ - **Enhanced Compress** - Auto/range/topic modes for flexible compression
7
12
  - **Semantic Compression** - Groups related tool calls and compresses them intelligently
8
13
  - **Cost-Aware Pruning** - Considers token pricing when deciding what to compress
9
14
  - **Adaptive Thresholds** - Learns from compression history to optimize timing
10
15
  - **Session Persistence** - Saves state across restarts
11
16
  - **Deduplication** - Removes repeated tool calls automatically
12
17
  - **Error Purging** - Cleans up failed tool call outputs after configurable turns
18
+ - **Topic Extraction** - Identifies and tracks conversation topics
19
+ - **Smart Recommendations** - Provides actionable suggestions for context optimization
13
20
 
14
21
  ## Installation
15
22
 
@@ -17,6 +24,39 @@ Smart context management plugin for OpenCode. Optimizes token usage through sema
17
24
  opencode plugin @serkanalgur/opencode-slim@latest --global
18
25
  ```
19
26
 
27
+ ## Usage
28
+
29
+ ### Slash Commands
30
+
31
+ Slim provides slash commands for easy access:
32
+
33
+ - `/panel` — Opens the rich TUI panel with context usage, stats, and help
34
+ - `/compress [focus]` — Manually trigger compression with focus description
35
+
36
+ ### TUI Panel
37
+
38
+ The panel provides a real-time overview of your context usage, including:
39
+ - Token usage vs model limit with visual progress bar
40
+ - Message breakdown by role (user/assistant/tools)
41
+ - Compression history and savings
42
+ - Cost estimation based on your model
43
+ - Smart recommendations for optimization
44
+
45
+ ### Compress Tool
46
+
47
+ The enhanced compress tool supports multiple modes:
48
+
49
+ ```typescript
50
+ // Auto mode (default) - intelligently selects what to compress
51
+ compress({ focus: "old exploration" })
52
+
53
+ // Range mode - compress specific message range
54
+ compress({ focus: "completed tasks", mode: "range", start: 0, end: 50 })
55
+
56
+ // Topic mode - compress messages matching a topic
57
+ compress({ focus: "database work", mode: "topic", topic: "database" })
58
+ ```
59
+
20
60
  ## Configuration
21
61
 
22
62
  Create `~/.config/opencode/slim.jsonc`:
@@ -62,6 +102,16 @@ Create `~/.config/opencode/slim.jsonc`:
62
102
 
63
103
  ## How It Works
64
104
 
105
+ ### TUI Panel
106
+
107
+ The panel provides a real-time overview of your context usage, including:
108
+ - Token usage vs model limit with visual progress bar
109
+ - Message breakdown by role (user/assistant/tools)
110
+ - Compression history and savings
111
+ - Cost estimation based on your model
112
+ - Topic distribution across the conversation
113
+ - Smart recommendations for optimization
114
+
65
115
  ### Semantic Compression
66
116
 
67
117
  Unlike simple text truncation, slim analyzes the semantic content of messages and groups related tool calls together. This preserves context while removing redundancy.
@@ -80,19 +130,24 @@ State is saved to disk, so compression history and learning persist across resta
80
130
 
81
131
  ## Commands
82
132
 
83
- - `/slim` - Show current context stats and compression history
84
- - `/slim compress` - Manually trigger compression
85
- - `/slim reset` - Reset adaptive learning
133
+ | Command | Description |
134
+ |---------|-------------|
135
+ | `/panel` | Open the Slim TUI panel with context usage, stats, and help |
136
+ | `/compress [focus]` | Manually trigger compression with focus description |
86
137
 
87
138
  ## Comparison with DCP
88
139
 
89
140
  | Feature | slim | DCP |
90
141
  |---------|------|-----|
142
+ | TUI Panel | ✅ | ✅ |
143
+ | Slash Commands | ✅ `/panel` `/compress` | ✅ `/dcp` `/dcp-compress` |
144
+ | Manual Compress | ✅ | ✅ |
91
145
  | Semantic grouping | ✅ | ❌ |
92
146
  | Cost awareness | ✅ | ❌ |
93
147
  | Adaptive thresholds | ✅ | ❌ |
94
148
  | Session persistence | ✅ | ❌ |
95
- | File count | ~8 | ~150 |
149
+ | Topic extraction | | |
150
+ | File count | ~10 | ~150 |
96
151
  | License | MIT | AGPL-3.0 |
97
152
  | Config complexity | Low | High |
98
153
 
@@ -0,0 +1,14 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
2
+ export interface SlimCommand {
3
+ title: string;
4
+ name: string;
5
+ description: string;
6
+ slashName: string;
7
+ run: () => void | Promise<void>;
8
+ }
9
+ /**
10
+ * Register slash commands with the OpenCode TUI.
11
+ * Uses the legacy api.command API for V1 compatibility.
12
+ */
13
+ export declare function registerCommands(api: TuiPluginApi, commands: SlimCommand[]): void;
14
+ //# sourceMappingURL=commands.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../../lib/tui/commands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAE3D,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAClC;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,IAAI,CAejF"}
@@ -0,0 +1,4 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
2
+ import type { SlimConfig } from "../types";
3
+ export declare function loadConfig(_api: TuiPluginApi): SlimConfig;
4
+ //# sourceMappingURL=data.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data.d.ts","sourceRoot":"","sources":["../../../lib/tui/data.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAC3D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AA2D1C,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU,CA6BzD"}
@@ -0,0 +1,8 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
3
+ import type { SlimConfig } from "../types";
4
+ export declare function PanelDialog(props: {
5
+ api: TuiPluginApi;
6
+ config: SlimConfig;
7
+ }): any;
8
+ //# sourceMappingURL=dialogs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dialogs.d.ts","sourceRoot":"","sources":["../../../lib/tui/dialogs.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AAGtC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAC3D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AA0I1C,wBAAgB,WAAW,CAAC,KAAK,EAAE;IAAE,GAAG,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,UAAU,CAAA;CAAE,OA2E3E"}
@@ -0,0 +1,5 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
3
+ import type { SlimConfig } from "../types";
4
+ export declare function openPanelModal(api: TuiPluginApi, config: SlimConfig): void;
5
+ //# sourceMappingURL=modals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modals.d.ts","sourceRoot":"","sources":["../../../lib/tui/modals.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AAEtC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAC3D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAG1C,wBAAgB,cAAc,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI,CAK1E"}
package/dist/tui.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ declare const _default: {
3
+ id: string;
4
+ tui: import("@opencode-ai/plugin/tui").TuiPlugin;
5
+ };
6
+ export default _default;
7
+ //# sourceMappingURL=tui.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../tui.tsx"],"names":[],"mappings":"AAAA,sCAAsC;;;;;AAsBtC,wBAG2B"}
@@ -0,0 +1,30 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
+
3
+ export interface SlimCommand {
4
+ title: string
5
+ name: string
6
+ description: string
7
+ slashName: string
8
+ run: () => void | Promise<void>
9
+ }
10
+
11
+ /**
12
+ * Register slash commands with the OpenCode TUI.
13
+ * Uses the legacy api.command API for V1 compatibility.
14
+ */
15
+ export function registerCommands(api: TuiPluginApi, commands: SlimCommand[]): void {
16
+ if (!api.command) return
17
+
18
+ api.command.register(() =>
19
+ commands.map((cmd) => ({
20
+ title: cmd.title,
21
+ value: cmd.name,
22
+ description: cmd.description,
23
+ category: "Slim",
24
+ slash: {
25
+ name: cmd.slashName,
26
+ },
27
+ onSelect: () => cmd.run(),
28
+ })),
29
+ )
30
+ }
@@ -0,0 +1,90 @@
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
+ }
@@ -0,0 +1,467 @@
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
+ }
@@ -0,0 +1,12 @@
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencode-slim",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Smart context management plugin for OpenCode - semantic compression, cost-aware pruning, adaptive thresholds",
5
5
  "keywords": [
6
6
  "opencode",
@@ -26,6 +26,10 @@
26
26
  ".": {
27
27
  "types": "./dist/index.d.ts",
28
28
  "import": "./dist/index.js"
29
+ },
30
+ "./tui": {
31
+ "types": "./dist/tui.d.ts",
32
+ "import": "./tui.tsx"
29
33
  }
30
34
  },
31
35
  "main": "./dist/index.js",
@@ -36,6 +40,8 @@
36
40
  },
37
41
  "files": [
38
42
  "dist/",
43
+ "tui.tsx",
44
+ "lib/tui/",
39
45
  "README.md",
40
46
  "LICENSE"
41
47
  ],
@@ -54,14 +60,39 @@
54
60
  },
55
61
  "devDependencies": {
56
62
  "@opencode-ai/plugin": "^1.18.29",
63
+ "@opentui/core": "^0.4.5",
64
+ "@opentui/keymap": "^0.4.5",
65
+ "@opentui/solid": "^0.4.5",
57
66
  "@types/node": "^22.0.0",
58
67
  "prettier": "^3.4.0",
68
+ "solid-js": "1.9.12",
59
69
  "tsup": "^8.3.0",
60
70
  "tsx": "^4.19.0",
61
71
  "typescript": "^5.7.0"
62
72
  },
63
73
  "peerDependencies": {
64
- "@opencode-ai/plugin": ">=1.18.29"
74
+ "@opencode-ai/plugin": ">=1.18.29",
75
+ "@opentui/core": ">=0.4.5",
76
+ "@opentui/keymap": ">=0.4.5",
77
+ "@opentui/solid": ">=0.4.5",
78
+ "solid-js": ">=1.0.0"
65
79
  },
66
- "$schema": "https://json.schemastore.org/package.json"
80
+ "peerDependenciesMeta": {
81
+ "@opentui/core": {
82
+ "optional": true
83
+ },
84
+ "@opentui/keymap": {
85
+ "optional": true
86
+ },
87
+ "@opentui/solid": {
88
+ "optional": true
89
+ },
90
+ "solid-js": {
91
+ "optional": true
92
+ }
93
+ },
94
+ "$schema": "https://json.schemastore.org/package.json",
95
+ "optionalDependencies": {
96
+ "solid-js": "^1.9.15"
97
+ }
67
98
  }
package/tui.tsx ADDED
@@ -0,0 +1,26 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
4
+ import { registerCommands } from "./lib/tui/commands"
5
+ import { loadConfig } from "./lib/tui/data"
6
+ import { openPanelModal } from "./lib/tui/modals"
7
+
8
+ const tui: TuiPluginModule["tui"] = async (api) => {
9
+ const config = loadConfig(api)
10
+ if (!config.enabled) return
11
+
12
+ registerCommands(api, [
13
+ {
14
+ title: "Slim",
15
+ name: "slim.panel",
16
+ description: "Open Slim context panel",
17
+ slashName: "panel",
18
+ run: () => openPanelModal(api, config),
19
+ },
20
+ ])
21
+ }
22
+
23
+ export default {
24
+ id: "opencode-slim",
25
+ tui,
26
+ } satisfies TuiPluginModule