/** * Interactive TUI browser for the Obsidian CLI, opened with /obsidian. * * Views: * list → filterable command list with risk badges * args → one-line argument editor for the selected command * output → scrollable result view * * Keys: ↑/↓ navigate · / filter · enter run/edit · m cycle permission mode * (session-only) · v set vault · r rediscover catalog · esc back/quit */ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { decide, type Catalog, type CliCommand } from "./catalog.ts"; import type { ObsidianCliConfig, PermissionMode } from "./config.ts"; import { paramsToArgs } from "./runner.ts"; import { RISK_BADGE, formatTsv, isTsv } from "./render.ts"; type ThemeLike = { fg: (color: string, text: string) => string; bg: (color: string, text: string) => string; bold: (text: string) => string; }; interface Host { exec: ( command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }, ) => Promise<{ stdout: string; stderr: string; code: number; killed: boolean }>; } export interface BrowserOptions { config: ObsidianCliConfig; catalog: Catalog; host: Host; theme: ThemeLike; vault?: string; permissionMode: PermissionMode; refreshCatalog: () => Promise; onVaultChange: (vault?: string) => void; onModeChange: (mode: PermissionMode) => void; /** Request a TUI repaint; required after async state changes. */ requestRender: () => void; done: (value?: unknown) => void; /** Execute through the extension policy/confirmation pipeline. */ executeCommand: (command: string, args: string[], vault?: string) => Promise<{ stdout?: string; durationMs?: number; }>; } type View = "list" | "args" | "output" | "vault"; const MODE_CYCLE: PermissionMode[] = ["read-only", "all", "custom"]; const PAGE = 14; function commonPrefix(strings: string[]): string { if (strings.length === 0) return ""; let prefix = strings[0]; for (const s of strings.slice(1)) { while (!s.startsWith(prefix)) prefix = prefix.slice(0, -1); if (prefix === "") break; } return prefix; } export class ObsidianBrowser { private view: View = "list"; private catalog: Catalog; private items: CliCommand[] = []; private selected = 0; private scroll = 0; private filter = ""; private filtering = false; private argsText = ""; private argsCursor = 0; private current?: CliCommand; private outputLines: string[] = []; private outputTitle = ""; private outputScroll = 0; private running = false; private statusMsg = ""; private cachedWidth?: number; private cachedLines?: string[]; private opts: BrowserOptions; constructor(opts: BrowserOptions) { this.opts = opts; this.catalog = opts.catalog; this.rebuildItems(); } private get theme(): ThemeLike { return this.opts.theme; } private rebuildItems(): void { const all = [...this.catalog.commands.values()] .filter((cmd) => !this.isBlocked(cmd)) .sort((a, b) => a.name.localeCompare(b.name)); const f = this.filter.toLowerCase(); this.items = f ? all.filter((c) => c.name.includes(f) || c.description.toLowerCase().includes(f)) : all; if (this.selected >= this.items.length) this.selected = Math.max(0, this.items.length - 1); this.scroll = Math.min(this.scroll, Math.max(0, this.items.length - PAGE)); } private effectiveConfig(): ObsidianCliConfig { return { ...this.opts.config, permissionMode: this.opts.permissionMode }; } private isBlocked(cmd: CliCommand): boolean { return !decide(this.effectiveConfig(), cmd, cmd.name).allowed; } private mode(): PermissionMode { return this.opts.permissionMode; } private vault(): string | undefined { return this.opts.vault ?? this.opts.config.vault; } invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; } // ------------------------------------------------------------------ input handleInput(data: string): void { if (this.running) return; switch (this.view) { case "list": this.handleListInput(data); break; case "args": this.handleArgsInput(data); break; case "output": this.handleOutputInput(data); break; case "vault": this.handleVaultInput(data); break; } this.invalidate(); } private handleListInput(data: string): void { if (this.filtering) { if (matchesKey(data, Key.escape)) { this.filtering = false; this.filter = ""; } else if (matchesKey(data, Key.enter)) { this.filtering = false; } else if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) { this.autocompleteFilter(matchesKey(data, Key.shift("tab")) ? -1 : 1); return; // autocompleteFilter rebuilds items itself } else if (matchesKey(data, Key.backspace)) { this.filter = this.filter.slice(0, -1); this.selected = 0; this.scroll = 0; } else if (data.length === 1 && data >= " ") { this.filter += data; this.selected = 0; this.scroll = 0; } this.rebuildItems(); return; } if (matchesKey(data, Key.escape)) { this.opts.done(); } else if (matchesKey(data, Key.up)) { this.selected = Math.max(0, this.selected - 1); if (this.selected < this.scroll) this.scroll = this.selected; } else if (matchesKey(data, Key.down)) { this.selected = Math.min(this.items.length - 1, this.selected + 1); if (this.selected >= this.scroll + PAGE) this.scroll = this.selected - PAGE + 1; } else if (matchesKey(data, Key.pageUp)) { this.selected = Math.max(0, this.selected - PAGE); this.scroll = Math.max(0, this.scroll - PAGE); } else if (matchesKey(data, Key.pageDown)) { this.selected = Math.min(this.items.length - 1, this.selected + PAGE); this.scroll = Math.min(Math.max(0, this.items.length - PAGE), this.scroll + PAGE); } else if (data === "/") { this.filtering = true; } else if (matchesKey(data, Key.enter)) { const cmd = this.items[this.selected]; if (cmd) this.enterArgs(cmd); } else if (data === "m") { const next = MODE_CYCLE[(MODE_CYCLE.indexOf(this.mode()) + 1) % MODE_CYCLE.length]; this.opts.onModeChange(next); this.statusMsg = `permission mode (session): ${next}`; this.rebuildItems(); } else if (data === "v") { this.argsText = this.vault() ?? ""; this.argsCursor = this.argsText.length; this.view = "vault"; } else if (data === "r") { this.statusMsg = "rediscovering catalog…"; void this.opts.refreshCatalog().then((catalog) => { this.catalog = catalog; this.statusMsg = `catalog refreshed (${catalog.source}, ${catalog.commands.size} commands)`; this.rebuildItems(); this.invalidate(); this.opts.requestRender(); }); } } /** * Tab completion while filtering: complete to the longest common prefix of * the current matches; once the filter already equals that prefix, cycle * the selection through the matches (shift+tab cycles backwards). */ private autocompleteFilter(direction: 1 | -1): void { if (this.items.length === 0) return; // Prefer name-prefix matches over substring/description matches so the // completion pool is not diluted (e.g. "diff" matching "syn" via its // description "...sync versions"). const names = this.items.map((i) => i.name); const starts = names.filter((n) => n.startsWith(this.filter)); const contains = names.filter((n) => n.includes(this.filter)); const pool = starts.length > 0 ? starts : contains.length > 0 ? contains : names; const common = commonPrefix(pool); if (common.length > this.filter.length && common.startsWith(this.filter)) { this.filter = common; this.selected = 0; this.scroll = 0; } else { const n = this.items.length; this.selected = (((this.selected + direction) % n) + n) % n; if (this.selected < this.scroll) this.scroll = this.selected; if (this.selected >= this.scroll + PAGE) this.scroll = this.selected - PAGE + 1; } this.rebuildItems(); } private enterArgs(cmd: CliCommand): void { this.current = cmd; this.argsText = ""; this.argsCursor = 0; const flagNames = Object.keys(cmd.flags); if (flagNames.length === 0) { void this.execute(cmd, []); } else { this.view = "args"; } } private handleArgsInput(data: string): void { if (matchesKey(data, Key.escape)) { this.view = "list"; } else if (matchesKey(data, Key.enter)) { const tokens = this.argsText.trim().length > 0 ? this.argsText.trim().split(/\s+/) : []; void this.execute(this.current!, tokens); } else if (matchesKey(data, Key.backspace)) { if (this.argsCursor > 0) { this.argsText = this.argsText.slice(0, this.argsCursor - 1) + this.argsText.slice(this.argsCursor); this.argsCursor--; } } else if (matchesKey(data, Key.left)) { this.argsCursor = Math.max(0, this.argsCursor - 1); } else if (matchesKey(data, Key.right)) { this.argsCursor = Math.min(this.argsText.length, this.argsCursor + 1); } else if (matchesKey(data, Key.home) || matchesKey(data, Key.ctrl("a"))) { this.argsCursor = 0; } else if (matchesKey(data, Key.end) || matchesKey(data, Key.ctrl("e"))) { this.argsCursor = this.argsText.length; } else if (data.length === 1 && data >= " ") { this.argsText = this.argsText.slice(0, this.argsCursor) + data + this.argsText.slice(this.argsCursor); this.argsCursor++; } } private handleVaultInput(data: string): void { if (matchesKey(data, Key.escape)) { this.view = "list"; } else if (matchesKey(data, Key.enter)) { const value = this.argsText.trim(); this.opts.onVaultChange(value.length > 0 ? value : undefined); this.statusMsg = value.length > 0 ? `vault: ${value}` : "vault: (active)"; this.view = "list"; } else if (matchesKey(data, Key.backspace)) { this.argsText = this.argsText.slice(0, -1); } else if (data.length === 1 && data >= " ") { this.argsText += data; } } private handleOutputInput(data: string): void { if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) { this.view = "list"; } else if (matchesKey(data, Key.up)) { this.outputScroll = Math.max(0, this.outputScroll - 1); } else if (matchesKey(data, Key.down)) { this.outputScroll = Math.min(Math.max(0, this.outputLines.length - PAGE), this.outputScroll + 1); } else if (matchesKey(data, Key.pageUp)) { this.outputScroll = Math.max(0, this.outputScroll - PAGE); } else if (matchesKey(data, Key.pageDown)) { this.outputScroll = Math.min(Math.max(0, this.outputLines.length - PAGE), this.outputScroll + PAGE); } } // -------------------------------------------------------------- execution private async execute(cmd: CliCommand, tokens: string[]): Promise { this.running = true; const invocation = [cmd.name, ...tokens].join(" "); const vaultLabel = this.vault(); const fullCmd = `obsidian ${vaultLabel ? `vault=${vaultLabel} ` : ""}${invocation}`; this.statusMsg = `running: ${fullCmd}`; this.invalidate(); const finish = (title: string, lines: string[], status: string): void => { this.running = false; this.outputTitle = title; this.outputLines = lines; this.outputScroll = 0; this.view = "output"; this.statusMsg = status; this.invalidate(); this.opts.requestRender(); }; const decision = decide(this.effectiveConfig(), cmd, cmd.name); if (!decision.allowed) { finish( fullCmd, [this.theme.fg("error", `✗ blocked: ${decision.reason}`)], `✗ blocked by obsidianCli policy`, ); return; } const params: Record = {}; for (const token of tokens) { const eq = token.indexOf("="); if (eq === -1) params[token] = true; else params[token.slice(0, eq)] = token.slice(eq + 1); } const args = paramsToArgs(params); try { const result = await this.opts.executeCommand(cmd.name, args, vaultLabel); let lines: string[]; if (!result.stdout?.trim()) { lines = [this.theme.fg("dim", "(no output — command succeeded with empty result)")]; } else { lines = isTsv(result.stdout) ? formatTsv(result.stdout) : result.stdout.split("\n"); } finish( `${fullCmd} (${result.durationMs ?? 0}ms)`, lines, `✓ ${cmd.name} · ${result.durationMs ?? 0}ms · ${lines.length} lines · exit ok`, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); finish(fullCmd, [this.theme.fg("error", `✗ exception: ${message}`)], `✗ exception: ${message}`); } } // ---------------------------------------------------------------- render render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; const lines: string[] = []; const t = this.theme; const vaultLabel = this.vault() ?? "(active)"; const modeColor = this.mode() === "read-only" ? "success" : this.mode() === "all" ? "warning" : "accent"; lines.push( t.fg("accent", t.bold("◆ Obsidian CLI")) + t.fg("dim", ` vault: ${vaultLabel} mode: `) + t.fg(modeColor, this.mode()) + t.fg("dim", ` catalog: ${this.catalog.source} (${this.catalog.commands.size})`), ); lines.push( t.fg( "dim", "↑↓ move · / filter · tab complete · enter run · m mode · v vault · r refresh · esc quit", ), ); lines.push(""); if (this.view === "list") lines.push(...this.renderList(width)); else if (this.view === "args") lines.push(...this.renderArgs(width)); else if (this.view === "vault") lines.push(...this.renderVault(width)); else lines.push(...this.renderOutput(width)); if (this.statusMsg) { lines.push(""); lines.push(t.fg("muted", this.statusMsg)); } this.cachedLines = lines.map((l) => truncateToWidth(l, width)); this.cachedWidth = width; return this.cachedLines; } private renderList(width: number): string[] { const t = this.theme; const lines: string[] = []; const filterLabel = this.filtering ? `/${this.filter}█` : this.filter ? `filter: ${this.filter}` : ""; if (filterLabel) lines.push(t.fg("accent", filterLabel)); if (this.items.length === 0) { lines.push(t.fg("dim", " (no commands match the current permissions/filter)")); return lines; } const windowItems = this.items.slice(this.scroll, this.scroll + PAGE); for (let i = 0; i < windowItems.length; i++) { const cmd = windowItems[i]; const idx = this.scroll + i; const badge = RISK_BADGE[cmd.risk]; const badgeColor = cmd.risk === "read" ? "success" : cmd.risk === "write" ? "warning" : "error"; const isSel = idx === this.selected; const pointer = isSel ? t.fg("accent", "❯ ") : " "; const name = cmd.name.padEnd(20); const desc = cmd.description; const line = pointer + t.fg(badgeColor, badge.icon) + " " + (isSel ? t.fg("accent", t.bold(name)) : t.fg("text", name)) + " " + t.fg("dim", desc); lines.push(truncateToWidth(line, width)); } const more = this.items.length - (this.scroll + windowItems.length); const header = `${this.scroll + 1}-${this.scroll + windowItems.length}/${this.items.length}`; lines.push(t.fg("dim", more > 0 ? ` … ${more} more ${header}` : ` ${header}`)); return lines; } private renderArgs(width: number): string[] { const t = this.theme; const cmd = this.current!; const lines: string[] = []; lines.push(t.fg("accent", t.bold(`obsidian ${cmd.name}`)) + t.fg("dim", ` ${cmd.description}`)); if (cmd.usage) lines.push(t.fg("muted", `usage: ${cmd.name} ${cmd.usage}`)); for (const [flag, info] of Object.entries(cmd.flags)) { const label = info.value ? `${flag}=${info.value}` : flag; const req = info.required ? t.fg("error", " (required)") : ""; lines.push(t.fg("dim", ` ${label}`) + req + t.fg("dim", ` ${info.description}`)); } lines.push(""); const before = this.argsText.slice(0, this.argsCursor); const after = this.argsText.slice(this.argsCursor); lines.push(t.fg("accent", "args› ") + t.fg("text", before) + "█" + t.fg("text", after)); lines.push(t.fg("dim", "space-separated tokens: flag key=value (enter to run, esc to cancel)")); return lines.map((l) => truncateToWidth(l, width)); } private renderVault(width: number): string[] { const t = this.theme; return [ t.fg("accent", t.bold("Set vault")) + t.fg("dim", " (name or id, empty = active vault)"), "", t.fg("accent", "vault› ") + t.fg("text", this.argsText) + "█", ].map((l) => truncateToWidth(l, width)); } private renderOutput(width: number): string[] { const t = this.theme; const lines: string[] = []; lines.push(t.fg("accent", t.bold(this.outputTitle))); lines.push(""); const visible = this.outputLines.slice(this.outputScroll, this.outputScroll + PAGE); for (const line of visible) lines.push(truncateToWidth(line, width)); if (this.outputLines.length > PAGE) { const end = Math.min(this.outputScroll + PAGE, this.outputLines.length); lines.push(t.fg("dim", ` ${this.outputScroll + 1}-${end}/${this.outputLines.length} (↑↓ scroll, esc back)`)); } else { lines.push(t.fg("dim", " (esc back)")); } return lines; } }