@cortexkit/opencode-magic-context 0.24.0 → 0.24.1
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/dist/features/magic-context/compartment-chunk-embedding.d.ts +10 -0
- package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts +14 -0
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts +10 -1
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/hooks/magic-context/apply-operations.d.ts +23 -0
- package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +7 -2
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.js +111 -67
- package/dist/shared/tui-preferences.d.ts +32 -0
- package/dist/shared/tui-preferences.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/shared/tui-preferences.test.ts +210 -0
- package/src/shared/tui-preferences.ts +303 -0
- package/src/tui/slots/sidebar-content.tsx +99 -10
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { readFileSync, watch } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { parse, stringify } from "comment-json";
|
|
6
|
+
|
|
7
|
+
// Shared preferences file for OpenCode TUI plugins. One top-level key per plugin
|
|
8
|
+
// (short, non-integer-like name, e.g. "magic-context"). The file is OPTIONAL:
|
|
9
|
+
// every reader falls back to defaults when it is missing or malformed.
|
|
10
|
+
//
|
|
11
|
+
// Cross-plugin convention (anthropic-auth / aft / magic-context all mirror it):
|
|
12
|
+
// - same file name + env override + lookup order,
|
|
13
|
+
// - byte-identical `computeEffectiveOrder` so the three sort consistently,
|
|
14
|
+
// - a coordinated default-order ladder (anthropic-auth 160, AFT 180, MC 200).
|
|
15
|
+
//
|
|
16
|
+
// MC uses `comment-json` (already a dep, Bun-safe) for the WRITE path — a full
|
|
17
|
+
// parse → mutate-one-key → stringify round-trip that preserves comments and
|
|
18
|
+
// sibling plugins' keys. (anthropic-auth uses jsonc-parser's surgical `modify`;
|
|
19
|
+
// AFT and MC use comment-json. Both are interop-safe as long as a sibling key's
|
|
20
|
+
// values AND comments survive — asserted by the interop test.)
|
|
21
|
+
|
|
22
|
+
export const TUI_PREFS_FILE_ENV = "OPENCODE_TUI_PREFERENCES_FILE";
|
|
23
|
+
const FILE_NAME = "tui-preferences.jsonc";
|
|
24
|
+
|
|
25
|
+
export function getTuiPreferencesFile(): string {
|
|
26
|
+
const override = process.env[TUI_PREFS_FILE_ENV];
|
|
27
|
+
if (override) return override;
|
|
28
|
+
const configDir =
|
|
29
|
+
process.env.OPENCODE_CONFIG_DIR ||
|
|
30
|
+
join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
|
|
31
|
+
return join(configDir, FILE_NAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Tolerant read: a missing file, parse error, or non-object root all resolve to
|
|
39
|
+
// {} so the sidebar never crashes on hand-edited content. Never throws.
|
|
40
|
+
export async function readTuiPreferencesFile(): Promise<Record<string, unknown>> {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(getTuiPreferencesFile(), "utf8");
|
|
43
|
+
if (raw.trim() === "") return {};
|
|
44
|
+
const root: unknown = parse(raw);
|
|
45
|
+
return isRecord(root) ? (root as Record<string, unknown>) : {};
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Synchronous tolerant read — used once at slot mount to seed the initial
|
|
52
|
+
// collapse state and effective order WITHOUT a frame of async flicker (the
|
|
53
|
+
// sidebar must render at its final width/collapse on the very first paint).
|
|
54
|
+
// Same tolerance contract as the async reader. Never throws.
|
|
55
|
+
export function readTuiPreferencesFileSync(): Record<string, unknown> {
|
|
56
|
+
try {
|
|
57
|
+
const raw = readFileSync(getTuiPreferencesFile(), "utf8");
|
|
58
|
+
if (raw.trim() === "") return {};
|
|
59
|
+
const root: unknown = parse(raw);
|
|
60
|
+
return isRecord(root) ? (root as Record<string, unknown>) : {};
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const PLUGIN_KEY = "magic-context";
|
|
67
|
+
export const DEFAULT_SLOT_ORDER = 200;
|
|
68
|
+
|
|
69
|
+
export interface MagicContextTuiPrefs {
|
|
70
|
+
forceToTop: boolean;
|
|
71
|
+
order: number;
|
|
72
|
+
startCollapsed: boolean;
|
|
73
|
+
rememberCollapsed: boolean;
|
|
74
|
+
// null = never persisted; seed the UI from `startCollapsed` instead.
|
|
75
|
+
collapsed: boolean | null;
|
|
76
|
+
header: {
|
|
77
|
+
label: string;
|
|
78
|
+
};
|
|
79
|
+
sections: {
|
|
80
|
+
historian: boolean;
|
|
81
|
+
memory: boolean;
|
|
82
|
+
status: boolean;
|
|
83
|
+
dreamer: boolean;
|
|
84
|
+
stats: boolean;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type TuiSections = MagicContextTuiPrefs["sections"];
|
|
89
|
+
|
|
90
|
+
export const DEFAULT_PREFS: MagicContextTuiPrefs = {
|
|
91
|
+
forceToTop: false,
|
|
92
|
+
order: DEFAULT_SLOT_ORDER,
|
|
93
|
+
startCollapsed: false,
|
|
94
|
+
rememberCollapsed: true,
|
|
95
|
+
collapsed: null,
|
|
96
|
+
header: { label: "Magic Context" },
|
|
97
|
+
sections: {
|
|
98
|
+
historian: true,
|
|
99
|
+
memory: true,
|
|
100
|
+
status: true,
|
|
101
|
+
dreamer: true,
|
|
102
|
+
stats: true,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function bool(value: unknown, fallback: boolean): boolean {
|
|
107
|
+
return typeof value === "boolean" ? value : fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function int(value: unknown, fallback: number, min: number, max: number): number {
|
|
111
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
112
|
+
return Math.min(Math.max(Math.round(value), min), max);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function label(value: unknown, fallback: string, maxLength: number): string {
|
|
116
|
+
if (typeof value !== "string" || value.length === 0) return fallback;
|
|
117
|
+
return value.slice(0, maxLength);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Per-key validation: every value is independently clamped/defaulted so one bad
|
|
121
|
+
// entry never poisons the rest. Never throws. A missing/non-object MC key →
|
|
122
|
+
// full defaults clone.
|
|
123
|
+
export function resolveMagicContextPrefs(root: Record<string, unknown>): MagicContextTuiPrefs {
|
|
124
|
+
const entry = root[PLUGIN_KEY];
|
|
125
|
+
if (!isRecord(entry)) return structuredClone(DEFAULT_PREFS);
|
|
126
|
+
|
|
127
|
+
const d = DEFAULT_PREFS;
|
|
128
|
+
const header = isRecord(entry.header) ? entry.header : {};
|
|
129
|
+
const sections = isRecord(entry.sections) ? entry.sections : {};
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
forceToTop: bool(entry.forceToTop, d.forceToTop),
|
|
133
|
+
order: int(entry.order, d.order, -10000, 10000),
|
|
134
|
+
startCollapsed: bool(entry.startCollapsed, d.startCollapsed),
|
|
135
|
+
rememberCollapsed: bool(entry.rememberCollapsed, d.rememberCollapsed),
|
|
136
|
+
collapsed: typeof entry.collapsed === "boolean" ? entry.collapsed : null,
|
|
137
|
+
header: {
|
|
138
|
+
label: label(header.label, d.header.label, 24),
|
|
139
|
+
},
|
|
140
|
+
sections: {
|
|
141
|
+
historian: bool(sections.historian, d.sections.historian),
|
|
142
|
+
memory: bool(sections.memory, d.sections.memory),
|
|
143
|
+
status: bool(sections.status, d.sections.status),
|
|
144
|
+
dreamer: bool(sections.dreamer, d.sections.dreamer),
|
|
145
|
+
stats: bool(sections.stats, d.sections.stats),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const FORCE_TOP_BASE = -100000;
|
|
151
|
+
|
|
152
|
+
// Shared forceToTop convention — MUST stay byte-identical across anthropic-auth,
|
|
153
|
+
// AFT, and magic-context or the three sort inconsistently against each other.
|
|
154
|
+
// Forced plugins sort below FORCE_TOP_BASE, ordered among themselves by their
|
|
155
|
+
// top-level key's position in the file, so users reprioritize by reordering
|
|
156
|
+
// keys. The user-facing `order` knob clamps to -10000..10000, strictly above the
|
|
157
|
+
// forced band, so a manual order can never beat forceToTop. Host slots render
|
|
158
|
+
// ascending by order; OpenCode's built-ins occupy 100-500.
|
|
159
|
+
//
|
|
160
|
+
// Key-naming requirement: plugin keys must be non-integer-like short names (e.g.
|
|
161
|
+
// "magic-context"). JS object key iteration hoists integer-like keys ("0", "42")
|
|
162
|
+
// ahead of string keys, which would skew the indexOf-based ordering of forced
|
|
163
|
+
// plugins. The shared convention requires non-numeric names.
|
|
164
|
+
export function computeEffectiveOrder(
|
|
165
|
+
root: Record<string, unknown>,
|
|
166
|
+
pluginKey: string,
|
|
167
|
+
defaultOrder: number,
|
|
168
|
+
): number {
|
|
169
|
+
const entry = root[pluginKey];
|
|
170
|
+
if (!isRecord(entry)) return defaultOrder;
|
|
171
|
+
if (entry.forceToTop === true) {
|
|
172
|
+
return FORCE_TOP_BASE + Object.keys(root).indexOf(pluginKey);
|
|
173
|
+
}
|
|
174
|
+
return int(entry.order, defaultOrder, -10000, 10000);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const TEMPLATE = `// Shared preferences for OpenCode TUI plugins.
|
|
178
|
+
// One top-level key per plugin (short name). See each plugin's README for its
|
|
179
|
+
// supported settings. This file is safe to hand-edit; plugins update individual
|
|
180
|
+
// keys and preserve the rest (values and comments).
|
|
181
|
+
{}
|
|
182
|
+
`;
|
|
183
|
+
|
|
184
|
+
type JsonValue = string | number | boolean | null;
|
|
185
|
+
|
|
186
|
+
// Set a nested path on a comment-json root, creating intermediate plain objects
|
|
187
|
+
// as needed. Mutating an existing leaf preserves its comments; sibling keys are
|
|
188
|
+
// untouched. Returns false when the path is blocked by a non-object value.
|
|
189
|
+
function setDeep(root: Record<string, unknown>, path: string[], value: JsonValue): boolean {
|
|
190
|
+
let node: Record<string, unknown> = root;
|
|
191
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
192
|
+
const key = path[i];
|
|
193
|
+
const child = node[key];
|
|
194
|
+
if (child === undefined || child === null) {
|
|
195
|
+
node[key] = {};
|
|
196
|
+
} else if (!isRecord(child)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
node = node[key] as Record<string, unknown>;
|
|
200
|
+
}
|
|
201
|
+
node[path[path.length - 1]] = value;
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function writePreference(pluginKey: string, path: string[], value: JsonValue): Promise<void> {
|
|
206
|
+
const file = getTuiPreferencesFile();
|
|
207
|
+
await mkdir(dirname(file), { recursive: true });
|
|
208
|
+
let text: string;
|
|
209
|
+
try {
|
|
210
|
+
text = await readFile(file, "utf8");
|
|
211
|
+
} catch {
|
|
212
|
+
text = "";
|
|
213
|
+
}
|
|
214
|
+
if (text.trim() === "") text = TEMPLATE;
|
|
215
|
+
|
|
216
|
+
let root: unknown;
|
|
217
|
+
try {
|
|
218
|
+
root = parse(text);
|
|
219
|
+
} catch {
|
|
220
|
+
// The shared file is currently malformed. Skip the write rather than
|
|
221
|
+
// clobber sibling plugins' keys — the user fixes the file, persistence
|
|
222
|
+
// resumes. (Collapse just won't survive restart until then.)
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!isRecord(root)) root = {};
|
|
226
|
+
if (!setDeep(root as Record<string, unknown>, [pluginKey, ...path], value)) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const next = `${stringify(root, null, 2)}\n`;
|
|
231
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
232
|
+
await writeFile(tmp, next, "utf8");
|
|
233
|
+
await rename(tmp, file);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
237
|
+
|
|
238
|
+
// Writes are serialized on a promise chain: each update re-reads the file,
|
|
239
|
+
// applies a comment-preserving edit to one property, and replaces the file
|
|
240
|
+
// atomically (temp + rename in the same directory — the only safe cross-process
|
|
241
|
+
// swap). Best-effort by design; preferences are never worth crashing the TUI.
|
|
242
|
+
export function queueTuiPreferenceUpdate(
|
|
243
|
+
pluginKey: string,
|
|
244
|
+
path: string[],
|
|
245
|
+
value: JsonValue,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
writeChain = writeChain.then(() => writePreference(pluginKey, path, value)).catch(() => {});
|
|
248
|
+
return writeChain;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const WATCH_DEBOUNCE_MS = 150;
|
|
252
|
+
|
|
253
|
+
// Watches the DIRECTORY, not the file: editors and our own atomic writes replace
|
|
254
|
+
// the file via rename, which kills file-level watchers.
|
|
255
|
+
//
|
|
256
|
+
// Two-stage filtering: (1) a cheap filename pre-filter on the prefs name or our
|
|
257
|
+
// `.tmp`; (2) inside the debounce, re-read and compare against last-seen content
|
|
258
|
+
// — the authority. Some platforms (macOS FSEvents, some inotify backends)
|
|
259
|
+
// misattribute a sibling rename to the real filename, so a name filter alone
|
|
260
|
+
// still produces strays; the content compare is robust against that, coalesced
|
|
261
|
+
// events, and mtime granularity.
|
|
262
|
+
//
|
|
263
|
+
// Returns a disposer; never throws.
|
|
264
|
+
export function watchTuiPreferences(onChange: () => void): () => void {
|
|
265
|
+
const file = getTuiPreferencesFile();
|
|
266
|
+
const name = basename(file);
|
|
267
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
268
|
+
let lastSeen: string | null = null;
|
|
269
|
+
// Seed asynchronously; a real change before the seed resolves still wins
|
|
270
|
+
// because the debounce re-reads fresh and compares against `lastSeen` (null
|
|
271
|
+
// → does not match → fires).
|
|
272
|
+
void readFile(file, "utf8")
|
|
273
|
+
.then((text) => {
|
|
274
|
+
if (lastSeen === null) lastSeen = text;
|
|
275
|
+
})
|
|
276
|
+
.catch(() => {});
|
|
277
|
+
try {
|
|
278
|
+
const watcher = watch(dirname(file), (_event, filename) => {
|
|
279
|
+
const isOurs =
|
|
280
|
+
filename === name ||
|
|
281
|
+
(filename?.startsWith(`${name}.`) && filename.endsWith(".tmp"));
|
|
282
|
+
if (filename != null && !isOurs) return;
|
|
283
|
+
if (timer) clearTimeout(timer);
|
|
284
|
+
timer = setTimeout(() => {
|
|
285
|
+
timer = null;
|
|
286
|
+
void readFile(file, "utf8")
|
|
287
|
+
.catch(() => null)
|
|
288
|
+
.then((text) => {
|
|
289
|
+
if (text === null) return;
|
|
290
|
+
if (text === lastSeen) return;
|
|
291
|
+
lastSeen = text;
|
|
292
|
+
onChange();
|
|
293
|
+
});
|
|
294
|
+
}, WATCH_DEBOUNCE_MS);
|
|
295
|
+
});
|
|
296
|
+
return () => {
|
|
297
|
+
if (timer) clearTimeout(timer);
|
|
298
|
+
watcher.close();
|
|
299
|
+
};
|
|
300
|
+
} catch {
|
|
301
|
+
return () => {};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -4,6 +4,17 @@ import type { TuiSlotPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/
|
|
|
4
4
|
import packageJson from "../../../package.json"
|
|
5
5
|
import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db"
|
|
6
6
|
import { formatThresholdPercent } from "../../shared/format-threshold"
|
|
7
|
+
import {
|
|
8
|
+
computeEffectiveOrder,
|
|
9
|
+
DEFAULT_SLOT_ORDER,
|
|
10
|
+
type MagicContextTuiPrefs,
|
|
11
|
+
PLUGIN_KEY,
|
|
12
|
+
queueTuiPreferenceUpdate,
|
|
13
|
+
readTuiPreferencesFile,
|
|
14
|
+
readTuiPreferencesFileSync,
|
|
15
|
+
resolveMagicContextPrefs,
|
|
16
|
+
watchTuiPreferences,
|
|
17
|
+
} from "../../shared/tui-preferences"
|
|
7
18
|
|
|
8
19
|
// Module-level hook so the upgrade/recomp dialog can kick the sidebar into its
|
|
9
20
|
// fast recomp self-poll the INSTANT the user confirms — without waiting for a
|
|
@@ -17,6 +28,64 @@ export function kickRecompProgressRefresh(): void {
|
|
|
17
28
|
const SINGLE_BORDER = { type: "single" } as any
|
|
18
29
|
const REFRESH_DEBOUNCE_MS = 150
|
|
19
30
|
|
|
31
|
+
export interface SidebarController {
|
|
32
|
+
prefs: () => MagicContextTuiPrefs
|
|
33
|
+
collapsed: () => boolean
|
|
34
|
+
toggleCollapsed: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The TUI may unmount and remount sidebar_content when the user switches views
|
|
38
|
+
// (main -> subagent -> main). A remount re-runs the component body, so a signal
|
|
39
|
+
// created inside the component would reset to its seed. The controller lives in
|
|
40
|
+
// the slot-factory closure (plugin/process lifetime) and owns the durable
|
|
41
|
+
// prefs/collapse signals plus the single shared file watcher, so collapse state
|
|
42
|
+
// and live pref reloads survive remounts. No Solid effects/memos here — those
|
|
43
|
+
// need an owner; the poll-interval effect stays inside the component.
|
|
44
|
+
function createSidebarController(initialPrefs: MagicContextTuiPrefs): SidebarController {
|
|
45
|
+
const [prefs, setPrefs] = createSignal<MagicContextTuiPrefs>(initialPrefs)
|
|
46
|
+
const seedCollapsed =
|
|
47
|
+
initialPrefs.rememberCollapsed && initialPrefs.collapsed != null
|
|
48
|
+
? initialPrefs.collapsed
|
|
49
|
+
: initialPrefs.startCollapsed
|
|
50
|
+
const [collapsed, setCollapsed] = createSignal(seedCollapsed)
|
|
51
|
+
let lastPersistedCollapsed: boolean | null = initialPrefs.collapsed
|
|
52
|
+
let lastApplied = JSON.stringify(initialPrefs)
|
|
53
|
+
|
|
54
|
+
// Watcher lives for the process lifetime — intentionally never disposed.
|
|
55
|
+
// Collapse echo guard: lastPersistedCollapsed advances only once our own
|
|
56
|
+
// write lands, so a watcher echo of the value we just wrote is rejected by
|
|
57
|
+
// the `!==` check and cannot revert a user click.
|
|
58
|
+
watchTuiPreferences(() => {
|
|
59
|
+
void (async () => {
|
|
60
|
+
const next = resolveMagicContextPrefs(await readTuiPreferencesFile())
|
|
61
|
+
const serialized = JSON.stringify(next)
|
|
62
|
+
if (serialized === lastApplied) return
|
|
63
|
+
lastApplied = serialized
|
|
64
|
+
setPrefs(next)
|
|
65
|
+
if (
|
|
66
|
+
next.rememberCollapsed &&
|
|
67
|
+
next.collapsed != null &&
|
|
68
|
+
next.collapsed !== lastPersistedCollapsed
|
|
69
|
+
) {
|
|
70
|
+
lastPersistedCollapsed = next.collapsed
|
|
71
|
+
setCollapsed(next.collapsed)
|
|
72
|
+
}
|
|
73
|
+
})()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
function toggleCollapsed() {
|
|
77
|
+
const next = !collapsed()
|
|
78
|
+
setCollapsed(next)
|
|
79
|
+
if (prefs().rememberCollapsed) {
|
|
80
|
+
void queueTuiPreferenceUpdate(PLUGIN_KEY, ["collapsed"], next).then(() => {
|
|
81
|
+
lastPersistedCollapsed = next
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { prefs, collapsed, toggleCollapsed }
|
|
87
|
+
}
|
|
88
|
+
|
|
20
89
|
function compactTokens(value: number): string {
|
|
21
90
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
|
22
91
|
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`
|
|
@@ -382,12 +451,15 @@ const SidebarContent = (props: {
|
|
|
382
451
|
api: TuiPluginApi
|
|
383
452
|
sessionID: () => string
|
|
384
453
|
theme: TuiThemeCurrent
|
|
454
|
+
controller: SidebarController
|
|
385
455
|
}) => {
|
|
386
456
|
const [snapshot, setSnapshot] = createSignal<SidebarSnapshot | null>(null)
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
const
|
|
457
|
+
// Collapse state + section visibility prefs live in the controller (plugin
|
|
458
|
+
// closure), so they survive view-switch remounts and persist across restarts
|
|
459
|
+
// via ~/.config/opencode/tui-preferences.jsonc. Read reactively.
|
|
460
|
+
const collapsed = props.controller.collapsed
|
|
461
|
+
const sections = () => props.controller.prefs().sections
|
|
462
|
+
const headerLabel = () => props.controller.prefs().header.label
|
|
391
463
|
let refreshTimer: ReturnType<typeof setTimeout> | undefined
|
|
392
464
|
// Self-sustaining poll while a recomp/upgrade is running. Recomp work
|
|
393
465
|
// happens in CHILD sessions whose message events are filtered out of the
|
|
@@ -610,11 +682,11 @@ const SidebarContent = (props: {
|
|
|
610
682
|
flexDirection="row"
|
|
611
683
|
justifyContent="space-between"
|
|
612
684
|
alignItems="center"
|
|
613
|
-
onMouseDown={() =>
|
|
685
|
+
onMouseDown={() => props.controller.toggleCollapsed()}
|
|
614
686
|
>
|
|
615
687
|
<box paddingLeft={1} paddingRight={1} backgroundColor={props.theme.accent}>
|
|
616
688
|
<text fg={props.theme.background}>
|
|
617
|
-
<b>{collapsed() ? "▶ " : "▼ "}
|
|
689
|
+
<b>{collapsed() ? "▶ " : "▼ "}{headerLabel()}</b>
|
|
618
690
|
</text>
|
|
619
691
|
</box>
|
|
620
692
|
<text fg={props.theme.textMuted}>v{packageJson.version}</text>
|
|
@@ -688,6 +760,8 @@ const SidebarContent = (props: {
|
|
|
688
760
|
{!collapsed() && (
|
|
689
761
|
<>
|
|
690
762
|
{/* Historian section */}
|
|
763
|
+
{sections().historian && (
|
|
764
|
+
<>
|
|
691
765
|
<box width="100%" marginTop={1} flexDirection="row" justifyContent="space-between">
|
|
692
766
|
<text fg={props.theme.text}>
|
|
693
767
|
<b>Historian</b>
|
|
@@ -713,8 +787,12 @@ const SidebarContent = (props: {
|
|
|
713
787
|
{s()?.recompProgress && (
|
|
714
788
|
<RecompProgressSection theme={props.theme} progress={s()!.recompProgress!} />
|
|
715
789
|
)}
|
|
790
|
+
</>
|
|
791
|
+
)}
|
|
716
792
|
|
|
717
793
|
{/* Memory section */}
|
|
794
|
+
{sections().memory && (
|
|
795
|
+
<>
|
|
718
796
|
<SectionHeader theme={props.theme} title="Memory" />
|
|
719
797
|
<StatRow
|
|
720
798
|
theme={props.theme}
|
|
@@ -730,9 +808,12 @@ const SidebarContent = (props: {
|
|
|
730
808
|
dim
|
|
731
809
|
/>
|
|
732
810
|
)}
|
|
811
|
+
</>
|
|
812
|
+
)}
|
|
733
813
|
|
|
734
814
|
{/* Queue & Status */}
|
|
735
|
-
{(
|
|
815
|
+
{sections().status &&
|
|
816
|
+
((s()?.pendingOpsCount ?? 0) > 0 ||
|
|
736
817
|
(s()?.sessionNoteCount ?? 0) > 0 ||
|
|
737
818
|
(s()?.readySmartNoteCount ?? 0) > 0) && (
|
|
738
819
|
<>
|
|
@@ -764,7 +845,7 @@ const SidebarContent = (props: {
|
|
|
764
845
|
)}
|
|
765
846
|
|
|
766
847
|
{/* Dreamer */}
|
|
767
|
-
{s()?.lastDreamerRunAt && (
|
|
848
|
+
{sections().dreamer && s()?.lastDreamerRunAt && (
|
|
768
849
|
<>
|
|
769
850
|
<SectionHeader theme={props.theme} title="Dreamer" />
|
|
770
851
|
<StatRow
|
|
@@ -782,7 +863,7 @@ const SidebarContent = (props: {
|
|
|
782
863
|
snapshot fields (newWorkTokens, totalInputTokens) and the
|
|
783
864
|
session_meta columns are still populated; only the UI is
|
|
784
865
|
simplified for now. */}
|
|
785
|
-
{s()?.totalInputTokens != null && (
|
|
866
|
+
{sections().stats && s()?.totalInputTokens != null && (
|
|
786
867
|
<>
|
|
787
868
|
<SectionHeader theme={props.theme} title="Stats" />
|
|
788
869
|
<StatRow
|
|
@@ -800,8 +881,15 @@ const SidebarContent = (props: {
|
|
|
800
881
|
}
|
|
801
882
|
|
|
802
883
|
export function createSidebarContentSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
884
|
+
// Seed synchronously at slot construction so the sidebar renders at its
|
|
885
|
+
// final collapse state + order on the first paint (no async flicker). The
|
|
886
|
+
// controller lives here in the factory closure for the plugin lifetime, so
|
|
887
|
+
// collapse state and live pref reloads survive sidebar_content remounts.
|
|
888
|
+
const seedRoot = readTuiPreferencesFileSync()
|
|
889
|
+
const controller = createSidebarController(resolveMagicContextPrefs(seedRoot))
|
|
890
|
+
const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER)
|
|
803
891
|
return {
|
|
804
|
-
order:
|
|
892
|
+
order: effectiveOrder,
|
|
805
893
|
slots: {
|
|
806
894
|
sidebar_content: (ctx, value) => {
|
|
807
895
|
const theme = createMemo(() => ctx.theme.current)
|
|
@@ -810,6 +898,7 @@ export function createSidebarContentSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
|
810
898
|
api={api}
|
|
811
899
|
sessionID={() => value.session_id}
|
|
812
900
|
theme={theme()}
|
|
901
|
+
controller={controller}
|
|
813
902
|
/>
|
|
814
903
|
)
|
|
815
904
|
},
|