@serkanalgur/opencode-slim 1.2.0 → 1.4.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 +73 -4
- package/dist/lib/tui/data.d.ts +4 -0
- package/dist/lib/tui/data.d.ts.map +1 -0
- package/dist/lib/tui/dialogs.d.ts +8 -0
- package/dist/lib/tui/dialogs.d.ts.map +1 -0
- package/dist/lib/tui/modals.d.ts +5 -0
- package/dist/lib/tui/modals.d.ts.map +1 -0
- package/dist/tui.d.ts +7 -0
- package/dist/tui.d.ts.map +1 -0
- package/lib/tui/data.ts +90 -0
- package/lib/tui/dialogs.tsx +467 -0
- package/lib/tui/modals.tsx +12 -0
- package/package.json +34 -3
- package/tui.tsx +90 -0
package/README.md
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
# opencode-slim
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@serkanalgur/opencode-slim)
|
|
4
|
+
[](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,51 @@ 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
|
+
This installs the plugin globally. The TUI features (panel, slash commands) are automatically loaded when OpenCode starts.
|
|
28
|
+
|
|
29
|
+
### Manual Installation
|
|
30
|
+
|
|
31
|
+
If the CLI command doesn't work, add to your `~/.config/opencode/opencode.json`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"plugins": ["@serkanalgur/opencode-slim"]
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Slash Commands
|
|
42
|
+
|
|
43
|
+
After installation, these slash commands are available in the TUI:
|
|
44
|
+
|
|
45
|
+
- `/panel` — Opens the rich TUI panel with context usage, stats, and help
|
|
46
|
+
- `/compress` — Shows instructions for using the compress tool
|
|
47
|
+
|
|
48
|
+
### TUI Panel
|
|
49
|
+
|
|
50
|
+
The panel provides a real-time overview of your context usage, including:
|
|
51
|
+
- Token usage vs model limit with visual progress bar
|
|
52
|
+
- Message breakdown by role (user/assistant/tools)
|
|
53
|
+
- Compression history and savings
|
|
54
|
+
- Cost estimation based on your model
|
|
55
|
+
- Smart recommendations for optimization
|
|
56
|
+
|
|
57
|
+
### Compress Tool
|
|
58
|
+
|
|
59
|
+
The enhanced compress tool supports multiple modes:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Auto mode (default) - intelligently selects what to compress
|
|
63
|
+
compress({ focus: "old exploration" })
|
|
64
|
+
|
|
65
|
+
// Range mode - compress specific message range
|
|
66
|
+
compress({ focus: "completed tasks", mode: "range", start: 0, end: 50 })
|
|
67
|
+
|
|
68
|
+
// Topic mode - compress messages matching a topic
|
|
69
|
+
compress({ focus: "database work", mode: "topic", topic: "database" })
|
|
70
|
+
```
|
|
71
|
+
|
|
20
72
|
## Configuration
|
|
21
73
|
|
|
22
74
|
Create `~/.config/opencode/slim.jsonc`:
|
|
@@ -62,6 +114,16 @@ Create `~/.config/opencode/slim.jsonc`:
|
|
|
62
114
|
|
|
63
115
|
## How It Works
|
|
64
116
|
|
|
117
|
+
### TUI Panel
|
|
118
|
+
|
|
119
|
+
The panel provides a real-time overview of your context usage, including:
|
|
120
|
+
- Token usage vs model limit with visual progress bar
|
|
121
|
+
- Message breakdown by role (user/assistant/tools)
|
|
122
|
+
- Compression history and savings
|
|
123
|
+
- Cost estimation based on your model
|
|
124
|
+
- Topic distribution across the conversation
|
|
125
|
+
- Smart recommendations for optimization
|
|
126
|
+
|
|
65
127
|
### Semantic Compression
|
|
66
128
|
|
|
67
129
|
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 +142,26 @@ State is saved to disk, so compression history and learning persist across resta
|
|
|
80
142
|
|
|
81
143
|
## Commands
|
|
82
144
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
145
|
+
| Command | Description |
|
|
146
|
+
|---------|-------------|
|
|
147
|
+
| `/panel` | Open the Slim TUI panel with context usage, stats, and help |
|
|
148
|
+
| `/compress` | Show instructions for using the compress tool |
|
|
149
|
+
|
|
150
|
+
Note: Compression is performed by the AI assistant using the `compress` tool. The slash command provides guidance on usage.
|
|
86
151
|
|
|
87
152
|
## Comparison with DCP
|
|
88
153
|
|
|
89
154
|
| Feature | slim | DCP |
|
|
90
155
|
|---------|------|-----|
|
|
156
|
+
| TUI Panel | ✅ | ✅ |
|
|
157
|
+
| Slash Commands | ✅ `/panel` `/compress` | ✅ `/dcp` `/dcp-compress` |
|
|
158
|
+
| Manual Compress | ✅ | ✅ |
|
|
91
159
|
| Semantic grouping | ✅ | ❌ |
|
|
92
160
|
| Cost awareness | ✅ | ❌ |
|
|
93
161
|
| Adaptive thresholds | ✅ | ❌ |
|
|
94
162
|
| Session persistence | ✅ | ❌ |
|
|
95
|
-
|
|
|
163
|
+
| Topic extraction | ✅ | ❌ |
|
|
164
|
+
| File count | ~10 | ~150 |
|
|
96
165
|
| License | MIT | AGPL-3.0 |
|
|
97
166
|
| Config complexity | Low | High |
|
|
98
167
|
|
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../tui.tsx"],"names":[],"mappings":"AAAA,sCAAsC;;;;;AAsFtC,wBAG2B"}
|
package/lib/tui/data.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "1.4.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
|
-
"
|
|
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,90 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import type { TuiPluginModule, TuiPluginApi } 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 appropriate API version.
|
|
10
|
+
* - v2 (1.18.29+): keymap.registerLayer
|
|
11
|
+
* - v1 (legacy): api.command.register
|
|
12
|
+
*/
|
|
13
|
+
function registerSlashCommands(api: TuiPluginApi, config: SlimConfig): void {
|
|
14
|
+
// v2 way: keymap.registerLayer
|
|
15
|
+
if (api.keymap && typeof api.keymap.registerLayer === "function") {
|
|
16
|
+
api.keymap.registerLayer({
|
|
17
|
+
mode: "global",
|
|
18
|
+
priority: 10,
|
|
19
|
+
commands: [
|
|
20
|
+
{
|
|
21
|
+
name: "slim.panel",
|
|
22
|
+
title: "Open Slim Panel",
|
|
23
|
+
group: "Slim",
|
|
24
|
+
slash: { name: "panel" },
|
|
25
|
+
enabled: () => true,
|
|
26
|
+
suggested: true,
|
|
27
|
+
run: () => {
|
|
28
|
+
openPanelModal(api, config)
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "slim.compress",
|
|
33
|
+
title: "Compress Context",
|
|
34
|
+
group: "Slim",
|
|
35
|
+
slash: { name: "compress" },
|
|
36
|
+
enabled: () => true,
|
|
37
|
+
suggested: true,
|
|
38
|
+
run: () => {
|
|
39
|
+
api.ui.toast({
|
|
40
|
+
title: "Slim",
|
|
41
|
+
message: "Use the compress tool: compress({ focus: 'your focus' })",
|
|
42
|
+
variant: "info",
|
|
43
|
+
})
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
})
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// v1 fallback: api.command.register
|
|
52
|
+
if (api.command && typeof api.command.register === "function") {
|
|
53
|
+
api.command.register(() => [
|
|
54
|
+
{
|
|
55
|
+
title: "Slim",
|
|
56
|
+
value: "slim.panel",
|
|
57
|
+
description: "Open Slim context panel",
|
|
58
|
+
category: "Slim",
|
|
59
|
+
slash: { name: "panel" },
|
|
60
|
+
onSelect: () => openPanelModal(api, config),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
title: "Compress",
|
|
64
|
+
value: "slim.compress",
|
|
65
|
+
description: "Compress context (use compress tool)",
|
|
66
|
+
category: "Slim",
|
|
67
|
+
slash: { name: "compress" },
|
|
68
|
+
onSelect: () => {
|
|
69
|
+
api.ui.toast({
|
|
70
|
+
title: "Slim",
|
|
71
|
+
message: "Use the compress tool: compress({ focus: 'your focus' })",
|
|
72
|
+
variant: "info",
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
])
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tui: TuiPluginModule["tui"] = async (api) => {
|
|
81
|
+
const config = loadConfig(api)
|
|
82
|
+
if (!config.enabled) return
|
|
83
|
+
|
|
84
|
+
registerSlashCommands(api, config)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default {
|
|
88
|
+
id: "opencode-slim",
|
|
89
|
+
tui,
|
|
90
|
+
} satisfies TuiPluginModule
|