@cortexkit/aft-opencode 0.39.0 → 0.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/aft-opencode",
3
- "version": "0.39.0",
3
+ "version": "0.39.1",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Agent File Tools (AFT) — tree-sitter and lsp powered code analysis",
6
6
  "main": "dist/index.js",
@@ -30,19 +30,19 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.2.0",
33
- "@cortexkit/aft-bridge": "0.39.0",
33
+ "@cortexkit/aft-bridge": "0.39.1",
34
34
  "@xterm/headless": "^5.5.0",
35
35
  "comment-json": "^4.6.2",
36
36
  "undici": "^7.25.0",
37
37
  "zod": "^4.1.8"
38
38
  },
39
39
  "optionalDependencies": {
40
- "@cortexkit/aft-darwin-arm64": "0.39.0",
41
- "@cortexkit/aft-darwin-x64": "0.39.0",
42
- "@cortexkit/aft-linux-arm64": "0.39.0",
43
- "@cortexkit/aft-linux-x64": "0.39.0",
44
- "@cortexkit/aft-win32-arm64": "0.39.0",
45
- "@cortexkit/aft-win32-x64": "0.39.0"
40
+ "@cortexkit/aft-darwin-arm64": "0.39.1",
41
+ "@cortexkit/aft-darwin-x64": "0.39.1",
42
+ "@cortexkit/aft-linux-arm64": "0.39.1",
43
+ "@cortexkit/aft-linux-x64": "0.39.1",
44
+ "@cortexkit/aft-win32-arm64": "0.39.1",
45
+ "@cortexkit/aft-win32-x64": "0.39.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@opencode-ai/plugin": "^1.15.11",
package/src/tui/index.tsx CHANGED
@@ -727,7 +727,7 @@ const tui: TuiPlugin = async (api) => {
727
727
  // command palette entry so the sidebar is available immediately when the
728
728
  // user opens their first session.
729
729
  try {
730
- api.slots.register(createAftSidebarSlot(api, packageVersion));
730
+ api.slots.register(await createAftSidebarSlot(api, packageVersion));
731
731
  } catch {
732
732
  // Older OpenCode TUI hosts may not implement api.slots; fall through
733
733
  // and keep the slash command working.
@@ -0,0 +1,243 @@
1
+ import { 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
+ export const TUI_PREFS_FILE_ENV = "OPENCODE_TUI_PREFERENCES_FILE";
8
+ const FILE_NAME = "tui-preferences.jsonc";
9
+
10
+ export function getTuiPreferencesFile(): string {
11
+ const override = process.env[TUI_PREFS_FILE_ENV];
12
+ if (override) return override;
13
+ const configDir =
14
+ process.env.OPENCODE_CONFIG_DIR ||
15
+ join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
16
+ return join(configDir, FILE_NAME);
17
+ }
18
+
19
+ function isRecord(value: unknown): value is Record<string, unknown> {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+
23
+ export async function readTuiPreferencesFile(): Promise<Record<string, unknown>> {
24
+ try {
25
+ const raw = await readFile(getTuiPreferencesFile(), "utf8");
26
+ const root: unknown = parse(raw);
27
+ return isRecord(root) ? (root as Record<string, unknown>) : {};
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ export const PLUGIN_KEY = "aft";
34
+ export const DEFAULT_SLOT_ORDER = 180;
35
+
36
+ export interface AftTuiPrefs {
37
+ forceToTop: boolean;
38
+ order: number;
39
+ startCollapsed: boolean;
40
+ rememberCollapsed: boolean;
41
+ collapsed: boolean | null;
42
+ header: {
43
+ label: string;
44
+ showVersion: boolean;
45
+ };
46
+ sections: {
47
+ searchIndex: boolean;
48
+ semanticIndex: boolean;
49
+ codeHealth: boolean;
50
+ compression: boolean;
51
+ };
52
+ }
53
+
54
+ export const DEFAULT_PREFS: AftTuiPrefs = {
55
+ forceToTop: false,
56
+ order: DEFAULT_SLOT_ORDER,
57
+ startCollapsed: false,
58
+ rememberCollapsed: true,
59
+ collapsed: null,
60
+ header: { label: "AFT", showVersion: true },
61
+ sections: {
62
+ searchIndex: true,
63
+ semanticIndex: true,
64
+ codeHealth: true,
65
+ compression: true,
66
+ },
67
+ };
68
+
69
+ function bool(value: unknown, fallback: boolean): boolean {
70
+ return typeof value === "boolean" ? value : fallback;
71
+ }
72
+
73
+ function int(value: unknown, fallback: number, min: number, max: number): number {
74
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
75
+ return Math.min(Math.max(Math.round(value), min), max);
76
+ }
77
+
78
+ function label(value: unknown, fallback: string, maxLength: number): string {
79
+ if (typeof value !== "string" || value.length === 0) return fallback;
80
+ return value.slice(0, maxLength);
81
+ }
82
+
83
+ /** Initial collapsed state from persisted prefs or startCollapsed default. */
84
+ export function seedCollapsedFromPrefs(prefs: AftTuiPrefs): boolean {
85
+ return prefs.collapsed ?? prefs.startCollapsed;
86
+ }
87
+
88
+ export function resolveAftPrefs(root: Record<string, unknown>): AftTuiPrefs {
89
+ const entry = root[PLUGIN_KEY];
90
+ if (!isRecord(entry)) return structuredClone(DEFAULT_PREFS);
91
+
92
+ const d = DEFAULT_PREFS;
93
+ const header = isRecord(entry.header) ? entry.header : {};
94
+ const sections = isRecord(entry.sections) ? entry.sections : {};
95
+
96
+ return {
97
+ forceToTop: bool(entry.forceToTop, d.forceToTop),
98
+ order: int(entry.order, d.order, -10000, 10000),
99
+ startCollapsed: bool(entry.startCollapsed, d.startCollapsed),
100
+ rememberCollapsed: bool(entry.rememberCollapsed, d.rememberCollapsed),
101
+ collapsed: typeof entry.collapsed === "boolean" ? entry.collapsed : null,
102
+ header: {
103
+ label: label(header.label, d.header.label, 20),
104
+ showVersion: bool(header.showVersion, d.header.showVersion),
105
+ },
106
+ sections: {
107
+ searchIndex: bool(sections.searchIndex, d.sections.searchIndex),
108
+ semanticIndex: bool(sections.semanticIndex, d.sections.semanticIndex),
109
+ codeHealth: bool(sections.codeHealth, d.sections.codeHealth),
110
+ compression: bool(sections.compression, d.sections.compression),
111
+ },
112
+ };
113
+ }
114
+
115
+ const FORCE_TOP_BASE = -100000;
116
+
117
+ export function computeEffectiveOrder(
118
+ root: Record<string, unknown>,
119
+ pluginKey: string,
120
+ defaultOrder: number,
121
+ ): number {
122
+ const entry = root[pluginKey];
123
+ if (!isRecord(entry)) return defaultOrder;
124
+ if (entry.forceToTop === true) {
125
+ return FORCE_TOP_BASE + Object.keys(root).indexOf(pluginKey);
126
+ }
127
+ return int(entry.order, defaultOrder, -10000, 10000);
128
+ }
129
+
130
+ const TEMPLATE = `// Shared preferences for opencode TUI plugins.
131
+ // One top-level key per plugin (short name). See each plugin's README for
132
+ // its supported settings. This file is safe to hand-edit; plugins update
133
+ // individual keys in place and preserve comments.
134
+ {}
135
+ `;
136
+
137
+ type JsonValue = string | number | boolean | null;
138
+
139
+ function setNested(target: Record<string, unknown>, path: string[], value: JsonValue): void {
140
+ let current: Record<string, unknown> = target;
141
+ for (let i = 0; i < path.length - 1; i++) {
142
+ const key = path[i];
143
+ if (key === undefined) return;
144
+ const next = current[key];
145
+ if (!isRecord(next)) {
146
+ const created: Record<string, unknown> = {};
147
+ current[key] = created;
148
+ current = created;
149
+ } else {
150
+ current = next;
151
+ }
152
+ }
153
+ const leaf = path[path.length - 1];
154
+ if (leaf === undefined) return;
155
+ current[leaf] = value;
156
+ }
157
+
158
+ async function writePreference(pluginKey: string, path: string[], value: JsonValue): Promise<void> {
159
+ const file = getTuiPreferencesFile();
160
+ await mkdir(dirname(file), { recursive: true });
161
+ let text: string;
162
+ try {
163
+ text = await readFile(file, "utf8");
164
+ } catch {
165
+ text = "";
166
+ }
167
+ if (text.trim() === "") text = TEMPLATE;
168
+
169
+ let root: Record<string, unknown>;
170
+ try {
171
+ const parsed = parse(text);
172
+ root = isRecord(parsed) ? (parsed as Record<string, unknown>) : {};
173
+ } catch {
174
+ root = {};
175
+ }
176
+
177
+ if (!isRecord(root[pluginKey])) {
178
+ root[pluginKey] = {};
179
+ }
180
+ const entry = root[pluginKey] as Record<string, unknown>;
181
+ setNested(entry, path, value);
182
+
183
+ const next = `${stringify(root, null, 2)}\n`;
184
+ const tmp = `${file}.${process.pid}.tmp`;
185
+ await writeFile(tmp, next, "utf8");
186
+ await rename(tmp, file);
187
+ }
188
+
189
+ let writeChain: Promise<void> = Promise.resolve();
190
+
191
+ export function queueTuiPreferenceUpdate(
192
+ pluginKey: string,
193
+ path: string[],
194
+ value: JsonValue,
195
+ ): Promise<void> {
196
+ writeChain = writeChain.then(() => writePreference(pluginKey, path, value)).catch(() => {});
197
+ return writeChain;
198
+ }
199
+
200
+ const WATCH_DEBOUNCE_MS = 150;
201
+
202
+ export function watchTuiPreferences(onChange: () => void): () => void {
203
+ const file = getTuiPreferencesFile();
204
+ const name = basename(file);
205
+ let timer: ReturnType<typeof setTimeout> | null = null;
206
+ let lastSeen: string | null = null;
207
+ void readFile(file, "utf8")
208
+ .then((text) => {
209
+ if (lastSeen === null) lastSeen = text;
210
+ })
211
+ .catch(() => {});
212
+ try {
213
+ const watcher = watch(dirname(file), (_event, filename) => {
214
+ const isOurs =
215
+ filename === name || (filename?.startsWith(`${name}.`) && filename.endsWith(".tmp"));
216
+ if (filename != null && !isOurs) return;
217
+ if (timer) clearTimeout(timer);
218
+ timer = setTimeout(() => {
219
+ timer = null;
220
+ void readFile(file, "utf8")
221
+ .catch(() => null)
222
+ .then((text) => {
223
+ if (text === null) return;
224
+ if (text === lastSeen) return;
225
+ lastSeen = text;
226
+ onChange();
227
+ });
228
+ }, WATCH_DEBOUNCE_MS);
229
+ });
230
+ return () => {
231
+ if (timer) clearTimeout(timer);
232
+ watcher.close();
233
+ };
234
+ } catch {
235
+ return () => {};
236
+ }
237
+ }
238
+
239
+ export function persistCollapsedIfEnabled(prefs: AftTuiPrefs, collapsed: boolean): void {
240
+ if (prefs.rememberCollapsed) {
241
+ void queueTuiPreferenceUpdate(PLUGIN_KEY, ["collapsed"], collapsed);
242
+ }
243
+ }
@@ -20,6 +20,18 @@ import {
20
20
  type StatusCompression,
21
21
  } from "../shared/status";
22
22
  import { resolveCortexKitStorageRoot } from "../shared/storage-paths";
23
+ import {
24
+ type AftTuiPrefs,
25
+ computeEffectiveOrder,
26
+ DEFAULT_PREFS,
27
+ DEFAULT_SLOT_ORDER,
28
+ PLUGIN_KEY,
29
+ persistCollapsedIfEnabled,
30
+ readTuiPreferencesFile,
31
+ resolveAftPrefs,
32
+ seedCollapsedFromPrefs,
33
+ watchTuiPreferences,
34
+ } from "./preferences";
23
35
 
24
36
  const SINGLE_BORDER = { type: "single" } as any;
25
37
  const REFRESH_DEBOUNCE_MS = 200;
@@ -307,9 +319,8 @@ const SidebarContent = (props: {
307
319
  pluginVersion: string;
308
320
  }) => {
309
321
  const [status, setStatus] = createSignal<ScopedSidebarStatus | null>(null);
310
- // Collapsed/expanded toggle local UI state (not persisted), mirroring
311
- // OpenCode's native MCP sidebar section. Default expanded.
312
- const [collapsed, setCollapsed] = createSignal(false);
322
+ const [prefs, setPrefs] = createSignal<AftTuiPrefs>(structuredClone(DEFAULT_PREFS));
323
+ const [collapsed, setCollapsed] = createSignal(seedCollapsedFromPrefs(DEFAULT_PREFS));
313
324
  let inflight: {
314
325
  controller: AbortController;
315
326
  generation: number;
@@ -428,7 +439,21 @@ const SidebarContent = (props: {
428
439
  }, REFRESH_DEBOUNCE_MS);
429
440
  };
430
441
 
442
+ const reloadPrefs = async () => {
443
+ const root = await readTuiPreferencesFile();
444
+ const next = resolveAftPrefs(root);
445
+ setPrefs(next);
446
+ setCollapsed(seedCollapsedFromPrefs(next));
447
+ requestRender();
448
+ };
449
+
450
+ void reloadPrefs();
451
+ const unwatchPrefs = watchTuiPreferences(() => {
452
+ void reloadPrefs();
453
+ });
454
+
431
455
  onCleanup(() => {
456
+ unwatchPrefs();
432
457
  generation++;
433
458
  abortInflight();
434
459
  if (debounceTimer) clearTimeout(debounceTimer);
@@ -560,7 +585,12 @@ const SidebarContent = (props: {
560
585
  justifyContent="space-between"
561
586
  alignItems="center"
562
587
  onMouseDown={() => {
563
- if (!notInitialized()) setCollapsed((x) => !x);
588
+ if (notInitialized()) return;
589
+ setCollapsed((x) => {
590
+ const next = !x;
591
+ persistCollapsedIfEnabled(prefs(), next);
592
+ return next;
593
+ });
564
594
  }}
565
595
  >
566
596
  <box flexDirection="row" alignItems="center">
@@ -570,7 +600,7 @@ const SidebarContent = (props: {
570
600
  <text fg={props.theme.background}>
571
601
  <b>
572
602
  {notInitialized() ? "" : collapsed() ? "▶ " : "▼ "}
573
- AFT
603
+ {prefs().header.label}
574
604
  </b>
575
605
  </text>
576
606
  </box>
@@ -587,7 +617,7 @@ const SidebarContent = (props: {
587
617
  </box>
588
618
  )}
589
619
  </box>
590
- {!notInitialized() && (
620
+ {!notInitialized() && prefs().header.showVersion && (
591
621
  <text fg={props.theme.textMuted}>v{s()?.version ?? props.pluginVersion}</text>
592
622
  )}
593
623
  </box>
@@ -622,13 +652,17 @@ const SidebarContent = (props: {
622
652
  order of the expanded grid. */}
623
653
  {!notInitialized() && collapsed() && (
624
654
  <box width="100%" flexDirection="column">
625
- <CollapsedRow theme={props.theme} label="Search Index">
626
- <text fg={toneColor(props.theme, searchStatus().tone)}>●</text>
627
- </CollapsedRow>
628
- <CollapsedRow theme={props.theme} label="Semantic Index">
629
- <text fg={toneColor(props.theme, semanticStatus().tone)}>●</text>
630
- </CollapsedRow>
631
- {collapsedHealthLights(statusBar()) && (
655
+ {prefs().sections.searchIndex && (
656
+ <CollapsedRow theme={props.theme} label="Search Index">
657
+ <text fg={toneColor(props.theme, searchStatus().tone)}>●</text>
658
+ </CollapsedRow>
659
+ )}
660
+ {prefs().sections.semanticIndex && (
661
+ <CollapsedRow theme={props.theme} label="Semantic Index">
662
+ <text fg={toneColor(props.theme, semanticStatus().tone)}>●</text>
663
+ </CollapsedRow>
664
+ )}
665
+ {prefs().sections.codeHealth && collapsedHealthLights(statusBar()) && (
632
666
  <CollapsedRow theme={props.theme} label="Code Health">
633
667
  <box flexDirection="row" gap={1}>
634
668
  <text fg={toneColor(props.theme, collapsedHealthLights(statusBar())!.diagnostics)}>
@@ -641,7 +675,7 @@ const SidebarContent = (props: {
641
675
  </box>
642
676
  </CollapsedRow>
643
677
  )}
644
- {collapsedCompressionValue(s()?.compression) && (
678
+ {prefs().sections.compression && collapsedCompressionValue(s()?.compression) && (
645
679
  <CollapsedRow theme={props.theme} label="Compression">
646
680
  <text fg={props.theme.textMuted}>
647
681
  <b>{collapsedCompressionValue(s()?.compression)}</b>
@@ -654,69 +688,76 @@ const SidebarContent = (props: {
654
688
  {/* Search index */}
655
689
  {!notInitialized() && !collapsed() && (
656
690
  <>
657
- <SectionHeader theme={props.theme} title="Search Index" />
658
- <StatRow
659
- theme={props.theme}
660
- label="Status"
661
- value={searchStatus().label}
662
- tone={searchStatus().tone}
663
- />
664
- {(s()?.search_index?.files ?? null) != null && (
665
- <StatRow
666
- theme={props.theme}
667
- label="Files"
668
- value={formatCount(s()!.search_index.files)}
669
- tone="muted"
670
- />
691
+ {prefs().sections.searchIndex && (
692
+ <>
693
+ <SectionHeader theme={props.theme} title="Search Index" />
694
+ <StatRow
695
+ theme={props.theme}
696
+ label="Status"
697
+ value={searchStatus().label}
698
+ tone={searchStatus().tone}
699
+ />
700
+ {(s()?.search_index?.files ?? null) != null && (
701
+ <StatRow
702
+ theme={props.theme}
703
+ label="Files"
704
+ value={formatCount(s()!.search_index.files)}
705
+ tone="muted"
706
+ />
707
+ )}
708
+ <StatRow
709
+ theme={props.theme}
710
+ label="Disk"
711
+ value={formatBytes(trigramBytes())}
712
+ tone="muted"
713
+ />
714
+ </>
671
715
  )}
672
- <StatRow
673
- theme={props.theme}
674
- label="Disk"
675
- value={formatBytes(trigramBytes())}
676
- tone="muted"
677
- />
678
716
 
679
- {/* Semantic index */}
680
- <SectionHeader theme={props.theme} title="Semantic Index" />
681
- <StatRow
682
- theme={props.theme}
683
- label="Status"
684
- value={semanticStatus().label}
685
- tone={semanticStatus().tone}
686
- />
687
- {semanticRefreshing() && (
688
- <box width="100%">
689
- <text fg={props.theme.textMuted}>{semanticRefreshing()}</text>
690
- </box>
691
- )}
692
- {/* When loading, magic-context-style progress hint helps users see
717
+ {prefs().sections.semanticIndex && (
718
+ <>
719
+ <SectionHeader theme={props.theme} title="Semantic Index" />
720
+ <StatRow
721
+ theme={props.theme}
722
+ label="Status"
723
+ value={semanticStatus().label}
724
+ tone={semanticStatus().tone}
725
+ />
726
+ {semanticRefreshing() && (
727
+ <box width="100%">
728
+ <text fg={props.theme.textMuted}>{semanticRefreshing()}</text>
729
+ </box>
730
+ )}
731
+ {/* When loading, magic-context-style progress hint helps users see
693
732
  background work is making progress instead of stuck. */}
694
- {s()?.semantic_index?.status === "loading" &&
695
- s()?.semantic_index?.entries_total != null &&
696
- s()!.semantic_index.entries_total! > 0 && (
733
+ {s()?.semantic_index?.status === "loading" &&
734
+ s()?.semantic_index?.entries_total != null &&
735
+ s()!.semantic_index.entries_total! > 0 && (
736
+ <StatRow
737
+ theme={props.theme}
738
+ label="Progress"
739
+ value={`${formatCount(s()!.semantic_index.entries_done)} / ${formatCount(
740
+ s()!.semantic_index.entries_total,
741
+ )}`}
742
+ tone="warn"
743
+ />
744
+ )}
745
+ {(s()?.semantic_index?.entries ?? null) != null && (
746
+ <StatRow
747
+ theme={props.theme}
748
+ label="Entries"
749
+ value={formatCount(s()!.semantic_index.entries)}
750
+ tone="muted"
751
+ />
752
+ )}
697
753
  <StatRow
698
754
  theme={props.theme}
699
- label="Progress"
700
- value={`${formatCount(s()!.semantic_index.entries_done)} / ${formatCount(
701
- s()!.semantic_index.entries_total,
702
- )}`}
703
- tone="warn"
755
+ label="Disk"
756
+ value={formatBytes(semanticBytes())}
757
+ tone="muted"
704
758
  />
705
- )}
706
- {(s()?.semantic_index?.entries ?? null) != null && (
707
- <StatRow
708
- theme={props.theme}
709
- label="Entries"
710
- value={formatCount(s()!.semantic_index.entries)}
711
- tone="muted"
712
- />
759
+ </>
713
760
  )}
714
- <StatRow
715
- theme={props.theme}
716
- label="Disk"
717
- value={formatBytes(semanticBytes())}
718
- tone="muted"
719
- />
720
761
 
721
762
  {/* Code Health — the agent status-bar glance, surfaced for users.
722
763
  Hidden until the Tier-2 cache is populated (status_bar undefined),
@@ -724,7 +765,7 @@ const SidebarContent = (props: {
724
765
  diagnostics; D/U/C/T come from the last background scan. A `~` on
725
766
  the section header flags the Tier-2 counts as predating the latest
726
767
  edit. */}
727
- {statusBar() && (
768
+ {prefs().sections.codeHealth && statusBar() && (
728
769
  <>
729
770
  <SectionHeader
730
771
  theme={props.theme}
@@ -774,7 +815,7 @@ const SidebarContent = (props: {
774
815
  subheader followed by two StatRows (Tokens Saved, Compression
775
816
  Ratio). Keeps numbers right-aligned in the value column instead
776
817
  of jamming them after the label on the same line. */}
777
- {compressionRows().length > 0 && (
818
+ {prefs().sections.compression && compressionRows().length > 0 && (
778
819
  <>
779
820
  <SectionHeader theme={props.theme} title="Compression" />
780
821
  {compressionRows().map((row) =>
@@ -802,12 +843,17 @@ const SidebarContent = (props: {
802
843
  );
803
844
  };
804
845
 
805
- export function createAftSidebarSlot(api: TuiPluginApi, pluginVersion: string): TuiSlotPlugin {
846
+ export async function createAftSidebarSlot(
847
+ api: TuiPluginApi,
848
+ pluginVersion: string,
849
+ ): Promise<TuiSlotPlugin> {
850
+ const root = await readTuiPreferencesFile();
851
+ const order = computeEffectiveOrder(root, PLUGIN_KEY, DEFAULT_SLOT_ORDER);
806
852
  return {
807
- // 150 matches magic-context's order chosen so AFT renders below
808
- // higher-priority panels but above default plugin slots. If both
809
- // plugins are loaded together, magic-context will appear first.
810
- order: 160,
853
+ // DEFAULT_SLOT_ORDER (180) is AFT's coordinated default in the shared
854
+ // tui-preferences ladder (anthropic-auth 160, AFT 180, magic-context 200).
855
+ // Override via `order` or `forceToTop` in tui-preferences.jsonc.
856
+ order,
811
857
  slots: {
812
858
  sidebar_content: (ctx, value) => {
813
859
  const theme = createMemo(() => (ctx as any).theme.current);