@mirrowel/opencode-souk 0.1.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/IMPLEMENTATION_PLAN.md +176 -0
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/config.d.ts +1093 -0
- package/dist/config.js +496 -0
- package/dist/forge.d.ts +3 -0
- package/dist/forge.js +78 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +303 -0
- package/dist/install.d.ts +18 -0
- package/dist/install.js +719 -0
- package/dist/registry.d.ts +67 -0
- package/dist/registry.js +447 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +2 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +1686 -0
- package/docs/CONFIG.md +67 -0
- package/package.json +86 -0
- package/souk.example.jsonc +68 -0
- package/src/skill/souk-installer/SKILL.md +313 -0
- package/src/tui.tsx +1892 -0
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,1892 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import type { TuiPlugin, TuiPluginApi, TuiDialogSelectOption } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
4
|
+
import { useTerminalDimensions } from "@opentui/solid"
|
|
5
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
|
6
|
+
import {
|
|
7
|
+
backupJournalPath,
|
|
8
|
+
createFullBackup,
|
|
9
|
+
deleteAllFullBackups,
|
|
10
|
+
deleteFullBackup,
|
|
11
|
+
confidenceRank,
|
|
12
|
+
debugLogPath,
|
|
13
|
+
defaultConfigDir,
|
|
14
|
+
defaultCachePath,
|
|
15
|
+
defaultSidecarPath,
|
|
16
|
+
diagnoseConfig,
|
|
17
|
+
effectiveUiHeightPercent,
|
|
18
|
+
loadBackupJournal,
|
|
19
|
+
loadCache,
|
|
20
|
+
loadSidecar,
|
|
21
|
+
loadSidecarSafe,
|
|
22
|
+
PERSONALITY_PRESETS,
|
|
23
|
+
personalityDescription,
|
|
24
|
+
reconstructPatchBackup,
|
|
25
|
+
saveBackupJournal,
|
|
26
|
+
saveSidecar,
|
|
27
|
+
type BackupJournal,
|
|
28
|
+
type CacheFile,
|
|
29
|
+
type FullBackupEntry,
|
|
30
|
+
type RegistryItem,
|
|
31
|
+
type ScopeChoice,
|
|
32
|
+
type SidecarConfig,
|
|
33
|
+
} from "./config.js"
|
|
34
|
+
import { openForge } from "./forge.js"
|
|
35
|
+
import { nativeInstall, previewNativeInstall, scopeLabel } from "./install.js"
|
|
36
|
+
import { loadRegistry } from "./registry.js"
|
|
37
|
+
|
|
38
|
+
type KeyContext = { event?: { preventDefault?: () => void; stopPropagation?: () => void } }
|
|
39
|
+
type SoukItem = RegistryItem & { installed?: { id: string; active: boolean; enabled: boolean; spec: string } }
|
|
40
|
+
type DialogSize = "medium" | "large" | "xlarge"
|
|
41
|
+
type DialogHeight = "compact" | "normal" | "tall" | "max"
|
|
42
|
+
type DisplayColor = string | TuiPluginApi["theme"]["current"]["text"]
|
|
43
|
+
type UI = TuiPluginApi["ui"]
|
|
44
|
+
type WizardSelectOption<Value = unknown> = TuiDialogSelectOption<Value> & { color?: DisplayColor; danger?: boolean; help?: string }
|
|
45
|
+
type MenuChoice<Value> = { action: "select" | "inspect"; value: Value }
|
|
46
|
+
type HeightSliderChoice = { action: "save" | "custom"; value: number }
|
|
47
|
+
type BrowserState = { config: SidecarConfig; cache: CacheFile; cursor: number; selected: Set<string>; search: string }
|
|
48
|
+
type BrowserAction =
|
|
49
|
+
| { action: "back"; state: BrowserState }
|
|
50
|
+
| { action: "inspect"; state: BrowserState; item: SoukItem }
|
|
51
|
+
| { action: "install"; state: BrowserState; items: SoukItem[] }
|
|
52
|
+
| { action: "settings"; state: BrowserState }
|
|
53
|
+
| { action: "refresh"; state: BrowserState }
|
|
54
|
+
| { action: "search"; state: BrowserState }
|
|
55
|
+
type InstalledPluginsState = { cursor: number }
|
|
56
|
+
type InstalledPluginsAction =
|
|
57
|
+
| { action: "back"; state: InstalledPluginsState }
|
|
58
|
+
| { action: "inspect"; state: InstalledPluginsState; item: SoukItem }
|
|
59
|
+
| { action: "toggle"; state: InstalledPluginsState; item: SoukItem }
|
|
60
|
+
| { action: "refresh"; state: InstalledPluginsState }
|
|
61
|
+
type BackupListItem =
|
|
62
|
+
| { kind: "patch"; index: number; title: string; description: string; valid: boolean }
|
|
63
|
+
| { kind: "full"; id: string; title: string; description: string; entry: FullBackupEntry }
|
|
64
|
+
|
|
65
|
+
const INTERNAL_PLUGIN_MANAGER = "internal:plugin-manager"
|
|
66
|
+
const MODE = "souk.dialog"
|
|
67
|
+
const UI_SIZE_KV = "opencode-souk.ui-width"
|
|
68
|
+
const UI_HEIGHT_KV = "opencode-souk.ui-height"
|
|
69
|
+
const UI_HEIGHT_PERCENT_KV = "opencode-souk.ui-height-percent"
|
|
70
|
+
const BROWSER_SIZE_KV = "opencode-souk.browser-width"
|
|
71
|
+
const HEIGHT_PERCENT_MIN = 25
|
|
72
|
+
const HEIGHT_PERCENT_MAX = 100
|
|
73
|
+
const HEIGHT_PRESETS: Array<{ label: DialogHeight; value: number; key: string }> = [
|
|
74
|
+
{ label: "compact", value: 32, key: "1" },
|
|
75
|
+
{ label: "normal", value: 50, key: "2" },
|
|
76
|
+
{ label: "tall", value: 68, key: "3" },
|
|
77
|
+
{ label: "max", value: 100, key: "4" },
|
|
78
|
+
]
|
|
79
|
+
const SHIELDED_KEYS = [
|
|
80
|
+
..."abcdefghijklmnopqrstuvwxyz".split(""),
|
|
81
|
+
..."0123456789".split(""),
|
|
82
|
+
"space",
|
|
83
|
+
"tab",
|
|
84
|
+
"backspace",
|
|
85
|
+
"delete",
|
|
86
|
+
"home",
|
|
87
|
+
"end",
|
|
88
|
+
"left",
|
|
89
|
+
"right",
|
|
90
|
+
"/",
|
|
91
|
+
"?",
|
|
92
|
+
":",
|
|
93
|
+
";",
|
|
94
|
+
"'",
|
|
95
|
+
"\"",
|
|
96
|
+
",",
|
|
97
|
+
".",
|
|
98
|
+
"-",
|
|
99
|
+
"=",
|
|
100
|
+
"[",
|
|
101
|
+
"]",
|
|
102
|
+
"\\",
|
|
103
|
+
"`",
|
|
104
|
+
] as const
|
|
105
|
+
|
|
106
|
+
function blockKey(ctx?: KeyContext) {
|
|
107
|
+
ctx?.event?.preventDefault?.()
|
|
108
|
+
ctx?.event?.stopPropagation?.()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shieldBindings(command: string, except: string[] = []) {
|
|
112
|
+
return SHIELDED_KEYS.filter((key) => !except.includes(key)).map((key) => ({ key, cmd: command, desc: "Block background input" }))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function wizardDialogSize(api: TuiPluginApi): DialogSize {
|
|
116
|
+
const value = api.kv.get<DialogSize>(UI_SIZE_KV, "large")
|
|
117
|
+
if (value === "medium" || value === "large" || value === "xlarge") return value
|
|
118
|
+
return "large"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function setWizardDialogSize(api: TuiPluginApi, size: DialogSize) {
|
|
122
|
+
api.kv.set(UI_SIZE_KV, size)
|
|
123
|
+
api.ui.dialog.setSize(size)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function browserDialogSize(api: TuiPluginApi): DialogSize {
|
|
127
|
+
const value = api.kv.get<DialogSize>(BROWSER_SIZE_KV, "xlarge")
|
|
128
|
+
if (value === "medium" || value === "large" || value === "xlarge") return value
|
|
129
|
+
return "xlarge"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function setBrowserDialogSize(api: TuiPluginApi, size: DialogSize) {
|
|
133
|
+
api.kv.set(BROWSER_SIZE_KV, size)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function nextWizardDialogSize(api: TuiPluginApi): DialogSize {
|
|
137
|
+
const current = wizardDialogSize(api)
|
|
138
|
+
if (current === "medium") return "large"
|
|
139
|
+
if (current === "large") return "xlarge"
|
|
140
|
+
return "medium"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function nextBrowserDialogSize(api: TuiPluginApi): DialogSize {
|
|
144
|
+
const current = browserDialogSize(api)
|
|
145
|
+
if (current === "medium") return "large"
|
|
146
|
+
if (current === "large") return "xlarge"
|
|
147
|
+
return "medium"
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function heightPresetPercent(height: DialogHeight) {
|
|
151
|
+
return HEIGHT_PRESETS.find((preset) => preset.label === height)?.value ?? 68
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function setWizardDialogHeight(api: TuiPluginApi, height: DialogHeight) {
|
|
155
|
+
api.kv.set(UI_HEIGHT_KV, height)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function clampHeightPercent(value: number) {
|
|
159
|
+
return Math.max(HEIGHT_PERCENT_MIN, Math.min(HEIGHT_PERCENT_MAX, Math.round(value)))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function wizardDialogHeightPercent(api: TuiPluginApi) {
|
|
163
|
+
const value = api.kv.get<number>(UI_HEIGHT_PERCENT_KV, 68)
|
|
164
|
+
return typeof value === "number" && Number.isFinite(value) ? clampHeightPercent(value) : 68
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function setWizardDialogHeightPercent(api: TuiPluginApi, value: number) {
|
|
168
|
+
api.kv.set(UI_HEIGHT_PERCENT_KV, clampHeightPercent(value))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function useWizardDialogSize(api: TuiPluginApi) {
|
|
172
|
+
createEffect(() => api.ui.dialog.setSize(wizardDialogSize(api)))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function useBrowserDialogSize(api: TuiPluginApi) {
|
|
176
|
+
createEffect(() => api.ui.dialog.setSize(browserDialogSize(api)))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function useHidePromptCursor(api: TuiPluginApi) {
|
|
180
|
+
const editors = collectEditors(api.renderer.root)
|
|
181
|
+
const previous = editors.map((editor) => ({ editor, showCursor: editor.showCursor }))
|
|
182
|
+
for (const editor of editors) editor.showCursor = false
|
|
183
|
+
onCleanup(() => {
|
|
184
|
+
for (const item of previous) {
|
|
185
|
+
if (item.editor.isDestroyed) continue
|
|
186
|
+
item.editor.showCursor = item.showCursor
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function collectEditors(root: { getChildren?: () => unknown[] }): Array<{ showCursor: boolean; isDestroyed?: boolean; getChildren?: () => unknown[] }> {
|
|
192
|
+
const children = typeof root.getChildren === "function" ? root.getChildren() : []
|
|
193
|
+
return [
|
|
194
|
+
...(typeof (root as { showCursor?: unknown }).showCursor === "boolean" ? [root as { showCursor: boolean; isDestroyed?: boolean; getChildren?: () => unknown[] }] : []),
|
|
195
|
+
...children.flatMap((child) => collectEditors(child as { getChildren?: () => unknown[] })),
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function cappedHeight(count: number, max: number, min = 1) {
|
|
200
|
+
if (count <= 0) return min
|
|
201
|
+
return Math.max(min, Math.min(count, max))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function wizardMaxRows(api: TuiPluginApi, terminalHeight: number, chromeRows: number, minRows: number) {
|
|
205
|
+
const usable = Math.max(minRows, terminalHeight - chromeRows)
|
|
206
|
+
return Math.max(minRows, Math.min(usable, Math.floor(terminalHeight * (wizardDialogHeightPercent(api) / 100))))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function menuTitleWidth(size: DialogSize, options: readonly { title: string }[]) {
|
|
210
|
+
const longest = Math.max(0, ...options.map((option) => option.title.length)) + 3
|
|
211
|
+
if (size === "xlarge") return Math.min(Math.max(24, longest), 36)
|
|
212
|
+
if (size === "large") return Math.min(Math.max(24, longest), 30)
|
|
213
|
+
return Math.min(Math.max(22, longest), 26)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function dialogContentWidth(api: TuiPluginApi) {
|
|
217
|
+
const size = wizardDialogSize(api)
|
|
218
|
+
if (size === "xlarge") return 106
|
|
219
|
+
if (size === "large") return 78
|
|
220
|
+
return 50
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function estimatedVisualRows(message: string, width: number) {
|
|
224
|
+
return message.split(/\r?\n/).reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / Math.max(1, width))), 0)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function singleLine(value: string | undefined, fallback = "") {
|
|
228
|
+
const text = (value ?? "").replace(/[\r\n\t]+/g, " ").replace(/\s{2,}/g, " ").trim()
|
|
229
|
+
return text || fallback
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function KeyHints(props: { api: TuiPluginApi; hints: Array<{ key: string; label: string }> }) {
|
|
233
|
+
const theme = () => props.api.theme.current
|
|
234
|
+
return (
|
|
235
|
+
<box flexDirection="row" gap={2}>
|
|
236
|
+
<For each={props.hints}>{(hint) => (
|
|
237
|
+
<box flexDirection="row" gap={0}>
|
|
238
|
+
<text fg={theme().accent}><b>{hint.key}</b></text>
|
|
239
|
+
<text fg={theme().textMuted}> {hint.label}</text>
|
|
240
|
+
</box>
|
|
241
|
+
)}</For>
|
|
242
|
+
</box>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function installedItems(api: TuiPluginApi): SoukItem[] {
|
|
247
|
+
return api.plugins.list()
|
|
248
|
+
.map((plugin) => ({
|
|
249
|
+
id: `installed:${plugin.id}`,
|
|
250
|
+
source: "installed" as const,
|
|
251
|
+
sourceType: plugin.source,
|
|
252
|
+
kind: "plugin" as const,
|
|
253
|
+
confidence: "verified" as const,
|
|
254
|
+
name: plugin.id,
|
|
255
|
+
description: plugin.source === "internal" ? "Built-in TUI plugin" : plugin.spec,
|
|
256
|
+
tags: [plugin.source, plugin.active ? "active" : "inactive", plugin.enabled ? "enabled" : "disabled"],
|
|
257
|
+
aliases: [],
|
|
258
|
+
alternateKinds: [],
|
|
259
|
+
sources: [],
|
|
260
|
+
primarySource: "installed" as const,
|
|
261
|
+
warnings: plugin.id === INTERNAL_PLUGIN_MANAGER ? ["Souk replaces this built-in manager."] : plugin.id === "opencode-souk" ? ["This is the running Souk plugin."] : [],
|
|
262
|
+
installed: { id: plugin.id, active: plugin.active, enabled: plugin.enabled, spec: plugin.spec },
|
|
263
|
+
}))
|
|
264
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function marketplaceItems(cache: CacheFile): SoukItem[] {
|
|
268
|
+
return cache.items
|
|
269
|
+
.sort((a, b) => confidenceRank(a.confidence) - confidenceRank(b.confidence) || a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function visibleMarketplaceItems(cache: CacheFile, config: SidecarConfig, query: string): SoukItem[] {
|
|
273
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
|
274
|
+
return marketplaceItems(cache).filter((item) => {
|
|
275
|
+
if (config.hidden[item.id]) return false
|
|
276
|
+
if (!terms.length) return true
|
|
277
|
+
const haystack = [
|
|
278
|
+
item.name,
|
|
279
|
+
item.description,
|
|
280
|
+
item.kind,
|
|
281
|
+
item.confidence,
|
|
282
|
+
item.source,
|
|
283
|
+
item.sourceType,
|
|
284
|
+
item.repoUrl,
|
|
285
|
+
item.homepageUrl,
|
|
286
|
+
item.install?.spec,
|
|
287
|
+
...(item.install?.specs ?? []),
|
|
288
|
+
...item.tags,
|
|
289
|
+
...item.aliases,
|
|
290
|
+
...item.alternateKinds,
|
|
291
|
+
...item.sources.flatMap((source) => [source.source, source.sourceType, source.name, source.description, source.repoUrl, source.kind, source.confidence]),
|
|
292
|
+
].filter(Boolean).join(" ").toLowerCase()
|
|
293
|
+
return terms.every((term) => haystack.includes(term))
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function openSouk(api: TuiPluginApi, force = false) {
|
|
298
|
+
const loaded = loadSidecarSafe(defaultSidecarPath())
|
|
299
|
+
const config = loaded.config
|
|
300
|
+
setWizardDialogSize(api, config.ui.width)
|
|
301
|
+
setWizardDialogHeight(api, config.ui.height)
|
|
302
|
+
setWizardDialogHeightPercent(api, effectiveUiHeightPercent(config.ui))
|
|
303
|
+
setBrowserDialogSize(api, config.ui.browser.width)
|
|
304
|
+
if (loaded.error) api.ui.toast({ variant: "error", message: `Souk config failed to parse; defaults are active: ${loaded.error}` })
|
|
305
|
+
let cache = loadCache(defaultCachePath())
|
|
306
|
+
if (force || (config.cache.fetch_on_empty && cache.items.length === 0)) {
|
|
307
|
+
api.ui.toast({ variant: "info", message: force ? "Refreshing Souk sources..." : "Souk cache is empty; fetching sources..." })
|
|
308
|
+
cache = await loadRegistry(config, { force })
|
|
309
|
+
api.ui.toast({ variant: cache.items.length > 0 ? "success" : "warning", message: `Souk cache has ${cache.items.length} marketplace item(s).` })
|
|
310
|
+
}
|
|
311
|
+
await mainMenu(api, config, cache, loaded.error)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function mainMenu(api: TuiPluginApi, config: SidecarConfig, cache: CacheFile, configParseError?: string): Promise<void> {
|
|
315
|
+
const action = await showMenu(api, {
|
|
316
|
+
title: "OpenCode Souk",
|
|
317
|
+
options: [
|
|
318
|
+
{ title: "Browse items", value: "browse", description: `${marketplaceItems(cache).length} marketplace item(s)`, help: mainMenuHelp("browse", api, config, cache) },
|
|
319
|
+
{ title: "Installed plugins", value: "installed", description: `${installedItems(api).length} installed plugin(s)`, help: mainMenuHelp("installed", api, config, cache) },
|
|
320
|
+
{ title: "Refresh sources", value: "refresh", description: "Fetch community sources now", help: mainMenuHelp("refresh", api, config, cache) },
|
|
321
|
+
{ title: "Kāf Forge settings", value: "forge", description: `Forge ${config.forge.enabled ? "enabled" : "disabled"}`, help: mainMenuHelp("forge", api, config, cache) },
|
|
322
|
+
{ title: "Run diagnostics", value: "diagnostics", description: "Check config, cache, sources, model, and backups", help: mainMenuHelp("diagnostics", api, config, cache) },
|
|
323
|
+
{ title: "Debug & advanced", value: "advanced", description: "Debug log, backups, UI size", help: mainMenuHelp("advanced", api, config, cache) },
|
|
324
|
+
{ title: "Info", value: "info", description: "How Souk works", help: mainMenuHelp("info", api, config, cache) },
|
|
325
|
+
{ title: "Close", value: "close", description: "Leave the souk", help: mainMenuHelp("close", api, config, cache) },
|
|
326
|
+
],
|
|
327
|
+
})
|
|
328
|
+
if (!action || action === "close") return
|
|
329
|
+
if (action === "browse") {
|
|
330
|
+
const next = await browseItems(api, config, cache)
|
|
331
|
+
return mainMenu(api, next.config, next.cache, configParseError)
|
|
332
|
+
}
|
|
333
|
+
if (action === "installed") {
|
|
334
|
+
await manageInstalledPlugins(api)
|
|
335
|
+
return mainMenu(api, config, cache, configParseError)
|
|
336
|
+
}
|
|
337
|
+
if (action === "refresh") {
|
|
338
|
+
const next = await loadRegistry(config, { force: true })
|
|
339
|
+
api.ui.toast({ variant: "success", message: `Refreshed ${next.items.length} marketplace item(s).` })
|
|
340
|
+
return mainMenu(api, config, next, configParseError)
|
|
341
|
+
}
|
|
342
|
+
if (action === "forge") {
|
|
343
|
+
const next = await forgeSettings(api, config)
|
|
344
|
+
if (next) {
|
|
345
|
+
await saveSidecarWithToast(api, next)
|
|
346
|
+
return mainMenu(api, next, cache, configParseError)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (action === "diagnostics") {
|
|
350
|
+
await runDiagnostics(api, config, cache, configParseError)
|
|
351
|
+
return mainMenu(api, config, cache, configParseError)
|
|
352
|
+
}
|
|
353
|
+
if (action === "advanced") {
|
|
354
|
+
const next = await debugAdvancedMenu(api, config)
|
|
355
|
+
return mainMenu(api, next, cache, configParseError)
|
|
356
|
+
}
|
|
357
|
+
if (action === "info") {
|
|
358
|
+
await showSoukInfo(api)
|
|
359
|
+
return mainMenu(api, config, cache, configParseError)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function mainMenuHelp(action: "browse" | "installed" | "refresh" | "forge" | "diagnostics" | "advanced" | "info" | "close", api: TuiPluginApi, config: SidecarConfig, cache: CacheFile) {
|
|
364
|
+
const installed = installedItems(api).length
|
|
365
|
+
const marketplace = cache.items.length
|
|
366
|
+
const staleHours = config.cache.stale_after_hours
|
|
367
|
+
const sourceCount = Object.values(config.sources).filter((source) => source.enabled).length
|
|
368
|
+
const common = "Press Enter to select this menu item. Press Esc to return. Press i on any Souk menu row to read this contextual help."
|
|
369
|
+
if (action === "browse") return [
|
|
370
|
+
"Browse Items",
|
|
371
|
+
"Opens the Souk marketplace browser with cached registry entries only.",
|
|
372
|
+
"",
|
|
373
|
+
`Current inventory: ${marketplace} marketplace item(s). Installed plugins live in the separate Installed plugins view.`,
|
|
374
|
+
"",
|
|
375
|
+
"Controls",
|
|
376
|
+
"Enter toggles selection. Space opens the install menu for selected items, or for the highlighted item when nothing is selected.",
|
|
377
|
+
"/ opens marketplace search. i opens the highlighted item's appraisal without leaving the browser. s opens Kāf settings and returns to the browser. r refreshes sources and returns to the browser. Shift+Up/Down or Ctrl+Up/Down jumps kind groups. Shift+Left/Right jumps tag groups. Esc returns to this main menu.",
|
|
378
|
+
"",
|
|
379
|
+
"Safety",
|
|
380
|
+
"Item rows show normalized kind, merged confidence, source-derived name, and a one-line description. Press i before installing anything unfamiliar; combo items list every contributing source.",
|
|
381
|
+
common,
|
|
382
|
+
].join("\n")
|
|
383
|
+
if (action === "installed") return [
|
|
384
|
+
"Installed Plugins",
|
|
385
|
+
"Opens a separate management view for plugins already known to the running OpenCode TUI.",
|
|
386
|
+
"",
|
|
387
|
+
`Installed plugins: ${installed}.`,
|
|
388
|
+
"",
|
|
389
|
+
"Controls",
|
|
390
|
+
"Enter toggles the highlighted plugin on or off. i opens plugin details. r refreshes the runtime plugin list. Esc returns to this main menu.",
|
|
391
|
+
"",
|
|
392
|
+
"Scope",
|
|
393
|
+
"This view does not install, batch-select, open Forge, or write marketplace config. It only calls OpenCode's plugin activate/deactivate API for an installed plugin.",
|
|
394
|
+
common,
|
|
395
|
+
].join("\n")
|
|
396
|
+
if (action === "refresh") return [
|
|
397
|
+
"Refresh Sources",
|
|
398
|
+
"Fetches configured community sources now and rewrites the local marketplace cache.",
|
|
399
|
+
"",
|
|
400
|
+
`Enabled sources: ${sourceCount}. Cache stale threshold: ${staleHours} hour(s).`,
|
|
401
|
+
"Souk normally fetches when the cache is empty or when you explicitly refresh. Manual refresh is useful after changing source settings or when you expect new registry entries.",
|
|
402
|
+
"",
|
|
403
|
+
"Persistence",
|
|
404
|
+
`Cache file: ${defaultCachePath()}`,
|
|
405
|
+
"Refresh does not install anything and does not modify OpenCode config.",
|
|
406
|
+
common,
|
|
407
|
+
].join("\n")
|
|
408
|
+
if (action === "forge") return [
|
|
409
|
+
"Kāf Forge Settings",
|
|
410
|
+
"Configures the experimental LLM-assisted install path.",
|
|
411
|
+
"",
|
|
412
|
+
`Current state: Forge ${config.forge.enabled ? "enabled" : "disabled"}. Model: ${config.forge.agent.model}. Personality: ${config.forge.agent.personality}.`,
|
|
413
|
+
"",
|
|
414
|
+
"Behavior",
|
|
415
|
+
"Kāf is available for every item, including verified items, when Forge is enabled. Kāf starts in a read-only planning agent, must analyze security, and must ask yes/no before switching to action mode.",
|
|
416
|
+
"",
|
|
417
|
+
"Risk",
|
|
418
|
+
"Forge is experimental. The souk can make mistakes. Review Kāf's plan, source provenance, config writes, shell commands, permissions, and OAuth/token handling before approving action mode.",
|
|
419
|
+
common,
|
|
420
|
+
].join("\n")
|
|
421
|
+
if (action === "diagnostics") return [
|
|
422
|
+
"Run Diagnostics",
|
|
423
|
+
"Checks whether Souk's current runtime state looks coherent.",
|
|
424
|
+
"",
|
|
425
|
+
"Diagnostics cover config parse errors, cache/source state, stale cache age, Kāf model and variant references, backup journal health, and loaded plugin IDs.",
|
|
426
|
+
"",
|
|
427
|
+
"Diagnostics are read-only. They do not refresh sources, install items, restore backups, or write config.",
|
|
428
|
+
common,
|
|
429
|
+
].join("\n")
|
|
430
|
+
if (action === "advanced") return [
|
|
431
|
+
"Debug & Advanced",
|
|
432
|
+
"Advanced operational tools ported from Agent Variants for safer plugin maintenance.",
|
|
433
|
+
"",
|
|
434
|
+
`Debug log: ${debugLogPath(defaultConfigDir())}`,
|
|
435
|
+
`Backup journal: ${backupJournalPath(defaultConfigDir())}`,
|
|
436
|
+
"",
|
|
437
|
+
"Includes debug logging, recent log viewing, log clearing, full config backups, backup browsing/restoring, and TUI width/height controls.",
|
|
438
|
+
"UI size changes are hot. Kāf agent config changes may require a full OpenCode restart to be fully reflected.",
|
|
439
|
+
common,
|
|
440
|
+
].join("\n")
|
|
441
|
+
if (action === "info") return [
|
|
442
|
+
"Souk Info",
|
|
443
|
+
"Opens the full Souk help page.",
|
|
444
|
+
"",
|
|
445
|
+
"The info page explains the main menu, item browser, native installs, Forge/Kāf, confidence labels, scope prompts, cache behavior, backups, and debug logging.",
|
|
446
|
+
"Use it when you want a broader map rather than help for only the highlighted row.",
|
|
447
|
+
common,
|
|
448
|
+
].join("\n")
|
|
449
|
+
return [
|
|
450
|
+
"Close",
|
|
451
|
+
"Leaves Souk and clears the current dialog.",
|
|
452
|
+
"",
|
|
453
|
+
"No marketplace cache, config, backup, install, or Forge changes are made by closing the UI.",
|
|
454
|
+
common,
|
|
455
|
+
].join("\n")
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function browseItems(api: TuiPluginApi, config: SidecarConfig, cache: CacheFile): Promise<{ config: SidecarConfig; cache: CacheFile }> {
|
|
459
|
+
let state: BrowserState = { config, cache, cursor: 0, selected: new Set(), search: "" }
|
|
460
|
+
while (true) {
|
|
461
|
+
const action = await showBrowserOnce(api, state)
|
|
462
|
+
if (!action) return { config: state.config, cache: state.cache }
|
|
463
|
+
state = action.state
|
|
464
|
+
if (action.action === "back") return { config: state.config, cache: state.cache }
|
|
465
|
+
if (action.action === "inspect") {
|
|
466
|
+
await showInfo(api, { title: action.item.name, message: itemInfo(action.item, state.config) })
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
if (action.action === "settings") {
|
|
470
|
+
const next = await forgeSettings(api, state.config)
|
|
471
|
+
if (next) {
|
|
472
|
+
state = { ...state, config: next }
|
|
473
|
+
await saveSidecarWithToast(api, next)
|
|
474
|
+
}
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
if (action.action === "search") {
|
|
478
|
+
const nextSearch = await showPrompt(api, { title: "Search marketplace", placeholder: "name, kind, tag, source, repo... empty clears", value: state.search })
|
|
479
|
+
if (nextSearch !== undefined) {
|
|
480
|
+
state = { ...state, search: nextSearch.trim(), cursor: 0 }
|
|
481
|
+
}
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
if (action.action === "refresh") {
|
|
485
|
+
try {
|
|
486
|
+
const next = await loadRegistry(state.config, { force: true })
|
|
487
|
+
state = { ...state, cache: next, cursor: Math.min(state.cursor, Math.max(0, visibleMarketplaceItems(next, state.config, state.search).length - 1)) }
|
|
488
|
+
api.ui.toast({ variant: "success", message: `Refreshed ${next.items.length} marketplace item(s).` })
|
|
489
|
+
} catch (error) {
|
|
490
|
+
api.ui.toast({ variant: "error", message: error instanceof Error ? error.message : String(error) })
|
|
491
|
+
}
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
if (action.action === "install") {
|
|
495
|
+
await installMenu(api, state.config, action.items)
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function showBrowserOnce(api: TuiPluginApi, state: BrowserState): Promise<BrowserAction | undefined> {
|
|
502
|
+
return new Promise((resolve) => {
|
|
503
|
+
let settled = false
|
|
504
|
+
const done = (value: BrowserAction | undefined, clear = true) => {
|
|
505
|
+
if (settled) return
|
|
506
|
+
settled = true
|
|
507
|
+
resolve(value)
|
|
508
|
+
if (clear) api.ui.dialog.clear()
|
|
509
|
+
}
|
|
510
|
+
api.ui.dialog.replace(() => <SoukDialog api={api} initialState={state} onDone={done} />, () => done(undefined, false))
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function SoukDialog(props: { api: TuiPluginApi; initialState: BrowserState; onDone: (value: BrowserAction | undefined) => void }) {
|
|
515
|
+
const api = props.api
|
|
516
|
+
const theme = () => api.theme.current
|
|
517
|
+
useBrowserDialogSize(api)
|
|
518
|
+
useHidePromptCursor(api)
|
|
519
|
+
const dimensions = useTerminalDimensions()
|
|
520
|
+
const [config] = createSignal(props.initialState.config)
|
|
521
|
+
const [cache] = createSignal(props.initialState.cache)
|
|
522
|
+
const [search] = createSignal(props.initialState.search)
|
|
523
|
+
const [selected, setSelected] = createSignal<Set<string>>(new Set(props.initialState.selected))
|
|
524
|
+
const [cursor, setCursor] = createSignal(props.initialState.cursor)
|
|
525
|
+
const items = createMemo(() => visibleMarketplaceItems(cache(), config(), search()))
|
|
526
|
+
const current = createMemo(() => items()[Math.min(cursor(), Math.max(0, items().length - 1))])
|
|
527
|
+
const listHeight = createMemo(() => cappedHeight(items().length, wizardMaxRows(api, dimensions().height, 12, 8), 8))
|
|
528
|
+
const nameWidth = createMemo(() => browserDialogSize(api) === "xlarge" ? 36 : browserDialogSize(api) === "large" ? 30 : 22)
|
|
529
|
+
const popMode = api.mode.push(MODE)
|
|
530
|
+
let scroll: ScrollBoxRenderable | undefined
|
|
531
|
+
const snapshot = (): BrowserState => ({
|
|
532
|
+
config: config(),
|
|
533
|
+
cache: cache(),
|
|
534
|
+
cursor: Math.min(cursor(), Math.max(0, items().length - 1)),
|
|
535
|
+
selected: new Set(selected()),
|
|
536
|
+
search: search(),
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
const move = (delta: number) => setCursor((value) => {
|
|
540
|
+
const next = Math.max(0, Math.min(items().length - 1, value + delta))
|
|
541
|
+
scroll?.scrollTo(Math.max(0, next - 2))
|
|
542
|
+
return next
|
|
543
|
+
})
|
|
544
|
+
const jumpBy = (delta: 1 | -1, field: "kind" | "tag") => setCursor((value) => {
|
|
545
|
+
const list = items()
|
|
546
|
+
if (!list.length) return 0
|
|
547
|
+
const currentItem = list[value]
|
|
548
|
+
const currentValue = field === "kind" ? currentItem?.kind : currentItem?.tags[0]
|
|
549
|
+
let index = value
|
|
550
|
+
while (index + delta >= 0 && index + delta < list.length) {
|
|
551
|
+
index += delta
|
|
552
|
+
const nextValue = field === "kind" ? list[index]?.kind : list[index]?.tags[0]
|
|
553
|
+
if (nextValue !== currentValue) {
|
|
554
|
+
scroll?.scrollTo(Math.max(0, index - 2))
|
|
555
|
+
return index
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return value
|
|
559
|
+
})
|
|
560
|
+
const toggle = () => {
|
|
561
|
+
const item = current()
|
|
562
|
+
if (!item) return
|
|
563
|
+
setSelected((prev) => {
|
|
564
|
+
const next = new Set(prev)
|
|
565
|
+
if (next.has(item.id)) next.delete(item.id)
|
|
566
|
+
else next.add(item.id)
|
|
567
|
+
return next
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
const inspect = () => {
|
|
571
|
+
const item = current()
|
|
572
|
+
if (!item) return
|
|
573
|
+
props.onDone({ action: "inspect", state: snapshot(), item })
|
|
574
|
+
}
|
|
575
|
+
const install = () => {
|
|
576
|
+
const chosen = selectedItems(items(), selected())
|
|
577
|
+
if (!chosen.length) {
|
|
578
|
+
api.ui.toast({ variant: "warning", message: "Select at least one item with Enter before installing." })
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
props.onDone({ action: "install", state: snapshot(), items: chosen })
|
|
582
|
+
}
|
|
583
|
+
const commandPrefix = `souk.main.${Math.random().toString(36).slice(2)}`
|
|
584
|
+
const unregister = api.keymap.registerLayer({
|
|
585
|
+
priority: 10000,
|
|
586
|
+
commands: [
|
|
587
|
+
{ name: `${commandPrefix}.up`, title: "Previous item", run: (ctx: KeyContext) => { blockKey(ctx); move(-1) } },
|
|
588
|
+
{ name: `${commandPrefix}.down`, title: "Next item", run: (ctx: KeyContext) => { blockKey(ctx); move(1) } },
|
|
589
|
+
{ name: `${commandPrefix}.toggle`, title: "Select item", run: (ctx: KeyContext) => { blockKey(ctx); toggle() } },
|
|
590
|
+
{ name: `${commandPrefix}.install`, title: "Install selected", run: (ctx: KeyContext) => { blockKey(ctx); install() } },
|
|
591
|
+
{ name: `${commandPrefix}.inspect`, title: "Inspect item", run: (ctx: KeyContext) => { blockKey(ctx); inspect() } },
|
|
592
|
+
{ name: `${commandPrefix}.search`, title: "Search items", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone({ action: "search", state: snapshot() }) } },
|
|
593
|
+
{ name: `${commandPrefix}.jumpKindUp`, title: "Previous kind", run: (ctx: KeyContext) => { blockKey(ctx); jumpBy(-1, "kind") } },
|
|
594
|
+
{ name: `${commandPrefix}.jumpKindDown`, title: "Next kind", run: (ctx: KeyContext) => { blockKey(ctx); jumpBy(1, "kind") } },
|
|
595
|
+
{ name: `${commandPrefix}.jumpTagUp`, title: "Previous tag", run: (ctx: KeyContext) => { blockKey(ctx); jumpBy(-1, "tag") } },
|
|
596
|
+
{ name: `${commandPrefix}.jumpTagDown`, title: "Next tag", run: (ctx: KeyContext) => { blockKey(ctx); jumpBy(1, "tag") } },
|
|
597
|
+
{ name: `${commandPrefix}.refresh`, title: "Refresh sources", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone({ action: "refresh", state: snapshot() }) } },
|
|
598
|
+
{ name: `${commandPrefix}.settings`, title: "Forge settings", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone({ action: "settings", state: snapshot() }) } },
|
|
599
|
+
{ name: `${commandPrefix}.close`, title: "Back", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone({ action: "back", state: snapshot() }) } },
|
|
600
|
+
{ name: `${commandPrefix}.shield`, title: "Block background input", run: blockKey },
|
|
601
|
+
],
|
|
602
|
+
bindings: [
|
|
603
|
+
{ key: "up", cmd: `${commandPrefix}.up`, desc: "Previous item" },
|
|
604
|
+
{ key: "ctrl+p", cmd: `${commandPrefix}.up`, desc: "Previous item" },
|
|
605
|
+
{ key: "down", cmd: `${commandPrefix}.down`, desc: "Next item" },
|
|
606
|
+
{ key: "ctrl+n", cmd: `${commandPrefix}.down`, desc: "Next item" },
|
|
607
|
+
{ key: "enter", cmd: `${commandPrefix}.toggle`, desc: "Select item" },
|
|
608
|
+
{ key: "space", cmd: `${commandPrefix}.install`, desc: "Install selected" },
|
|
609
|
+
{ key: "i", cmd: `${commandPrefix}.inspect`, desc: "Inspect item" },
|
|
610
|
+
{ key: "/", cmd: `${commandPrefix}.search`, desc: "Search items" },
|
|
611
|
+
{ key: "shift+up", cmd: `${commandPrefix}.jumpKindUp`, desc: "Previous kind" },
|
|
612
|
+
{ key: "shift+down", cmd: `${commandPrefix}.jumpKindDown`, desc: "Next kind" },
|
|
613
|
+
{ key: "ctrl+up", cmd: `${commandPrefix}.jumpKindUp`, desc: "Previous kind" },
|
|
614
|
+
{ key: "ctrl+down", cmd: `${commandPrefix}.jumpKindDown`, desc: "Next kind" },
|
|
615
|
+
{ key: "shift+left", cmd: `${commandPrefix}.jumpTagUp`, desc: "Previous tag" },
|
|
616
|
+
{ key: "shift+right", cmd: `${commandPrefix}.jumpTagDown`, desc: "Next tag" },
|
|
617
|
+
{ key: "r", cmd: `${commandPrefix}.refresh`, desc: "Refresh sources" },
|
|
618
|
+
{ key: "s", cmd: `${commandPrefix}.settings`, desc: "Forge settings" },
|
|
619
|
+
{ key: "escape", cmd: `${commandPrefix}.close`, desc: "Back" },
|
|
620
|
+
...shieldBindings(`${commandPrefix}.shield`, ["space", "i", "r", "s", "/"]),
|
|
621
|
+
],
|
|
622
|
+
} as any)
|
|
623
|
+
onCleanup(() => {
|
|
624
|
+
unregister()
|
|
625
|
+
popMode()
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
return (
|
|
629
|
+
<box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
630
|
+
<box flexDirection="row" justifyContent="space-between" width="100%" marginBottom={1}>
|
|
631
|
+
<text fg={theme().accent}><b>OpenCode Souk</b></text>
|
|
632
|
+
<KeyHints api={api} hints={[{ key: "enter", label: "select" }, { key: "space", label: "install" }, { key: "/", label: "search" }, { key: "i", label: "inspect" }, { key: "r", label: "refresh" }, { key: "s", label: "settings" }, { key: "esc", label: "back" }]} />
|
|
633
|
+
</box>
|
|
634
|
+
<box flexDirection="row" gap={2} marginBottom={1}>
|
|
635
|
+
<text fg={theme().textMuted}>{items().length} item(s)</text>
|
|
636
|
+
<Show when={search()}><text fg={theme().accent}>search: {search()}</text></Show>
|
|
637
|
+
<text fg={theme().success}>{selected().size} selected</text>
|
|
638
|
+
<text fg={config().forge.enabled ? theme().warning : theme().textMuted}>Forge: {config().forge.enabled ? "enabled" : "disabled"}</text>
|
|
639
|
+
</box>
|
|
640
|
+
<box flexDirection="row" width="100%" gap={1} paddingLeft={1} paddingRight={1} marginBottom={0}>
|
|
641
|
+
<text width={3} fg={theme().textMuted}>sel</text>
|
|
642
|
+
<text width={7} fg={theme().textMuted}>kind</text>
|
|
643
|
+
<text width={9} fg={theme().textMuted}>status</text>
|
|
644
|
+
<text width={nameWidth()} fg={theme().textMuted}>name</text>
|
|
645
|
+
<text flexGrow={1} fg={theme().textMuted}>description</text>
|
|
646
|
+
</box>
|
|
647
|
+
<scrollbox maxHeight={listHeight()} ref={(element: ScrollBoxRenderable) => (scroll = element)}>
|
|
648
|
+
<box flexDirection="column" gap={0}>
|
|
649
|
+
<For each={items()}>
|
|
650
|
+
{(item, index) => {
|
|
651
|
+
const active = createMemo(() => cursor() === index())
|
|
652
|
+
const picked = createMemo(() => selected().has(item.id))
|
|
653
|
+
const fg = createMemo(() => active() ? theme().background : item.confidence === "verified" ? theme().success : item.confidence === "partial" ? theme().warning : theme().text)
|
|
654
|
+
return (
|
|
655
|
+
<box
|
|
656
|
+
flexDirection="row"
|
|
657
|
+
width="100%"
|
|
658
|
+
gap={1}
|
|
659
|
+
paddingLeft={1}
|
|
660
|
+
paddingRight={1}
|
|
661
|
+
backgroundColor={active() ? theme().primary : theme().backgroundPanel}
|
|
662
|
+
onMouseOver={() => setCursor(index())}
|
|
663
|
+
onMouseUp={toggle}
|
|
664
|
+
>
|
|
665
|
+
<text width={3} fg={active() ? theme().background : picked() ? theme().success : theme().textMuted}>{picked() ? "[x]" : "[ ]"}</text>
|
|
666
|
+
<text width={7} fg={fg()} wrapMode="none" overflow="hidden">{singleLine(item.kind)}</text>
|
|
667
|
+
<text width={9} fg={fg()} wrapMode="none" overflow="hidden">{singleLine(item.confidence)}</text>
|
|
668
|
+
<text width={nameWidth()} fg={fg()} wrapMode="none" overflow="hidden"><b>{singleLine(item.name, item.id)}</b></text>
|
|
669
|
+
<text flexGrow={1} fg={active() ? theme().background : theme().textMuted} wrapMode="none" overflow="hidden">{singleLine(item.description, "No description.")}</text>
|
|
670
|
+
</box>
|
|
671
|
+
)
|
|
672
|
+
}}
|
|
673
|
+
</For>
|
|
674
|
+
</box>
|
|
675
|
+
</scrollbox>
|
|
676
|
+
<box marginTop={1}>
|
|
677
|
+
<text fg={theme().textMuted}>A quiet shelf of unusual pieces. / searches. shift+up/down jumps kinds; shift+left/right jumps tags.</text>
|
|
678
|
+
</box>
|
|
679
|
+
</box>
|
|
680
|
+
)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function manageInstalledPlugins(api: TuiPluginApi): Promise<void> {
|
|
684
|
+
let state: InstalledPluginsState = { cursor: 0 }
|
|
685
|
+
while (true) {
|
|
686
|
+
const action = await showInstalledPluginsOnce(api, state)
|
|
687
|
+
if (!action) return
|
|
688
|
+
state = action.state
|
|
689
|
+
if (action.action === "back") return
|
|
690
|
+
if (action.action === "inspect") {
|
|
691
|
+
await showInfo(api, { title: action.item.name, message: installedPluginInfo(action.item) })
|
|
692
|
+
continue
|
|
693
|
+
}
|
|
694
|
+
if (action.action === "refresh") {
|
|
695
|
+
state = { cursor: Math.min(state.cursor, Math.max(0, installedItems(api).length - 1)) }
|
|
696
|
+
api.ui.toast({ variant: "info", message: `Found ${installedItems(api).length} installed plugin(s).` })
|
|
697
|
+
continue
|
|
698
|
+
}
|
|
699
|
+
if (action.action === "toggle") {
|
|
700
|
+
const id = action.item.installed?.id
|
|
701
|
+
if (!id) continue
|
|
702
|
+
if (id === "opencode-souk") {
|
|
703
|
+
api.ui.toast({ variant: "warning", message: "Souk cannot deactivate itself from inside Souk." })
|
|
704
|
+
continue
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
if (action.item.installed?.active) {
|
|
708
|
+
await api.plugins.deactivate(id)
|
|
709
|
+
api.ui.toast({ variant: "success", message: `Deactivated ${id}.` })
|
|
710
|
+
} else {
|
|
711
|
+
await api.plugins.activate(id)
|
|
712
|
+
api.ui.toast({ variant: "success", message: `Activated ${id}.` })
|
|
713
|
+
}
|
|
714
|
+
} catch (error) {
|
|
715
|
+
api.ui.toast({ variant: "error", message: error instanceof Error ? error.message : String(error) })
|
|
716
|
+
}
|
|
717
|
+
state = { cursor: Math.min(state.cursor, Math.max(0, installedItems(api).length - 1)) }
|
|
718
|
+
continue
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function showInstalledPluginsOnce(api: TuiPluginApi, state: InstalledPluginsState): Promise<InstalledPluginsAction | undefined> {
|
|
724
|
+
return new Promise((resolve) => {
|
|
725
|
+
let settled = false
|
|
726
|
+
const done = (value: InstalledPluginsAction | undefined, clear = true) => {
|
|
727
|
+
if (settled) return
|
|
728
|
+
settled = true
|
|
729
|
+
resolve(value)
|
|
730
|
+
if (clear) api.ui.dialog.clear()
|
|
731
|
+
}
|
|
732
|
+
api.ui.dialog.replace(() => <InstalledPluginsDialog api={api} initialState={state} onDone={done} />, () => done(undefined, false))
|
|
733
|
+
})
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function InstalledPluginsDialog(props: { api: TuiPluginApi; initialState: InstalledPluginsState; onDone: (value: InstalledPluginsAction | undefined) => void }) {
|
|
737
|
+
const api = props.api
|
|
738
|
+
const theme = () => api.theme.current
|
|
739
|
+
useBrowserDialogSize(api)
|
|
740
|
+
useHidePromptCursor(api)
|
|
741
|
+
const dimensions = useTerminalDimensions()
|
|
742
|
+
const [cursor, setCursor] = createSignal(props.initialState.cursor)
|
|
743
|
+
const items = createMemo(() => installedItems(api))
|
|
744
|
+
const current = createMemo(() => items()[Math.min(cursor(), Math.max(0, items().length - 1))])
|
|
745
|
+
const listHeight = createMemo(() => cappedHeight(items().length, wizardMaxRows(api, dimensions().height, 12, 8), 8))
|
|
746
|
+
const nameWidth = createMemo(() => browserDialogSize(api) === "xlarge" ? 40 : browserDialogSize(api) === "large" ? 32 : 24)
|
|
747
|
+
const popMode = api.mode.push(MODE)
|
|
748
|
+
let scroll: ScrollBoxRenderable | undefined
|
|
749
|
+
const snapshot = (): InstalledPluginsState => ({ cursor: Math.min(cursor(), Math.max(0, items().length - 1)) })
|
|
750
|
+
const move = (delta: number) => setCursor((value) => {
|
|
751
|
+
const next = Math.max(0, Math.min(items().length - 1, value + delta))
|
|
752
|
+
scroll?.scrollTo(Math.max(0, next - 2))
|
|
753
|
+
return next
|
|
754
|
+
})
|
|
755
|
+
const inspect = () => {
|
|
756
|
+
const item = current()
|
|
757
|
+
if (!item) return
|
|
758
|
+
props.onDone({ action: "inspect", state: snapshot(), item })
|
|
759
|
+
}
|
|
760
|
+
const toggle = () => {
|
|
761
|
+
const item = current()
|
|
762
|
+
if (!item) return
|
|
763
|
+
props.onDone({ action: "toggle", state: snapshot(), item })
|
|
764
|
+
}
|
|
765
|
+
const commandPrefix = `souk.installed.${Math.random().toString(36).slice(2)}`
|
|
766
|
+
const unregister = api.keymap.registerLayer({
|
|
767
|
+
priority: 10000,
|
|
768
|
+
commands: [
|
|
769
|
+
{ name: `${commandPrefix}.up`, title: "Previous plugin", run: (ctx: KeyContext) => { blockKey(ctx); move(-1) } },
|
|
770
|
+
{ name: `${commandPrefix}.down`, title: "Next plugin", run: (ctx: KeyContext) => { blockKey(ctx); move(1) } },
|
|
771
|
+
{ name: `${commandPrefix}.toggle`, title: "Toggle plugin", run: (ctx: KeyContext) => { blockKey(ctx); toggle() } },
|
|
772
|
+
{ name: `${commandPrefix}.inspect`, title: "Inspect plugin", run: (ctx: KeyContext) => { blockKey(ctx); inspect() } },
|
|
773
|
+
{ name: `${commandPrefix}.refresh`, title: "Refresh plugins", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone({ action: "refresh", state: snapshot() }) } },
|
|
774
|
+
{ name: `${commandPrefix}.back`, title: "Back", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone({ action: "back", state: snapshot() }) } },
|
|
775
|
+
{ name: `${commandPrefix}.shield`, title: "Block background input", run: blockKey },
|
|
776
|
+
],
|
|
777
|
+
bindings: [
|
|
778
|
+
{ key: "up", cmd: `${commandPrefix}.up`, desc: "Previous plugin" },
|
|
779
|
+
{ key: "ctrl+p", cmd: `${commandPrefix}.up`, desc: "Previous plugin" },
|
|
780
|
+
{ key: "down", cmd: `${commandPrefix}.down`, desc: "Next plugin" },
|
|
781
|
+
{ key: "ctrl+n", cmd: `${commandPrefix}.down`, desc: "Next plugin" },
|
|
782
|
+
{ key: "enter", cmd: `${commandPrefix}.toggle`, desc: "Toggle plugin" },
|
|
783
|
+
{ key: "i", cmd: `${commandPrefix}.inspect`, desc: "Inspect plugin" },
|
|
784
|
+
{ key: "r", cmd: `${commandPrefix}.refresh`, desc: "Refresh plugins" },
|
|
785
|
+
{ key: "escape", cmd: `${commandPrefix}.back`, desc: "Back" },
|
|
786
|
+
...shieldBindings(`${commandPrefix}.shield`, ["i", "r"]),
|
|
787
|
+
],
|
|
788
|
+
} as any)
|
|
789
|
+
onCleanup(() => { unregister(); popMode() })
|
|
790
|
+
|
|
791
|
+
return (
|
|
792
|
+
<box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
793
|
+
<box flexDirection="row" justifyContent="space-between" width="100%" marginBottom={1}>
|
|
794
|
+
<text fg={theme().accent}><b>Installed Plugins</b></text>
|
|
795
|
+
<KeyHints api={api} hints={[{ key: "enter", label: "toggle" }, { key: "i", label: "inspect" }, { key: "r", label: "refresh" }, { key: "esc", label: "back" }]} />
|
|
796
|
+
</box>
|
|
797
|
+
<box flexDirection="row" gap={2} marginBottom={1}>
|
|
798
|
+
<text fg={theme().textMuted}>{items().length} plugin(s)</text>
|
|
799
|
+
<text fg={theme().success}>{items().filter((item) => item.installed?.active).length} active</text>
|
|
800
|
+
<text fg={theme().textMuted}>no batch install or Forge here</text>
|
|
801
|
+
</box>
|
|
802
|
+
<box flexDirection="row" width="100%" gap={1} paddingLeft={1} paddingRight={1} marginBottom={0}>
|
|
803
|
+
<text width={9} fg={theme().textMuted}>state</text>
|
|
804
|
+
<text width={9} fg={theme().textMuted}>source</text>
|
|
805
|
+
<text width={nameWidth()} fg={theme().textMuted}>name</text>
|
|
806
|
+
<text flexGrow={1} fg={theme().textMuted}>description</text>
|
|
807
|
+
</box>
|
|
808
|
+
<scrollbox maxHeight={listHeight()} ref={(element: ScrollBoxRenderable) => (scroll = element)}>
|
|
809
|
+
<box flexDirection="column" gap={0}>
|
|
810
|
+
<For each={items()}>
|
|
811
|
+
{(item, index) => {
|
|
812
|
+
const active = createMemo(() => cursor() === index())
|
|
813
|
+
const enabled = createMemo(() => item.installed?.active ?? false)
|
|
814
|
+
const protectedPlugin = createMemo(() => item.installed?.id === "opencode-souk")
|
|
815
|
+
const fg = createMemo(() => active() ? theme().background : enabled() ? theme().success : protectedPlugin() ? theme().warning : theme().textMuted)
|
|
816
|
+
return (
|
|
817
|
+
<box
|
|
818
|
+
flexDirection="row"
|
|
819
|
+
width="100%"
|
|
820
|
+
gap={1}
|
|
821
|
+
paddingLeft={1}
|
|
822
|
+
paddingRight={1}
|
|
823
|
+
backgroundColor={active() ? theme().primary : theme().backgroundPanel}
|
|
824
|
+
onMouseOver={() => setCursor(index())}
|
|
825
|
+
onMouseUp={toggle}
|
|
826
|
+
>
|
|
827
|
+
<text width={9} fg={fg()} wrapMode="none" overflow="hidden">{enabled() ? "active" : "inactive"}</text>
|
|
828
|
+
<text width={9} fg={fg()} wrapMode="none" overflow="hidden">{singleLine(item.sourceType)}</text>
|
|
829
|
+
<text width={nameWidth()} fg={fg()} wrapMode="none" overflow="hidden"><b>{singleLine(item.name, item.id)}</b></text>
|
|
830
|
+
<text flexGrow={1} fg={active() ? theme().background : theme().textMuted} wrapMode="none" overflow="hidden">{singleLine(item.description, "No description.")}</text>
|
|
831
|
+
</box>
|
|
832
|
+
)
|
|
833
|
+
}}
|
|
834
|
+
</For>
|
|
835
|
+
</box>
|
|
836
|
+
</scrollbox>
|
|
837
|
+
<box marginTop={1}>
|
|
838
|
+
<text fg={theme().textMuted}>Installed plugins are managed separately from marketplace browsing. Enter toggles the highlighted plugin.</text>
|
|
839
|
+
</box>
|
|
840
|
+
</box>
|
|
841
|
+
)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function installMenu(api: TuiPluginApi, config: SidecarConfig, items: SoukItem[]) {
|
|
845
|
+
const scope = await askScope(api)
|
|
846
|
+
if (!scope) return
|
|
847
|
+
const options: WizardSelectOption<string>[] = [
|
|
848
|
+
{ title: "Safe install", value: "safe", description: "Use deterministic native installer where Souk can verify one", help: installActionHelp("safe", config, items, scope) },
|
|
849
|
+
{ title: "Forge with Kāf", value: "forge", description: config.forge.enabled ? "Open an LLM-assisted Kāf session for all selected items" : "Experimental Forge mode is disabled", help: installActionHelp("forge", config, items, scope) },
|
|
850
|
+
{ title: "Preview", value: "preview", description: "Show planned native writes/actions", help: installActionHelp("preview", config, items, scope) },
|
|
851
|
+
{ title: "Scope", value: "scope", description: `Current: ${scopeLabel(scope)}`, help: installActionHelp("scope", config, items, scope) },
|
|
852
|
+
{ title: "Cancel", value: "cancel", description: "Return to Souk", help: "Return to the previous Souk screen without installing, previewing, opening Forge, or changing scope." },
|
|
853
|
+
]
|
|
854
|
+
const action = await showMenu(api, { title: `${items.length} selected item(s)`, options })
|
|
855
|
+
if (!action || action === "cancel") return
|
|
856
|
+
if (action === "scope") return installMenu(api, config, items)
|
|
857
|
+
if (action === "preview") {
|
|
858
|
+
const preview = await previewNativeInstall(items, scope, config.install)
|
|
859
|
+
await showInfo(api, { title: "Native Install Preview", message: previewText(preview.summary, preview.warnings) })
|
|
860
|
+
return installMenu(api, config, items)
|
|
861
|
+
}
|
|
862
|
+
if (action === "safe") {
|
|
863
|
+
const preview = await previewNativeInstall(items, scope, config.install)
|
|
864
|
+
const ok = await showConfirm(api, {
|
|
865
|
+
title: "Safe install?",
|
|
866
|
+
message: previewText(preview.summary, preview.warnings),
|
|
867
|
+
})
|
|
868
|
+
if (!ok) return
|
|
869
|
+
const result = await nativeInstall(api, items, scope, { confirm: (title, message) => showConfirm(api, { title, message }) }, config.install)
|
|
870
|
+
await showInfo(api, { title: "Native Install Result", message: [...result.installed, ...result.failed].join("\n") || "Nothing changed." })
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
if (action === "forge") {
|
|
874
|
+
if (!config.forge.enabled) {
|
|
875
|
+
const enable = await showConfirm(api, { title: "Enable Forge?", message: "Forge is experimental. Kāf can make mistakes, but starts in plan mode and must ask before action mode. Enable Forge now?" })
|
|
876
|
+
if (!enable) return
|
|
877
|
+
config.forge.enabled = true
|
|
878
|
+
await saveSidecarWithToast(api, config)
|
|
879
|
+
}
|
|
880
|
+
const ok = await showConfirm(api, {
|
|
881
|
+
title: "Open Forge with Kāf?",
|
|
882
|
+
message: forgeConfirmText(items, scope),
|
|
883
|
+
})
|
|
884
|
+
if (!ok) return
|
|
885
|
+
try {
|
|
886
|
+
const sessionID = await openForge(api, config, items, scope)
|
|
887
|
+
api.ui.toast({ variant: "success", message: `Opened Souk Forge session ${sessionID}` })
|
|
888
|
+
} catch (error) {
|
|
889
|
+
api.ui.toast({ variant: "error", message: error instanceof Error ? error.message : String(error) })
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function installActionHelp(action: "safe" | "forge" | "preview" | "scope", config: SidecarConfig, items: SoukItem[], scope: ScopeChoice) {
|
|
895
|
+
const counts = new Map<string, number>()
|
|
896
|
+
for (const item of items) counts.set(item.confidence, (counts.get(item.confidence) ?? 0) + 1)
|
|
897
|
+
const summary = [`Selected items: ${items.length}`, `Scope: ${scopeLabel(scope)}`, ...Array.from(counts.entries()).map(([confidence, count]) => `${confidence}: ${count}`)]
|
|
898
|
+
if (action === "safe") return [
|
|
899
|
+
"Safe Install",
|
|
900
|
+
"Runs Souk's deterministic native installer for selected items.",
|
|
901
|
+
"",
|
|
902
|
+
...summary,
|
|
903
|
+
"",
|
|
904
|
+
"What Souk may write",
|
|
905
|
+
"Plugins install through OpenCode's plugin API. MCPs patch OpenCode config and may optionally connect/auth at runtime. Agents, commands, themes, and skills write conventional OpenCode files/config when metadata is sufficient.",
|
|
906
|
+
"",
|
|
907
|
+
"Safety behavior",
|
|
908
|
+
"Souk previews planned writes, creates a pre-install backup snapshot before supported native installs, preserves JSONC comments, uses atomic writes, and refuses conflicting overwrites. If an install path cannot be proven, Souk does not guess.",
|
|
909
|
+
].join("\n")
|
|
910
|
+
if (action === "forge") return [
|
|
911
|
+
"Forge With Kāf",
|
|
912
|
+
config.forge.enabled ? "Opens a dedicated Kāf session for the selected items." : "Forge is currently disabled. Selecting this can ask whether to enable it first.",
|
|
913
|
+
"",
|
|
914
|
+
...summary,
|
|
915
|
+
"",
|
|
916
|
+
"Plan/action gate",
|
|
917
|
+
"Kāf starts in a read-only planning agent with MCP research allowed. It must analyze security and ask yes/no before switching to the write-capable action agent.",
|
|
918
|
+
"",
|
|
919
|
+
"Security review",
|
|
920
|
+
"Kāf must review source provenance, package risk, config writes, permissions, shell commands, OAuth/token handling, backups, and any installation instructions. Kāf should use manual install plans when native tools are uncertain or insufficient. The souk can make mistakes.",
|
|
921
|
+
].join("\n")
|
|
922
|
+
if (action === "preview") return [
|
|
923
|
+
"Preview",
|
|
924
|
+
"Shows Souk's deterministic native install plan before writing anything.",
|
|
925
|
+
"",
|
|
926
|
+
...summary,
|
|
927
|
+
"",
|
|
928
|
+
"Use this before Safe install when you want to inspect paths, config patches, unsupported items, and warnings without opening Kāf.",
|
|
929
|
+
].join("\n")
|
|
930
|
+
return [
|
|
931
|
+
"Scope",
|
|
932
|
+
"Change where persistent install writes go for this install attempt.",
|
|
933
|
+
"",
|
|
934
|
+
`Current scope: ${scopeLabel(scope)}`,
|
|
935
|
+
"",
|
|
936
|
+
"Global scope writes to your global OpenCode config directory. Project scope writes under the selected project's .opencode directory/config.",
|
|
937
|
+
"Both native installs and Kāf Forge must ask for scope before any persistent write.",
|
|
938
|
+
].join("\n")
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function askScope(api: TuiPluginApi): Promise<ScopeChoice | undefined> {
|
|
942
|
+
const picked = await showMenu(api, {
|
|
943
|
+
title: "Install scope",
|
|
944
|
+
options: [
|
|
945
|
+
{ title: "Global", value: "global", description: "Install into the global OpenCode config", help: `Global scope\n\nWrites to ${defaultConfigDir()}. Use this for extensions you want available in every OpenCode project.\n\nSouk still previews writes and refuses conflicting overwrites before persistence.` },
|
|
946
|
+
{ title: "Project", value: "project", description: "Install into a project .opencode config", help: "Project scope\n\nWrites under a selected project's .opencode directory/config. Use this for project-specific MCPs, commands, agents, themes, or skills.\n\nSouk asks for the project path before writing." },
|
|
947
|
+
{ title: "Cancel", value: "cancel", description: "Return to Souk", help: "Return without selecting an install scope. No persistent write can happen without a scope." },
|
|
948
|
+
],
|
|
949
|
+
})
|
|
950
|
+
if (!picked || picked === "cancel") return
|
|
951
|
+
if (picked === "global") return { kind: "global" }
|
|
952
|
+
const fallback = api.state.path.worktree && api.state.path.worktree !== "/" ? api.state.path.worktree : api.state.path.directory
|
|
953
|
+
const path = await showPrompt(api, { title: "Project path", placeholder: fallback, value: fallback })
|
|
954
|
+
if (!path) return
|
|
955
|
+
return { kind: "project", path }
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function selectedItems(items: SoukItem[], selected: Set<string>) {
|
|
959
|
+
const chosen = items.filter((item) => selected.has(item.id))
|
|
960
|
+
return chosen
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function itemInfo(item: SoukItem, config: SidecarConfig) {
|
|
964
|
+
return [
|
|
965
|
+
`${item.name}`,
|
|
966
|
+
"",
|
|
967
|
+
"Identity",
|
|
968
|
+
`Kind: ${item.kind}`,
|
|
969
|
+
`ID: ${item.id}`,
|
|
970
|
+
`Source: ${item.source}${item.sourceType ? ` (${item.sourceType})` : ""}`,
|
|
971
|
+
item.sources.length ? `All sources: ${item.sources.map((source) => `${source.source}${source.sourceType ? `/${source.sourceType}` : ""}`).join(", ")}` : undefined,
|
|
972
|
+
item.aliases.length ? `Aliases: ${item.aliases.join(", ")}` : undefined,
|
|
973
|
+
item.alternateKinds.length ? `Alternate kinds: ${item.alternateKinds.join(", ")}` : undefined,
|
|
974
|
+
`Confidence: ${item.confidence}`,
|
|
975
|
+
`Install hint: ${item.install?.spec ?? item.install?.type ?? "none"}`,
|
|
976
|
+
item.installed ? `Installed: ${item.installed.active ? "active" : "inactive"}, ${item.installed.enabled ? "enabled" : "disabled"}` : undefined,
|
|
977
|
+
item.repoUrl ? `Repo: ${item.repoUrl}` : undefined,
|
|
978
|
+
item.homepageUrl ? `Home: ${item.homepageUrl}` : undefined,
|
|
979
|
+
item.tags.length ? `Tags: ${item.tags.join(", ")}` : undefined,
|
|
980
|
+
"",
|
|
981
|
+
item.sources.length ? "Source records:" : undefined,
|
|
982
|
+
...item.sources.flatMap((source) => [
|
|
983
|
+
`- ${source.source}${source.sourceType ? ` (${source.sourceType})` : ""}: ${source.name ?? item.name}`,
|
|
984
|
+
source.repoUrl ? ` repo: ${source.repoUrl}` : undefined,
|
|
985
|
+
source.homepageUrl ? ` home: ${source.homepageUrl}` : undefined,
|
|
986
|
+
source.confidence ? ` confidence: ${source.confidence}${source.kind ? `, kind: ${source.kind}` : ""}` : undefined,
|
|
987
|
+
source.install?.reason ? ` install: ${source.install.reason}${source.install.spec ? ` (${source.install.spec})` : ""}` : undefined,
|
|
988
|
+
].filter((line): line is string => typeof line === "string")),
|
|
989
|
+
item.sources.length ? "" : undefined,
|
|
990
|
+
"Description",
|
|
991
|
+
singleLine(item.description, "No description."),
|
|
992
|
+
"",
|
|
993
|
+
"Security appraisal:",
|
|
994
|
+
...securityNotes(item),
|
|
995
|
+
"",
|
|
996
|
+
"Warnings:",
|
|
997
|
+
...(item.warnings.length ? item.warnings.map((warning) => `- ${warning}`) : ["- No source-specific warnings."]),
|
|
998
|
+
"",
|
|
999
|
+
item.confidence === "verified" ? "Charted item. Native installer may be available." : "This path is not fully charted. The souk can make mistakes; review before writing.",
|
|
1000
|
+
"",
|
|
1001
|
+
"Browser controls",
|
|
1002
|
+
"Enter toggles selection. Space opens the install menu. i closes this appraisal and returns to the browser. s opens Kāf settings from the browser. Esc returns from the browser to the main menu.",
|
|
1003
|
+
"",
|
|
1004
|
+
`Forge: ${config.forge.enabled ? "enabled" : "disabled"}`,
|
|
1005
|
+
"Press Space to choose Safe install or Forge with Kāf for selected items.",
|
|
1006
|
+
].filter((line): line is string => typeof line === "string").join("\n")
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function installedPluginInfo(item: SoukItem) {
|
|
1010
|
+
const installed = item.installed
|
|
1011
|
+
return [
|
|
1012
|
+
item.name,
|
|
1013
|
+
"",
|
|
1014
|
+
"Installed Plugin",
|
|
1015
|
+
`ID: ${installed?.id ?? item.id}`,
|
|
1016
|
+
`Source: ${item.sourceType ?? "unknown"}`,
|
|
1017
|
+
`Spec: ${installed?.spec ?? "unknown"}`,
|
|
1018
|
+
`State: ${installed?.active ? "active" : "inactive"}`,
|
|
1019
|
+
`Enabled: ${installed?.enabled ? "yes" : "no"}`,
|
|
1020
|
+
item.tags.length ? `Tags: ${item.tags.join(", ")}` : undefined,
|
|
1021
|
+
"",
|
|
1022
|
+
"Description",
|
|
1023
|
+
singleLine(item.description, "No description."),
|
|
1024
|
+
"",
|
|
1025
|
+
"Controls",
|
|
1026
|
+
"Enter toggles this installed plugin on or off. r refreshes the installed plugin list. Esc returns to the main menu.",
|
|
1027
|
+
"",
|
|
1028
|
+
"What toggling does",
|
|
1029
|
+
"Souk calls OpenCode's runtime plugin activate/deactivate API. This view does not install anything, does not batch-select, does not open Forge, and does not write marketplace cache entries.",
|
|
1030
|
+
"",
|
|
1031
|
+
installed?.id === "opencode-souk" ? "Protected: Souk will not deactivate itself from inside Souk." : undefined,
|
|
1032
|
+
installed?.id === INTERNAL_PLUGIN_MANAGER ? "Note: Souk deactivates the built-in plugin manager on startup so the Souk command can replace it cleanly." : undefined,
|
|
1033
|
+
].filter((line): line is string => typeof line === "string").join("\n")
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function securityNotes(item: RegistryItem) {
|
|
1037
|
+
const notes = [
|
|
1038
|
+
`- Provenance should be checked from source ${item.source}${item.repoUrl ? ` and repo ${item.repoUrl}` : ""}.`,
|
|
1039
|
+
`- Install confidence is ${item.confidence}.`,
|
|
1040
|
+
]
|
|
1041
|
+
if (item.installationMarkdown && /curl\s+[^|]+\|\s*(?:sh|bash)|sudo\s+|rm\s+-rf/i.test(item.installationMarkdown)) notes.push("- Installation text contains high-risk shell patterns.")
|
|
1042
|
+
if (item.kind === "mcp") notes.push("- MCP servers can expose tools and may require tokens, headers, OAuth, or local command execution.")
|
|
1043
|
+
if (item.kind === "plugin") notes.push("- Plugins can register hooks and alter OpenCode behavior at runtime.")
|
|
1044
|
+
if (["agent", "command", "skill"].includes(item.kind)) notes.push("- Prompt/config extensions can alter model behavior and should be reviewed for hidden instructions.")
|
|
1045
|
+
if (item.confidence !== "verified") notes.push("- Not fully verified. Use preview or Forge and review all proposed writes.")
|
|
1046
|
+
return notes
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function previewText(summary: string, warnings: string[]) {
|
|
1050
|
+
return [summary, "", "Warnings:", ...(warnings.length ? warnings.map((warning) => `- ${warning}`) : ["- No native preview warnings."]), "", "The souk can make mistakes. Review before writing."].join("\n")
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function forgeConfirmText(items: RegistryItem[], scope: ScopeChoice) {
|
|
1054
|
+
const counts = new Map<string, number>()
|
|
1055
|
+
for (const item of items) counts.set(item.confidence, (counts.get(item.confidence) ?? 0) + 1)
|
|
1056
|
+
return [
|
|
1057
|
+
`Open Forge with Kāf for ${items.length} selected item(s)?`,
|
|
1058
|
+
"",
|
|
1059
|
+
`Scope: ${scopeLabel(scope)}`,
|
|
1060
|
+
...Array.from(counts.entries()).map(([confidence, count]) => `${confidence}: ${count}`),
|
|
1061
|
+
"",
|
|
1062
|
+
"Kāf will inspect metadata, installation instructions, and security risks before proposing changes.",
|
|
1063
|
+
"The souk can make mistakes. Nothing should be written until you approve action mode.",
|
|
1064
|
+
].join("\n")
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async function saveSidecarWithToast(api: TuiPluginApi, config: SidecarConfig, options: { backup?: boolean; restart?: boolean } = {}) {
|
|
1068
|
+
try {
|
|
1069
|
+
const result = saveSidecar(config, defaultSidecarPath(), { backup: options.backup })
|
|
1070
|
+
if (!result.changed) {
|
|
1071
|
+
api.ui.toast({ variant: "info", message: "No Souk settings changed." })
|
|
1072
|
+
return result
|
|
1073
|
+
}
|
|
1074
|
+
api.ui.toast({ variant: "success", message: options.restart === false ? "Souk settings saved." : "Souk settings saved. Restart OpenCode for Kāf agent config changes to fully apply." })
|
|
1075
|
+
return result
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
await showInfo(api, { title: "Save failed", message: error instanceof Error ? error.message : String(error) })
|
|
1078
|
+
return { changed: false }
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function forgeSettings(api: TuiPluginApi, config: SidecarConfig): Promise<SidecarConfig | undefined> {
|
|
1083
|
+
let next = structuredClone(config)
|
|
1084
|
+
while (true) {
|
|
1085
|
+
const action = await showMenu(api, {
|
|
1086
|
+
title: "Souk Settings",
|
|
1087
|
+
options: [
|
|
1088
|
+
{ title: `Forge: ${next.forge.enabled ? "enabled" : "disabled"}`, value: "toggle", description: "Experimental Kāf-assisted installs", help: forgeSettingHelp("toggle", next) },
|
|
1089
|
+
{ title: `Kāf model: ${next.forge.agent.model}`, value: "model", description: "Provider/model reference", help: forgeSettingHelp("model", next) },
|
|
1090
|
+
{ title: `Kāf variant: ${next.forge.agent.variant ?? "default"}`, value: "variant", description: "Provider model variant", help: forgeSettingHelp("variant", next) },
|
|
1091
|
+
{ title: `Personality: ${next.forge.agent.personality}`, value: "personality", description: personalityDescription(next.forge.agent.personality), help: forgeSettingHelp("personality", next) },
|
|
1092
|
+
{ title: `Debug: ${next.debug ? "on" : "off"}`, value: "debug", description: "Write Souk debug log entries", help: forgeSettingHelp("debug", next) },
|
|
1093
|
+
{ title: "Back", value: "back", description: "Return to Souk", help: "Return to the previous Souk screen. Settings changed in this menu are saved when you leave settings." },
|
|
1094
|
+
],
|
|
1095
|
+
})
|
|
1096
|
+
if (!action || action === "back") return next
|
|
1097
|
+
if (action === "toggle") next = { ...next, forge: { ...next.forge, enabled: !next.forge.enabled } }
|
|
1098
|
+
if (action === "model") {
|
|
1099
|
+
const value = await pickModel(api, next.forge.agent.model)
|
|
1100
|
+
if (value && value !== next.forge.agent.model) next = { ...next, forge: { ...next.forge, agent: { ...next.forge.agent, model: value, variant: undefined } } }
|
|
1101
|
+
}
|
|
1102
|
+
if (action === "variant") {
|
|
1103
|
+
const value = await pickModelVariant(api, next.forge.agent.model, next.forge.agent.variant)
|
|
1104
|
+
if (value !== undefined) next = { ...next, forge: { ...next.forge, agent: { ...next.forge.agent, variant: value || undefined } } }
|
|
1105
|
+
}
|
|
1106
|
+
if (action === "personality") {
|
|
1107
|
+
const value = await showMenu(api, {
|
|
1108
|
+
title: "Kāf personality",
|
|
1109
|
+
options: PERSONALITY_PRESETS.map((preset) => ({ title: preset, value: preset, description: personalityDescription(preset), help: personalityDescription(preset) })),
|
|
1110
|
+
})
|
|
1111
|
+
if (value) next = { ...next, forge: { ...next.forge, agent: { ...next.forge.agent, personality: value } } }
|
|
1112
|
+
}
|
|
1113
|
+
if (action === "debug") next = { ...next, debug: !next.debug }
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function forgeSettingHelp(action: "toggle" | "model" | "variant" | "personality" | "debug", config: SidecarConfig) {
|
|
1118
|
+
if (action === "toggle") return [
|
|
1119
|
+
"Forge",
|
|
1120
|
+
`Current state: ${config.forge.enabled ? "enabled" : "disabled"}.`,
|
|
1121
|
+
"",
|
|
1122
|
+
"Forge makes Kāf available from the install menu for every selected item, including verified deterministic installs.",
|
|
1123
|
+
"Kāf starts in plan mode, analyzes security, and must ask yes/no before action mode.",
|
|
1124
|
+
"",
|
|
1125
|
+
"Forge is experimental. The souk can make mistakes.",
|
|
1126
|
+
].join("\n")
|
|
1127
|
+
if (action === "model") return [
|
|
1128
|
+
"Kāf Model",
|
|
1129
|
+
`Current model: ${config.forge.agent.model}.`,
|
|
1130
|
+
"",
|
|
1131
|
+
"Pick the provider/model Kāf uses for Forge sessions. The picker is searchable and grouped by provider using OpenCode's provider catalog.",
|
|
1132
|
+
"Choose Custom model only when you know the provider/model ID is valid but it is not listed.",
|
|
1133
|
+
].join("\n")
|
|
1134
|
+
if (action === "variant") return [
|
|
1135
|
+
"Kāf Variant",
|
|
1136
|
+
`Current variant: ${config.forge.agent.variant ?? "provider default"}.`,
|
|
1137
|
+
"",
|
|
1138
|
+
"Optional provider-specific model variant. Known variants are shown when the selected model exposes them; otherwise you can type one manually.",
|
|
1139
|
+
"Leave this unset unless the provider documents a variant you want Kāf to use.",
|
|
1140
|
+
].join("\n")
|
|
1141
|
+
if (action === "personality") return [
|
|
1142
|
+
"Kāf Personality",
|
|
1143
|
+
`Current preset: ${config.forge.agent.personality}.`,
|
|
1144
|
+
"",
|
|
1145
|
+
personalityDescription(config.forge.agent.personality),
|
|
1146
|
+
"",
|
|
1147
|
+
"Personality affects flavor and presentation only. It must not obscure risk. Warnings drop personality and use direct language.",
|
|
1148
|
+
].join("\n")
|
|
1149
|
+
return [
|
|
1150
|
+
"Debug Logging",
|
|
1151
|
+
`Current state: ${config.debug ? "on" : "off"}.`,
|
|
1152
|
+
"",
|
|
1153
|
+
`Debug log path: ${debugLogPath(defaultConfigDir())}`,
|
|
1154
|
+
"Debug writes are best-effort and never fatal. Use this when diagnosing source refreshes, native installs, backup behavior, or Forge session creation.",
|
|
1155
|
+
].join("\n")
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async function pickModel(api: TuiPluginApi, current: string) {
|
|
1159
|
+
const picked = await showSelect(api.ui, { title: "Kāf model", options: modelOptions(api), current })
|
|
1160
|
+
if (!picked) return undefined
|
|
1161
|
+
if (picked === "__custom__") return showPrompt(api, { title: "Custom model ID", placeholder: "provider/model-id", value: current })
|
|
1162
|
+
return picked
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async function pickModelVariant(api: TuiPluginApi, model: string, current?: string) {
|
|
1166
|
+
const picked = await showSelect(api.ui, { title: "Kāf model variant", options: modelVariantOptions(api, model), current: current ?? "__remove__" })
|
|
1167
|
+
if (!picked) return undefined
|
|
1168
|
+
if (picked === "__remove__") return ""
|
|
1169
|
+
if (picked === "__custom__") return showPrompt(api, { title: "Custom model variant", placeholder: "variant-id", value: current })
|
|
1170
|
+
return picked
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function modelOptions(api: TuiPluginApi): WizardSelectOption<string>[] {
|
|
1174
|
+
const opts: WizardSelectOption<string>[] = []
|
|
1175
|
+
const seen = new Set<string>()
|
|
1176
|
+
for (const provider of api.state.provider ?? []) {
|
|
1177
|
+
for (const model of Object.values(provider.models ?? {})) {
|
|
1178
|
+
const ref = `${provider.id}/${model.id}`
|
|
1179
|
+
if (seen.has(ref)) continue
|
|
1180
|
+
seen.add(ref)
|
|
1181
|
+
opts.push({ title: model.name, value: ref, description: `via ${provider.name}`, category: provider.name, help: `Provider: ${provider.name}\nModel ID: ${ref}` })
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
opts.push({ title: "Custom model", value: "__custom__", description: "Type a provider/model ID manually", category: "Custom", help: "Use this when the provider/model is valid but not listed by the current provider catalog." })
|
|
1185
|
+
return opts
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function modelVariantOptions(api: TuiPluginApi, modelRef: string): WizardSelectOption<string>[] {
|
|
1189
|
+
const opts: WizardSelectOption<string>[] = [{ title: "Default", value: "__remove__", description: "Remove the provider variant override", category: "Default" }]
|
|
1190
|
+
const slash = modelRef.indexOf("/")
|
|
1191
|
+
const providerID = slash === -1 ? "" : modelRef.slice(0, slash)
|
|
1192
|
+
const modelID = slash === -1 ? "" : modelRef.slice(slash + 1)
|
|
1193
|
+
const provider = api.state.provider?.find((item) => item.id === providerID)
|
|
1194
|
+
const model = provider?.models?.[modelID]
|
|
1195
|
+
if (provider && model) {
|
|
1196
|
+
for (const variant of Object.keys(model.variants ?? {})) opts.push({ title: variant, value: variant, description: `${model.name} via ${provider.name}`, category: "Known variants" })
|
|
1197
|
+
}
|
|
1198
|
+
opts.push({ title: "Custom variant", value: "__custom__", description: provider && model ? "Type a provider-specific variant manually" : "No known variants; type one manually", category: "Custom" })
|
|
1199
|
+
return opts
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function runDiagnostics(api: TuiPluginApi, config: SidecarConfig, cache: CacheFile, configParseError?: string) {
|
|
1203
|
+
const diagnostics = diagnoseConfig(config, { providers: api.state.provider, cache, configParseError, pluginIDs: api.plugins.list().map((plugin) => plugin.id) })
|
|
1204
|
+
await showInfo(api, { title: "Souk Diagnostics", message: diagnostics.map((item) => `${item.level.toUpperCase()}: ${item.message}`).join("\n") || "No diagnostics." })
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
async function debugAdvancedMenu(api: TuiPluginApi, config: SidecarConfig): Promise<SidecarConfig> {
|
|
1208
|
+
let current = structuredClone(config)
|
|
1209
|
+
while (true) {
|
|
1210
|
+
const action = await showMenu(api, {
|
|
1211
|
+
title: "Debug & advanced",
|
|
1212
|
+
options: [
|
|
1213
|
+
{ title: `Debug mode: ${current.debug ? "on" : "off"}`, value: "debug", description: "Toggle debug log writes", help: advancedHelp("debug") },
|
|
1214
|
+
{ title: "View debug log", value: "view-log", description: "Show recent souk.debug.log entries", help: advancedHelp("view-log") },
|
|
1215
|
+
{ title: "Clear debug log", value: "clear-log", description: "Empty souk.debug.log", help: advancedHelp("clear-log") },
|
|
1216
|
+
{ title: "Create full config backup", value: "backup", description: "Snapshot souk.jsonc", help: advancedHelp("backup") },
|
|
1217
|
+
{ title: "Config backups", value: "backups", description: "Browse, preview, restore, or delete backups", help: advancedHelp("backups") },
|
|
1218
|
+
{ title: `Wizard UI width: ${wizardDialogSize(api)}`, value: "ui-size", description: "Cycle medium, large, xlarge", help: advancedHelp("ui-size") },
|
|
1219
|
+
{ title: `Wizard UI height: ${wizardDialogHeightPercent(api)}%`, value: "ui-height", description: "Adjust max height with slider or preset marks", help: advancedHelp("ui-height") },
|
|
1220
|
+
{ title: `Item browser width: ${browserDialogSize(api)}`, value: "browser-size", description: "Cycle browser medium, large, xlarge", help: advancedHelp("browser-size") },
|
|
1221
|
+
{ title: "Back", value: "back", description: "Return to main menu", help: "Return to the main Souk menu. Debug & advanced actions stay in this menu until you choose Back." },
|
|
1222
|
+
],
|
|
1223
|
+
})
|
|
1224
|
+
if (!action || action === "back") return current
|
|
1225
|
+
if (action === "debug") {
|
|
1226
|
+
current = { ...current, debug: !current.debug }
|
|
1227
|
+
await saveSidecarWithToast(api, current, { backup: false, restart: false })
|
|
1228
|
+
continue
|
|
1229
|
+
}
|
|
1230
|
+
if (action === "view-log") {
|
|
1231
|
+
await viewDebugLog(api)
|
|
1232
|
+
continue
|
|
1233
|
+
}
|
|
1234
|
+
if (action === "clear-log") {
|
|
1235
|
+
const ok = await showConfirm(api, { title: "Clear debug log?", message: `Empty ${debugLogPath(defaultConfigDir())}?` })
|
|
1236
|
+
if (!ok) continue
|
|
1237
|
+
mkdirSync(defaultConfigDir(), { recursive: true })
|
|
1238
|
+
writeFileSync(debugLogPath(defaultConfigDir()), "", "utf8")
|
|
1239
|
+
api.ui.toast({ variant: "success", message: "Souk debug log cleared." })
|
|
1240
|
+
continue
|
|
1241
|
+
}
|
|
1242
|
+
if (action === "backup") {
|
|
1243
|
+
createFullBackup(current, { label: "Manual backup" })
|
|
1244
|
+
api.ui.toast({ variant: "success", message: "Full Souk config backup created." })
|
|
1245
|
+
continue
|
|
1246
|
+
}
|
|
1247
|
+
if (action === "backups") {
|
|
1248
|
+
current = await configBackupsMenu(api, current)
|
|
1249
|
+
continue
|
|
1250
|
+
}
|
|
1251
|
+
if (action === "ui-size") {
|
|
1252
|
+
const width = nextWizardDialogSize(api)
|
|
1253
|
+
current = { ...current, ui: { ...current.ui, width } }
|
|
1254
|
+
setWizardDialogSize(api, width)
|
|
1255
|
+
await saveSidecarWithToast(api, current, { backup: false, restart: false })
|
|
1256
|
+
continue
|
|
1257
|
+
}
|
|
1258
|
+
if (action === "ui-height") {
|
|
1259
|
+
const value = await showHeightSlider(api, effectiveUiHeightPercent(current.ui), "Souk UI height")
|
|
1260
|
+
if (value !== undefined) {
|
|
1261
|
+
const preset = HEIGHT_PRESETS.find((item) => item.value === value)
|
|
1262
|
+
current = { ...current, ui: { ...current.ui, height_percent: value, height: preset?.label ?? current.ui.height } }
|
|
1263
|
+
setWizardDialogHeightPercent(api, value)
|
|
1264
|
+
if (preset) setWizardDialogHeight(api, preset.label)
|
|
1265
|
+
await saveSidecarWithToast(api, current, { backup: false, restart: false })
|
|
1266
|
+
}
|
|
1267
|
+
continue
|
|
1268
|
+
}
|
|
1269
|
+
if (action === "browser-size") {
|
|
1270
|
+
const width = nextBrowserDialogSize(api)
|
|
1271
|
+
current = { ...current, ui: { ...current.ui, browser: { ...current.ui.browser, width } } }
|
|
1272
|
+
setBrowserDialogSize(api, width)
|
|
1273
|
+
await saveSidecarWithToast(api, current, { backup: false, restart: false })
|
|
1274
|
+
continue
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function advancedHelp(action: "debug" | "view-log" | "clear-log" | "backup" | "backups" | "ui-size" | "ui-height" | "browser-size") {
|
|
1280
|
+
if (action === "debug") return [`Debug Mode`, `Path: ${debugLogPath(defaultConfigDir())}`, "", "Toggles best-effort debug logging for Souk internals. Debug writes are never fatal and should not block normal UI behavior.", "This setting is hot and does not need an OpenCode restart."].join("\n")
|
|
1281
|
+
if (action === "view-log") return [`View Debug Log`, `Path: ${debugLogPath(defaultConfigDir())}`, "", "Shows recent debug log entries in a scrollable info window. This is read-only and returns to Debug & advanced when closed."].join("\n")
|
|
1282
|
+
if (action === "clear-log") return [`Clear Debug Log`, `Path: ${debugLogPath(defaultConfigDir())}`, "", "Asks for confirmation, then empties the debug log file. This does not change Souk config or backups."].join("\n")
|
|
1283
|
+
if (action === "backup") return [`Create Full Config Backup`, `Journal: ${backupJournalPath(defaultConfigDir())}`, "", "Creates a full snapshot of the current Souk sidecar config. Useful before changing advanced settings or restoring older config."].join("\n")
|
|
1284
|
+
if (action === "backups") return [`Config Backups`, `Journal: ${backupJournalPath(defaultConfigDir())}`, "", "Browse patch restore points and full snapshots. Patch restores validate their hash chain. Full restores reset the patch chain after writing the snapshot."].join("\n")
|
|
1285
|
+
if (action === "ui-size") return ["Wizard UI Width", "Cycles the dialog width between medium, large, and xlarge.", "", "This affects Souk menus, info windows, and custom sliders immediately. It is saved without creating a backup entry."].join("\n")
|
|
1286
|
+
if (action === "ui-height") return ["Wizard UI Height", "Opens the height slider with compact, normal, tall, and max preset marks.", "", "This controls the maximum height used by Souk menus, info windows, custom sliders, and the item browser. It is saved without creating a backup entry."].join("\n")
|
|
1287
|
+
return ["Item Browser Width", "Cycles only the item browser width between medium, large, and xlarge.", "", "Use this when the marketplace table needs more room than the rest of Souk's menus. Other menus and info windows keep using Wizard UI width."].join("\n")
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async function showHeightSlider(api: TuiPluginApi, initial: number, title: string): Promise<number | undefined> {
|
|
1291
|
+
let current = initial
|
|
1292
|
+
while (true) {
|
|
1293
|
+
const choice = await showHeightSliderOnce(api, current, title)
|
|
1294
|
+
if (!choice) return undefined
|
|
1295
|
+
current = choice.value
|
|
1296
|
+
if (choice.action === "save") return current
|
|
1297
|
+
const input = await showPrompt(api, { title: `${title} percent`, placeholder: `${HEIGHT_PERCENT_MIN}-${HEIGHT_PERCENT_MAX}`, value: String(current) })
|
|
1298
|
+
if (input === undefined) continue
|
|
1299
|
+
const value = Number(input)
|
|
1300
|
+
if (!Number.isFinite(value)) {
|
|
1301
|
+
api.ui.toast({ variant: "error", message: `Enter a number from ${HEIGHT_PERCENT_MIN} to ${HEIGHT_PERCENT_MAX}.` })
|
|
1302
|
+
continue
|
|
1303
|
+
}
|
|
1304
|
+
current = clampHeightPercent(value)
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function showHeightSliderOnce(api: TuiPluginApi, current: number, title: string): Promise<HeightSliderChoice | undefined> {
|
|
1309
|
+
return new Promise((resolve) => {
|
|
1310
|
+
let settled = false
|
|
1311
|
+
const done = (value: HeightSliderChoice | undefined, clear = true) => {
|
|
1312
|
+
if (settled) return
|
|
1313
|
+
settled = true
|
|
1314
|
+
resolve(value)
|
|
1315
|
+
if (clear) api.ui.dialog.clear()
|
|
1316
|
+
}
|
|
1317
|
+
api.ui.dialog.replace(() => <HeightSliderDialog api={api} title={title} current={current} onDone={done} />, () => done(undefined, false))
|
|
1318
|
+
})
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function showSelect<Value>(
|
|
1322
|
+
ui: UI,
|
|
1323
|
+
props: {
|
|
1324
|
+
title: string
|
|
1325
|
+
options: TuiDialogSelectOption<Value>[]
|
|
1326
|
+
placeholder?: string
|
|
1327
|
+
current?: Value
|
|
1328
|
+
},
|
|
1329
|
+
): Promise<Value | undefined> {
|
|
1330
|
+
return new Promise((resolve) => {
|
|
1331
|
+
let settled = false
|
|
1332
|
+
const done = (value: Value | undefined) => {
|
|
1333
|
+
if (settled) return
|
|
1334
|
+
settled = true
|
|
1335
|
+
resolve(value)
|
|
1336
|
+
}
|
|
1337
|
+
ui.dialog.replace(() =>
|
|
1338
|
+
ui.DialogSelect<Value>({
|
|
1339
|
+
title: props.title,
|
|
1340
|
+
placeholder: props.placeholder ?? "Type to filter...",
|
|
1341
|
+
options: props.options,
|
|
1342
|
+
current: props.current,
|
|
1343
|
+
flat: props.options.length < 15,
|
|
1344
|
+
onSelect: (opt) => {
|
|
1345
|
+
done(opt.value)
|
|
1346
|
+
ui.dialog.clear()
|
|
1347
|
+
},
|
|
1348
|
+
}),
|
|
1349
|
+
() => done(undefined),
|
|
1350
|
+
)
|
|
1351
|
+
})
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function HeightSliderDialog(props: { api: TuiPluginApi; title: string; current: number; onDone: (value: HeightSliderChoice | undefined) => void }) {
|
|
1355
|
+
const theme = () => props.api.theme.current
|
|
1356
|
+
useWizardDialogSize(props.api)
|
|
1357
|
+
useHidePromptCursor(props.api)
|
|
1358
|
+
const [value, setValue] = createSignal(clampHeightPercent(props.current))
|
|
1359
|
+
const popMode = props.api.mode.push(MODE)
|
|
1360
|
+
const commandPrefix = `souk.height.${Math.random().toString(36).slice(2)}`
|
|
1361
|
+
const setPreset = (height: DialogHeight) => setValue(heightPresetPercent(height))
|
|
1362
|
+
const move = (delta: number) => setValue((current) => clampHeightPercent(current + delta))
|
|
1363
|
+
const sliderWidth = createMemo(() => wizardDialogSize(props.api) === "xlarge" ? 64 : wizardDialogSize(props.api) === "large" ? 48 : 34)
|
|
1364
|
+
const sliderCells = createMemo(() => {
|
|
1365
|
+
const width = sliderWidth()
|
|
1366
|
+
const selected = Math.round(((value() - HEIGHT_PERCENT_MIN) / (HEIGHT_PERCENT_MAX - HEIGHT_PERCENT_MIN)) * (width - 1))
|
|
1367
|
+
const presetPositions = new Map(HEIGHT_PRESETS.map((preset) => [Math.round(((preset.value - HEIGHT_PERCENT_MIN) / (HEIGHT_PERCENT_MAX - HEIGHT_PERCENT_MIN)) * (width - 1)), preset.label]))
|
|
1368
|
+
return Array.from({ length: width }, (_, index) => {
|
|
1369
|
+
const current = index === selected
|
|
1370
|
+
const preset = presetPositions.get(index)
|
|
1371
|
+
return {
|
|
1372
|
+
char: current ? "●" : preset ? "│" : index < selected ? "━" : "─",
|
|
1373
|
+
color: current ? theme().primary : preset ? theme().accent : index < selected ? theme().success : theme().textMuted,
|
|
1374
|
+
}
|
|
1375
|
+
})
|
|
1376
|
+
})
|
|
1377
|
+
const unregister = props.api.keymap.registerLayer({
|
|
1378
|
+
priority: 10000,
|
|
1379
|
+
commands: [
|
|
1380
|
+
{ name: `${commandPrefix}.left`, title: "Lower height", run: (ctx: KeyContext) => { blockKey(ctx); move(-1) } },
|
|
1381
|
+
{ name: `${commandPrefix}.right`, title: "Raise height", run: (ctx: KeyContext) => { blockKey(ctx); move(1) } },
|
|
1382
|
+
{ name: `${commandPrefix}.down`, title: "Lower height faster", run: (ctx: KeyContext) => { blockKey(ctx); move(-5) } },
|
|
1383
|
+
{ name: `${commandPrefix}.up`, title: "Raise height faster", run: (ctx: KeyContext) => { blockKey(ctx); move(5) } },
|
|
1384
|
+
{ name: `${commandPrefix}.compact`, title: "Compact preset", run: (ctx: KeyContext) => { blockKey(ctx); setPreset("compact") } },
|
|
1385
|
+
{ name: `${commandPrefix}.normal`, title: "Normal preset", run: (ctx: KeyContext) => { blockKey(ctx); setPreset("normal") } },
|
|
1386
|
+
{ name: `${commandPrefix}.tall`, title: "Tall preset", run: (ctx: KeyContext) => { blockKey(ctx); setPreset("tall") } },
|
|
1387
|
+
{ name: `${commandPrefix}.max`, title: "Max preset", run: (ctx: KeyContext) => { blockKey(ctx); setPreset("max") } },
|
|
1388
|
+
{ name: `${commandPrefix}.custom`, title: "Custom percent", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone({ action: "custom", value: value() }) } },
|
|
1389
|
+
{ name: `${commandPrefix}.save`, title: "Save height", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone({ action: "save", value: value() }) } },
|
|
1390
|
+
{ name: `${commandPrefix}.back`, title: "Back", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone(undefined) } },
|
|
1391
|
+
{ name: `${commandPrefix}.shield`, title: "Block background input", run: blockKey },
|
|
1392
|
+
],
|
|
1393
|
+
bindings: [
|
|
1394
|
+
{ key: "left", cmd: `${commandPrefix}.left`, desc: "Lower height" },
|
|
1395
|
+
{ key: "right", cmd: `${commandPrefix}.right`, desc: "Raise height" },
|
|
1396
|
+
{ key: "down", cmd: `${commandPrefix}.down`, desc: "Lower height faster" },
|
|
1397
|
+
{ key: "up", cmd: `${commandPrefix}.up`, desc: "Raise height faster" },
|
|
1398
|
+
{ key: "1", cmd: `${commandPrefix}.compact`, desc: "Compact preset" },
|
|
1399
|
+
{ key: "2", cmd: `${commandPrefix}.normal`, desc: "Normal preset" },
|
|
1400
|
+
{ key: "3", cmd: `${commandPrefix}.tall`, desc: "Tall preset" },
|
|
1401
|
+
{ key: "4", cmd: `${commandPrefix}.max`, desc: "Max preset" },
|
|
1402
|
+
{ key: "c", cmd: `${commandPrefix}.custom`, desc: "Custom percent" },
|
|
1403
|
+
{ key: "enter", cmd: `${commandPrefix}.save`, desc: "Save height" },
|
|
1404
|
+
{ key: "escape", cmd: `${commandPrefix}.back`, desc: "Back" },
|
|
1405
|
+
...shieldBindings(`${commandPrefix}.shield`, ["1", "2", "3", "4", "c"]),
|
|
1406
|
+
],
|
|
1407
|
+
} as any)
|
|
1408
|
+
onCleanup(() => { unregister(); popMode() })
|
|
1409
|
+
|
|
1410
|
+
return (
|
|
1411
|
+
<box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
1412
|
+
<box flexDirection="row" justifyContent="space-between" width="100%" marginBottom={1}>
|
|
1413
|
+
<text fg={theme().accent}><b>{props.title}</b></text>
|
|
1414
|
+
<KeyHints api={props.api} hints={[{ key: "esc", label: "back" }]} />
|
|
1415
|
+
</box>
|
|
1416
|
+
<box flexDirection="row" justifyContent="space-between" width="100%" marginBottom={1}>
|
|
1417
|
+
<text fg={theme().textMuted}>Current</text>
|
|
1418
|
+
<text fg={theme().primary}><b>{value()}%</b></text>
|
|
1419
|
+
</box>
|
|
1420
|
+
<box flexDirection="row" width="100%" marginBottom={1}>
|
|
1421
|
+
<text fg={theme().textMuted}>{HEIGHT_PERCENT_MIN}% </text>
|
|
1422
|
+
<For each={sliderCells()}>{(cell) => <text fg={cell.color}>{cell.char}</text>}</For>
|
|
1423
|
+
<text fg={theme().textMuted}> {HEIGHT_PERCENT_MAX}%</text>
|
|
1424
|
+
</box>
|
|
1425
|
+
<box flexDirection="column" gap={0} marginBottom={1}>
|
|
1426
|
+
<For each={HEIGHT_PRESETS}>{(preset) => <text fg={value() === preset.value ? theme().primary : theme().textMuted}>{preset.key} {preset.label}: {preset.value}%</text>}</For>
|
|
1427
|
+
</box>
|
|
1428
|
+
<box flexDirection="column" gap={0} marginBottom={1}>
|
|
1429
|
+
<KeyHints api={props.api} hints={[{ key: "left/right", label: "adjust 1%" }]} />
|
|
1430
|
+
<KeyHints api={props.api} hints={[{ key: "up/down", label: "adjust 5%" }]} />
|
|
1431
|
+
<KeyHints api={props.api} hints={[{ key: "c", label: "custom percent" }]} />
|
|
1432
|
+
</box>
|
|
1433
|
+
<box flexDirection="row" justifyContent="space-between" width="100%">
|
|
1434
|
+
<KeyHints api={props.api} hints={[{ key: "enter", label: "save" }]} />
|
|
1435
|
+
<box paddingLeft={3} paddingRight={3} backgroundColor={theme().primary} onMouseUp={() => props.onDone({ action: "save", value: value() })}>
|
|
1436
|
+
<text fg={theme().background}><b>save</b></text>
|
|
1437
|
+
</box>
|
|
1438
|
+
</box>
|
|
1439
|
+
</box>
|
|
1440
|
+
)
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async function viewDebugLog(api: TuiPluginApi) {
|
|
1444
|
+
const file = debugLogPath(defaultConfigDir())
|
|
1445
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "No debug log found."
|
|
1446
|
+
await showInfo(api, { title: "Souk Debug Log", message: text.split(/\r?\n/).slice(-80).join("\n") || "Debug log is empty." })
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async function configBackupsMenu(api: TuiPluginApi, config: SidecarConfig): Promise<SidecarConfig> {
|
|
1450
|
+
while (true) {
|
|
1451
|
+
const journal = safeLoadBackupJournal()
|
|
1452
|
+
const action = await showMenu(api, {
|
|
1453
|
+
title: "Config backups",
|
|
1454
|
+
options: [
|
|
1455
|
+
{ title: "Browse / restore backups", value: "browse", description: `${journal.patches.length} patch, ${journal.full.length} full`, help: "Preview and restore patch restore points or full snapshots. Patch restore validates the hash chain before writing." },
|
|
1456
|
+
{ title: "Create full backup", value: "create", description: "Snapshot current souk.jsonc" },
|
|
1457
|
+
{ title: "Delete full backups", value: "delete-full", description: `${journal.full.length} full backup(s)` },
|
|
1458
|
+
{ title: "Back", value: "back", description: "Return to Debug & advanced" },
|
|
1459
|
+
],
|
|
1460
|
+
})
|
|
1461
|
+
if (!action || action === "back") return config
|
|
1462
|
+
if (action === "create") {
|
|
1463
|
+
createFullBackup(config, { label: "Manual backup" })
|
|
1464
|
+
api.ui.toast({ variant: "success", message: "Full Souk config backup created." })
|
|
1465
|
+
continue
|
|
1466
|
+
}
|
|
1467
|
+
if (action === "browse") {
|
|
1468
|
+
const restored = await browseConfigBackups(api, config)
|
|
1469
|
+
if (restored !== config) return restored
|
|
1470
|
+
continue
|
|
1471
|
+
}
|
|
1472
|
+
if (action === "delete-full") {
|
|
1473
|
+
await deleteFullBackupsMenu(api)
|
|
1474
|
+
continue
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
async function browseConfigBackups(api: TuiPluginApi, config: SidecarConfig): Promise<SidecarConfig> {
|
|
1480
|
+
const journal = safeLoadBackupJournal()
|
|
1481
|
+
const items = backupListItems(config, journal)
|
|
1482
|
+
if (items.length === 0) {
|
|
1483
|
+
await showInfo(api, { title: "Config backups", message: `No backups found.\n\nJournal: ${backupJournalPath(defaultConfigDir())}` })
|
|
1484
|
+
return config
|
|
1485
|
+
}
|
|
1486
|
+
const picked = await showMenu(api, {
|
|
1487
|
+
title: "Restore backup",
|
|
1488
|
+
options: [...items.map((item) => ({ title: item.title, value: itemKey(item), description: item.description, danger: item.kind === "patch" && !item.valid, help: backupItemHelp(item) })), { title: "Back", value: "__back__", description: "Return to Config backups" }],
|
|
1489
|
+
})
|
|
1490
|
+
if (!picked || picked === "__back__") return config
|
|
1491
|
+
const item = items.find((candidate) => itemKey(candidate) === picked)
|
|
1492
|
+
if (!item) return config
|
|
1493
|
+
const restored = item.kind === "patch" ? reconstructPatchBackup(item.index, config, journal) : { ok: true as const, config: item.entry.config }
|
|
1494
|
+
if (!restored.ok) {
|
|
1495
|
+
await showInfo(api, { title: "Backup chain invalid", message: restored.message })
|
|
1496
|
+
return config
|
|
1497
|
+
}
|
|
1498
|
+
await showInfo(api, { title: item.title, message: backupPreview(item, restored.config) })
|
|
1499
|
+
const ok = await showConfirm(api, { title: "Restore this backup?", message: "This writes souk.jsonc to the previewed state." })
|
|
1500
|
+
if (!ok) return config
|
|
1501
|
+
const hardBackup = await showConfirm(api, { title: "Create full backup first?", message: "Optional safety snapshot of the current config before restoring. Default is No." })
|
|
1502
|
+
if (hardBackup) createFullBackup(config, { label: "Before restore" })
|
|
1503
|
+
if (item.kind === "patch") {
|
|
1504
|
+
const latest = safeLoadBackupJournal()
|
|
1505
|
+
const result = reconstructPatchBackup(item.index, loadSidecar(defaultSidecarPath()), latest)
|
|
1506
|
+
if (!result.ok) {
|
|
1507
|
+
await showInfo(api, { title: "Restore failed", message: result.message })
|
|
1508
|
+
return config
|
|
1509
|
+
}
|
|
1510
|
+
saveSidecar(result.config, defaultSidecarPath(), { backup: false, allowInvalidPrevious: true })
|
|
1511
|
+
latest.patches = latest.patches.slice(result.consumed)
|
|
1512
|
+
saveBackupJournal(latest)
|
|
1513
|
+
api.ui.toast({ variant: "success", message: "Config restored; consumed patch backups were removed." })
|
|
1514
|
+
return result.config
|
|
1515
|
+
}
|
|
1516
|
+
const latest = safeLoadBackupJournal()
|
|
1517
|
+
saveSidecar(restored.config, defaultSidecarPath(), { backup: false, allowInvalidPrevious: true })
|
|
1518
|
+
latest.patches = []
|
|
1519
|
+
saveBackupJournal(latest)
|
|
1520
|
+
api.ui.toast({ variant: "success", message: "Full backup restored; patch restore chain was reset." })
|
|
1521
|
+
return restored.config
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
async function deleteFullBackupsMenu(api: TuiPluginApi) {
|
|
1525
|
+
const journal = safeLoadBackupJournal()
|
|
1526
|
+
if (journal.full.length === 0) {
|
|
1527
|
+
await showInfo(api, { title: "Delete full backups", message: "No full backups found." })
|
|
1528
|
+
return
|
|
1529
|
+
}
|
|
1530
|
+
const picked = await showMenu(api, {
|
|
1531
|
+
title: "Delete full backup",
|
|
1532
|
+
options: [
|
|
1533
|
+
{ title: "Delete all full backups", value: "__all__", description: `${journal.full.length} full backup(s)`, danger: true },
|
|
1534
|
+
...journal.full.map((entry) => ({ title: `Delete ${formatBackupTime(entry.timestamp)}`, value: entry.id, description: entry.label ?? entry.hash.slice(0, 8), danger: true })),
|
|
1535
|
+
{ title: "Back", value: "__back__", description: "Return to Config backups" },
|
|
1536
|
+
],
|
|
1537
|
+
})
|
|
1538
|
+
if (!picked || picked === "__back__") return
|
|
1539
|
+
const ok = await showConfirm(api, { title: "Delete backup?", message: picked === "__all__" ? "Delete all full Souk config backups?" : "Delete this full Souk config backup?" })
|
|
1540
|
+
if (!ok) return
|
|
1541
|
+
if (picked === "__all__") deleteAllFullBackups()
|
|
1542
|
+
else deleteFullBackup(picked)
|
|
1543
|
+
api.ui.toast({ variant: "warning", message: "Full backup deleted." })
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function safeLoadBackupJournal(): BackupJournal {
|
|
1547
|
+
try {
|
|
1548
|
+
return loadBackupJournal()
|
|
1549
|
+
} catch {
|
|
1550
|
+
return { version: 1, patch_limit: 50, patches: [], full: [] }
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function backupListItems(config: SidecarConfig, journal: BackupJournal): BackupListItem[] {
|
|
1555
|
+
return [
|
|
1556
|
+
...journal.patches.map((entry, index): BackupListItem => {
|
|
1557
|
+
const restored = reconstructPatchBackup(index, config, journal)
|
|
1558
|
+
return { kind: "patch", index, valid: restored.ok, title: `Patch ${index + 1} ${formatBackupTime(entry.timestamp)}`, description: restored.ok ? entry.changed_paths.slice(0, 3).join(", ") || "config change" : "invalid hash chain" }
|
|
1559
|
+
}),
|
|
1560
|
+
...journal.full.map((entry): BackupListItem => ({ kind: "full", id: entry.id, title: `Full ${formatBackupTime(entry.timestamp)}`, description: entry.label ?? entry.hash.slice(0, 8), entry })),
|
|
1561
|
+
]
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function itemKey(item: BackupListItem) {
|
|
1565
|
+
return item.kind === "patch" ? `patch:${item.index}` : `full:${item.id}`
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function backupItemHelp(item: BackupListItem) {
|
|
1569
|
+
return item.kind === "patch" ? `Patch restore point.\n\nValid: ${item.valid ? "yes" : "no"}\nChanged paths: ${item.description}` : `Full config snapshot.\n\n${item.description}`
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function backupPreview(item: BackupListItem, config: SidecarConfig) {
|
|
1573
|
+
return [item.title, item.description, "", JSON.stringify(config, null, 2)].join("\n")
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function formatBackupTime(timestamp: string) {
|
|
1577
|
+
return timestamp.replace("T", " ").replace(/\.\d+Z$/, "Z")
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
async function showSoukInfo(api: TuiPluginApi) {
|
|
1581
|
+
await showInfo(api, {
|
|
1582
|
+
title: "Souk Info",
|
|
1583
|
+
message: [
|
|
1584
|
+
"What Souk Is",
|
|
1585
|
+
"OpenCode Souk is a TUI-native extension bazaar. It replaces the vanilla plugin manager with browsing, inspection, deterministic installs, and optional Kāf Forge assistance.",
|
|
1586
|
+
"",
|
|
1587
|
+
"Main Menu",
|
|
1588
|
+
"Browse items opens the marketplace browser. Installed plugins opens the runtime plugin management view. Refresh sources updates the marketplace cache. Kāf Forge settings configures experimental agent-assisted installs. Diagnostics checks runtime health. Debug & advanced contains logs, backups, and UI size controls.",
|
|
1589
|
+
"",
|
|
1590
|
+
"Contextual Help",
|
|
1591
|
+
"Press i on menu rows, install actions, scopes, settings, backups, and browser items. Souk should explain what the highlighted thing does, where data comes from, what it may write, and what risks matter.",
|
|
1592
|
+
"",
|
|
1593
|
+
"Item Browser",
|
|
1594
|
+
"Browse items shows marketplace/cache entries only. Installed plugins are intentionally excluded. Enter toggles selection. Space opens the install menu for selected items, or for the highlighted item when nothing is selected. / searches names, descriptions, tags, kinds, sources, repos, aliases, and install specs. i opens an item appraisal and returns to the browser. s opens Kāf settings and returns to the browser. r refreshes sources and returns to the browser. Shift+Up/Down or Ctrl+Up/Down jumps kind groups. Shift+Left/Right jumps tag groups. Esc returns to the main menu.",
|
|
1595
|
+
"",
|
|
1596
|
+
"Installed Plugins",
|
|
1597
|
+
"Installed plugins is a separate runtime management view. It is table-shaped like the item browser, but has no batch selection, install menu, Forge, preview, or scope prompt. Enter toggles the highlighted plugin on or off through OpenCode's plugin activate/deactivate API. i opens plugin details. r refreshes the plugin list. Esc returns to the main menu.",
|
|
1598
|
+
"",
|
|
1599
|
+
"Native Installs",
|
|
1600
|
+
"Souk installs plugins, MCPs, agents, commands, themes, and skills deterministically when metadata is sufficient. Plugins use OpenCode's plugin API. MCPs patch OpenCode config and may connect/auth at runtime. Agents, commands, themes, and skills write conventional OpenCode files/config.",
|
|
1601
|
+
"",
|
|
1602
|
+
"Native Install Safety",
|
|
1603
|
+
"Souk previews planned writes, creates pre-install backup snapshots, preserves JSONC comments, uses atomic writes, and refuses conflicting overwrites. Claude mcpServers conversion requires explicit approval with a diff preview. Souk does not guess when it cannot prove an install path.",
|
|
1604
|
+
"",
|
|
1605
|
+
"Install Scope",
|
|
1606
|
+
"Both native installs and Kāf Forge ask for scope before persistent writes. Global scope writes to the global OpenCode config directory. Project scope writes under the selected project's .opencode directory/config.",
|
|
1607
|
+
"",
|
|
1608
|
+
"Forge And Kāf",
|
|
1609
|
+
"Forge is experimental and optional. Kāf can assist with any selected item, including verified items. Kāf starts in a read-only planning agent, must analyze security, and must ask yes/no before switching to write-capable action mode.",
|
|
1610
|
+
"",
|
|
1611
|
+
"Kāf Security Review",
|
|
1612
|
+
"Kāf must inspect source provenance, package risk, config writes, permissions, shell commands, OAuth/token handling, backups, and installation instructions. Kāf should plan manual installs when native tools may fail or lose required setup semantics. Warnings drop personality and use direct language.",
|
|
1613
|
+
"",
|
|
1614
|
+
"Confidence Labels",
|
|
1615
|
+
"Verified means Souk has a charted path and may offer deterministic install. Partial means some mapping exists but review is still needed. Unmapped or unvetted items need extra caution. Anything below 100% confidence must say: The souk can make mistakes.",
|
|
1616
|
+
"",
|
|
1617
|
+
"Sources And Cache",
|
|
1618
|
+
`Cache file: ${defaultCachePath()}`,
|
|
1619
|
+
"Souk can read opencode.cafe, awesome-opencode, and OpenCode ecosystem docs when configured. It records per-source raw/normalized counts and parser warnings. It fetches when cache is empty or when you explicitly refresh. Refreshing sources does not install anything.",
|
|
1620
|
+
"Deduped combo items preserve all contributing sources, choose the richest primary record for display, and show alternate kinds when sources disagree.",
|
|
1621
|
+
"",
|
|
1622
|
+
"Debug And Backups",
|
|
1623
|
+
`Debug log: ${debugLogPath(defaultConfigDir())}`,
|
|
1624
|
+
`Backup journal: ${backupJournalPath(defaultConfigDir())}`,
|
|
1625
|
+
"Souk can write best-effort debug logs, create full config snapshots, and keep patch restore points for meaningful sidecar config changes. Native installs also create pre-install snapshots under souk-install-backups. UI/debug-only saves avoid backup spam.",
|
|
1626
|
+
"",
|
|
1627
|
+
"Restart Honesty",
|
|
1628
|
+
"Some UI settings are hot. Kāf agent config, installed files, plugins, skills, agents, commands, and many OpenCode config changes may require a full OpenCode restart before every surface reflects them.",
|
|
1629
|
+
].join("\n"),
|
|
1630
|
+
})
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
async function showMenu<Value>(api: TuiPluginApi, props: { title: string; options: WizardSelectOption<Value>[]; current?: Value }): Promise<Value | undefined> {
|
|
1634
|
+
let current = props.current
|
|
1635
|
+
while (true) {
|
|
1636
|
+
const choice = await showMenuOnce(api, { ...props, current })
|
|
1637
|
+
if (!choice) return undefined
|
|
1638
|
+
current = choice.value
|
|
1639
|
+
if (choice.action === "select") return choice.value
|
|
1640
|
+
const option = props.options.find((item) => item.value === choice.value)
|
|
1641
|
+
await showInfo(api, { title: option?.title ?? props.title, message: option?.help ?? option?.description ?? "No extra help is available for this option." })
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; options: WizardSelectOption<Value>[]; current?: Value }): Promise<MenuChoice<Value> | undefined> {
|
|
1646
|
+
return new Promise((resolve) => {
|
|
1647
|
+
let settled = false
|
|
1648
|
+
const done = (value: MenuChoice<Value> | undefined, clear = true) => {
|
|
1649
|
+
if (settled) return
|
|
1650
|
+
settled = true
|
|
1651
|
+
resolve(value)
|
|
1652
|
+
if (clear) api.ui.dialog.clear()
|
|
1653
|
+
}
|
|
1654
|
+
api.ui.dialog.replace(() => <MenuDialog api={api} title={props.title} options={props.options} current={props.current} onDone={done} />, () => done(undefined, false))
|
|
1655
|
+
})
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function MenuDialog<Value>(props: { api: TuiPluginApi; title: string; options: WizardSelectOption<Value>[]; current?: Value; onDone: (value: MenuChoice<Value> | undefined) => void }) {
|
|
1659
|
+
const theme = () => props.api.theme.current
|
|
1660
|
+
useWizardDialogSize(props.api)
|
|
1661
|
+
useHidePromptCursor(props.api)
|
|
1662
|
+
const dimensions = useTerminalDimensions()
|
|
1663
|
+
const listHeight = createMemo(() => cappedHeight(props.options.length, wizardMaxRows(props.api, dimensions().height, 14, 6)))
|
|
1664
|
+
const titleWidth = createMemo(() => menuTitleWidth(wizardDialogSize(props.api), props.options))
|
|
1665
|
+
const popMode = props.api.mode.push(MODE)
|
|
1666
|
+
let scroll: ScrollBoxRenderable | undefined
|
|
1667
|
+
const [selected, setSelected] = createSignal(Math.max(0, props.options.findIndex((option) => option.value === props.current)))
|
|
1668
|
+
const current = createMemo(() => props.options[selected()] ?? props.options[0])
|
|
1669
|
+
const move = (delta: number) => setSelected((value) => {
|
|
1670
|
+
const next = Math.max(0, Math.min(props.options.length - 1, value + delta))
|
|
1671
|
+
scroll?.scrollTo(Math.max(0, next - 2))
|
|
1672
|
+
return next
|
|
1673
|
+
})
|
|
1674
|
+
const choose = () => { const option = current(); if (!option || option.disabled) return; props.onDone({ action: "select", value: option.value }) }
|
|
1675
|
+
const inspect = () => { const option = current(); if (!option || option.disabled) return; props.onDone({ action: "inspect", value: option.value }) }
|
|
1676
|
+
const commandPrefix = `souk.menu.${Math.random().toString(36).slice(2)}`
|
|
1677
|
+
const unregister = props.api.keymap.registerLayer({
|
|
1678
|
+
priority: 10000,
|
|
1679
|
+
commands: [
|
|
1680
|
+
{ name: `${commandPrefix}.up`, title: "Previous item", run: (ctx: KeyContext) => { blockKey(ctx); move(-1) } },
|
|
1681
|
+
{ name: `${commandPrefix}.down`, title: "Next item", run: (ctx: KeyContext) => { blockKey(ctx); move(1) } },
|
|
1682
|
+
{ name: `${commandPrefix}.select`, title: "Select item", run: (ctx: KeyContext) => { blockKey(ctx); choose() } },
|
|
1683
|
+
{ name: `${commandPrefix}.inspect`, title: "Option help", run: (ctx: KeyContext) => { blockKey(ctx); inspect() } },
|
|
1684
|
+
{ name: `${commandPrefix}.back`, title: "Back", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone(undefined) } },
|
|
1685
|
+
{ name: `${commandPrefix}.shield`, title: "Block background input", run: blockKey },
|
|
1686
|
+
],
|
|
1687
|
+
bindings: [
|
|
1688
|
+
{ key: "up", cmd: `${commandPrefix}.up`, desc: "Previous item" },
|
|
1689
|
+
{ key: "ctrl+p", cmd: `${commandPrefix}.up`, desc: "Previous item" },
|
|
1690
|
+
{ key: "down", cmd: `${commandPrefix}.down`, desc: "Next item" },
|
|
1691
|
+
{ key: "ctrl+n", cmd: `${commandPrefix}.down`, desc: "Next item" },
|
|
1692
|
+
{ key: "enter", cmd: `${commandPrefix}.select`, desc: "Select item" },
|
|
1693
|
+
{ key: "i", cmd: `${commandPrefix}.inspect`, desc: "Option help" },
|
|
1694
|
+
{ key: "escape", cmd: `${commandPrefix}.back`, desc: "Back" },
|
|
1695
|
+
...shieldBindings(`${commandPrefix}.shield`, ["i"]),
|
|
1696
|
+
],
|
|
1697
|
+
} as any)
|
|
1698
|
+
onCleanup(() => { unregister(); popMode() })
|
|
1699
|
+
return (
|
|
1700
|
+
<box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
1701
|
+
<box flexDirection="row" justifyContent="space-between" width="100%" marginBottom={1}>
|
|
1702
|
+
<text fg={theme().text}><b>{props.title}</b></text>
|
|
1703
|
+
<box onMouseUp={() => props.onDone(undefined)}><KeyHints api={props.api} hints={[{ key: "esc", label: "back" }]} /></box>
|
|
1704
|
+
</box>
|
|
1705
|
+
<box marginBottom={1}>
|
|
1706
|
+
<KeyHints api={props.api} hints={[{ key: "enter", label: "select" }, { key: "up/down", label: "move" }, { key: "i", label: "help" }]} />
|
|
1707
|
+
</box>
|
|
1708
|
+
<scrollbox maxHeight={listHeight()} ref={(element: ScrollBoxRenderable) => (scroll = element)}>
|
|
1709
|
+
<box flexDirection="column" gap={0}>
|
|
1710
|
+
<For each={props.options}>{(option, index) => {
|
|
1711
|
+
const active = createMemo(() => selected() === index())
|
|
1712
|
+
const fg = createMemo(() => active() ? theme().background : option.danger ? theme().error : option.color ?? theme().text)
|
|
1713
|
+
const descFg = createMemo(() => active() ? theme().background : theme().textMuted)
|
|
1714
|
+
return <box flexDirection="row" width="100%" gap={1} paddingLeft={1} paddingRight={1} backgroundColor={active() ? theme().primary : theme().backgroundPanel} onMouseOver={() => setSelected(index())} onMouseUp={() => { if (!option.disabled) props.onDone({ action: "select", value: option.value }) }}>
|
|
1715
|
+
<text width={titleWidth()} flexShrink={0} fg={fg()} wrapMode="none" overflow="hidden"><b>{option.title}</b></text>
|
|
1716
|
+
<text flexGrow={1} fg={descFg()} wrapMode="none" overflow="hidden">{option.description ?? ""}</text>
|
|
1717
|
+
</box>
|
|
1718
|
+
}}</For>
|
|
1719
|
+
</box>
|
|
1720
|
+
</scrollbox>
|
|
1721
|
+
</box>
|
|
1722
|
+
)
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function showPrompt(api: TuiPluginApi, props: { title: string; placeholder?: string; value?: string }): Promise<string | undefined> {
|
|
1726
|
+
return new Promise((resolve) => {
|
|
1727
|
+
let settled = false
|
|
1728
|
+
const done = (value: string | undefined) => {
|
|
1729
|
+
if (settled) return
|
|
1730
|
+
settled = true
|
|
1731
|
+
resolve(value)
|
|
1732
|
+
}
|
|
1733
|
+
api.ui.dialog.replace(() =>
|
|
1734
|
+
api.ui.DialogPrompt({
|
|
1735
|
+
title: props.title,
|
|
1736
|
+
placeholder: props.placeholder,
|
|
1737
|
+
value: props.value ?? "",
|
|
1738
|
+
onConfirm: (value) => {
|
|
1739
|
+
done(value)
|
|
1740
|
+
api.ui.dialog.clear()
|
|
1741
|
+
},
|
|
1742
|
+
onCancel: () => {
|
|
1743
|
+
done(undefined)
|
|
1744
|
+
api.ui.dialog.clear()
|
|
1745
|
+
},
|
|
1746
|
+
}),
|
|
1747
|
+
() => done(undefined),
|
|
1748
|
+
)
|
|
1749
|
+
})
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function showConfirm(api: TuiPluginApi, props: { title: string; message: string }): Promise<boolean> {
|
|
1753
|
+
return new Promise((resolve) => {
|
|
1754
|
+
let settled = false
|
|
1755
|
+
const done = (value: boolean) => {
|
|
1756
|
+
if (settled) return
|
|
1757
|
+
settled = true
|
|
1758
|
+
resolve(value)
|
|
1759
|
+
}
|
|
1760
|
+
api.ui.dialog.replace(() =>
|
|
1761
|
+
api.ui.DialogConfirm({
|
|
1762
|
+
title: props.title,
|
|
1763
|
+
message: props.message,
|
|
1764
|
+
onConfirm: () => {
|
|
1765
|
+
done(true)
|
|
1766
|
+
api.ui.dialog.clear()
|
|
1767
|
+
},
|
|
1768
|
+
onCancel: () => {
|
|
1769
|
+
done(false)
|
|
1770
|
+
api.ui.dialog.clear()
|
|
1771
|
+
},
|
|
1772
|
+
}),
|
|
1773
|
+
() => done(false),
|
|
1774
|
+
)
|
|
1775
|
+
})
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function showInfo(api: TuiPluginApi, props: { title: string; message: string }): Promise<void> {
|
|
1779
|
+
return new Promise((resolve) => {
|
|
1780
|
+
let settled = false
|
|
1781
|
+
const done = () => {
|
|
1782
|
+
if (settled) return
|
|
1783
|
+
settled = true
|
|
1784
|
+
resolve()
|
|
1785
|
+
api.ui.dialog.clear()
|
|
1786
|
+
}
|
|
1787
|
+
api.ui.dialog.replace(() => <InfoDialog api={api} title={props.title} message={props.message} onDone={done} />, done)
|
|
1788
|
+
})
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function InfoDialog(props: { api: TuiPluginApi; title: string; message: string; onDone: () => void }) {
|
|
1792
|
+
const theme = () => props.api.theme.current
|
|
1793
|
+
useWizardDialogSize(props.api)
|
|
1794
|
+
useHidePromptCursor(props.api)
|
|
1795
|
+
const dimensions = useTerminalDimensions()
|
|
1796
|
+
const popMode = props.api.mode.push(MODE)
|
|
1797
|
+
const lines = createMemo(() => props.message.split(/\r?\n/))
|
|
1798
|
+
const visualRows = createMemo(() => estimatedVisualRows(props.message, dialogContentWidth(props.api)))
|
|
1799
|
+
const bodyHeight = createMemo(() => cappedHeight(visualRows() + 1, wizardMaxRows(props.api, dimensions().height, 13, 4), 4))
|
|
1800
|
+
let scroll: ScrollBoxRenderable | undefined
|
|
1801
|
+
const page = () => Math.max(1, (scroll?.height ?? bodyHeight()) - 1)
|
|
1802
|
+
const commandPrefix = `souk.info.${Math.random().toString(36).slice(2)}`
|
|
1803
|
+
const unregister = props.api.keymap.registerLayer({
|
|
1804
|
+
priority: 10000,
|
|
1805
|
+
commands: [
|
|
1806
|
+
{ name: `${commandPrefix}.close`, title: "Close", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone() } },
|
|
1807
|
+
{ name: `${commandPrefix}.up`, title: "Scroll up", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollBy(-1) } },
|
|
1808
|
+
{ name: `${commandPrefix}.down`, title: "Scroll down", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollBy(1) } },
|
|
1809
|
+
{ name: `${commandPrefix}.pageUp`, title: "Page up", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollBy(-page()) } },
|
|
1810
|
+
{ name: `${commandPrefix}.pageDown`, title: "Page down", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollBy(page()) } },
|
|
1811
|
+
{ name: `${commandPrefix}.home`, title: "Scroll top", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollTo(0) } },
|
|
1812
|
+
{ name: `${commandPrefix}.end`, title: "Scroll bottom", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollTo(scroll.scrollHeight) } },
|
|
1813
|
+
{ name: `${commandPrefix}.shield`, title: "Block background input", run: blockKey },
|
|
1814
|
+
],
|
|
1815
|
+
bindings: [
|
|
1816
|
+
{ key: "enter", cmd: `${commandPrefix}.close`, desc: "Close" },
|
|
1817
|
+
{ key: "escape", cmd: `${commandPrefix}.close`, desc: "Close" },
|
|
1818
|
+
{ key: "up", cmd: `${commandPrefix}.up`, desc: "Scroll up" },
|
|
1819
|
+
{ key: "ctrl+p", cmd: `${commandPrefix}.up`, desc: "Scroll up" },
|
|
1820
|
+
{ key: "down", cmd: `${commandPrefix}.down`, desc: "Scroll down" },
|
|
1821
|
+
{ key: "ctrl+n", cmd: `${commandPrefix}.down`, desc: "Scroll down" },
|
|
1822
|
+
{ key: "pageup", cmd: `${commandPrefix}.pageUp`, desc: "Page up" },
|
|
1823
|
+
{ key: "ctrl+b", cmd: `${commandPrefix}.pageUp`, desc: "Page up" },
|
|
1824
|
+
{ key: "pagedown", cmd: `${commandPrefix}.pageDown`, desc: "Page down" },
|
|
1825
|
+
{ key: "ctrl+f", cmd: `${commandPrefix}.pageDown`, desc: "Page down" },
|
|
1826
|
+
{ key: "home", cmd: `${commandPrefix}.home`, desc: "Scroll top" },
|
|
1827
|
+
{ key: "end", cmd: `${commandPrefix}.end`, desc: "Scroll bottom" },
|
|
1828
|
+
...shieldBindings(`${commandPrefix}.shield`, ["home", "end"]),
|
|
1829
|
+
],
|
|
1830
|
+
} as any)
|
|
1831
|
+
onCleanup(() => { unregister(); popMode() })
|
|
1832
|
+
|
|
1833
|
+
return (
|
|
1834
|
+
<box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
1835
|
+
<box flexDirection="row" justifyContent="space-between" width="100%" marginBottom={1}>
|
|
1836
|
+
<text fg={theme().accent}><b>{props.title}</b></text>
|
|
1837
|
+
<box onMouseUp={props.onDone}><KeyHints api={props.api} hints={[{ key: "esc", label: "close" }]} /></box>
|
|
1838
|
+
</box>
|
|
1839
|
+
<scrollbox maxHeight={bodyHeight()} ref={(element: ScrollBoxRenderable) => (scroll = element)}>
|
|
1840
|
+
<box flexDirection="column" gap={0}>
|
|
1841
|
+
<For each={lines()}>{(line) => {
|
|
1842
|
+
const heading = line.length > 0 && !line.startsWith(" ") && (line.endsWith(":") || /^[A-Z][A-Za-z ]+$/.test(line))
|
|
1843
|
+
const warning = /restart|required|red|warning|risk|mistakes|conflict|refuses|failed|error/i.test(line)
|
|
1844
|
+
const positive = /hot reload|yes|saved|enabled|verified|installed|success/i.test(line)
|
|
1845
|
+
return line.length === 0
|
|
1846
|
+
? <text> </text>
|
|
1847
|
+
: <text fg={warning ? theme().error : positive ? theme().success : heading ? theme().accent : theme().textMuted} wrapMode="word">{heading ? <b>{line}</b> : line}</text>
|
|
1848
|
+
}}</For>
|
|
1849
|
+
</box>
|
|
1850
|
+
</scrollbox>
|
|
1851
|
+
<box flexDirection="row" justifyContent="space-between" width="100%">
|
|
1852
|
+
<Show when={visualRows() > bodyHeight()}><KeyHints api={props.api} hints={[{ key: "up/down", label: "scroll" }]} /></Show>
|
|
1853
|
+
<box paddingLeft={3} paddingRight={3} backgroundColor={theme().primary} onMouseUp={props.onDone}>
|
|
1854
|
+
<text fg={theme().background}><b>ok</b></text>
|
|
1855
|
+
</box>
|
|
1856
|
+
</box>
|
|
1857
|
+
</box>
|
|
1858
|
+
)
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function registerCommands(api: TuiPluginApi) {
|
|
1862
|
+
return api.keymap.registerLayer({
|
|
1863
|
+
priority: 1000,
|
|
1864
|
+
commands: [
|
|
1865
|
+
{
|
|
1866
|
+
name: "souk.open",
|
|
1867
|
+
title: "Souk",
|
|
1868
|
+
category: "Plugin Manager",
|
|
1869
|
+
namespace: "palette",
|
|
1870
|
+
slashName: "souk",
|
|
1871
|
+
run() {
|
|
1872
|
+
void openSouk(api)
|
|
1873
|
+
},
|
|
1874
|
+
},
|
|
1875
|
+
],
|
|
1876
|
+
bindings: [],
|
|
1877
|
+
} as any)
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
const tui: TuiPlugin = async (api) => {
|
|
1881
|
+
const replaced = await api.plugins.deactivate(INTERNAL_PLUGIN_MANAGER).catch(() => false)
|
|
1882
|
+
if (!replaced) {
|
|
1883
|
+
api.ui.toast({
|
|
1884
|
+
variant: "warning",
|
|
1885
|
+
message: "Souk could not deactivate the built-in plugin manager. If vanilla Plugins still opens, disable internal:plugin-manager in tui.jsonc.",
|
|
1886
|
+
})
|
|
1887
|
+
}
|
|
1888
|
+
const unregister = registerCommands(api)
|
|
1889
|
+
api.lifecycle.onDispose(() => unregister())
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
export default { id: "opencode-souk", tui }
|