@elliotsystems/eai 0.1.1 → 0.1.3

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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * C2-minimal policy: validate the plugin's own options, and identify which
3
+ * agent-visible tool keys are Elliot knowledge tools.
4
+ *
5
+ * Deliberately small. The full bash-policy port (command classification,
6
+ * hard-deny set) is a later phase — nothing in C2 executes shell commands, and
7
+ * carrying unused policy code would be scope creep wearing a safety costume.
8
+ *
9
+ * `PluginOptions` is untyped upstream (`Record<string, unknown>`), so a typo'd
10
+ * option would otherwise vanish silently; zod turns that into a load-time
11
+ * message instead.
12
+ */
13
+ import { z } from "zod"
14
+
15
+ import {
16
+ type Category,
17
+ classifyCommand,
18
+ isHardDenied,
19
+ killsAgentRuntime,
20
+ looksLikeCredentialExfiltration,
21
+ } from "./classifier.js"
22
+ import { KNOWLEDGE_TOOL, knowledgeToolKey, sanitizeKey } from "./mcp-keys.js"
23
+
24
+ const OptionsSchema = z
25
+ .object({
26
+ /**
27
+ * MCP server name(s) this plugin should treat as Elliot. Must match the
28
+ * key(s) under `mcp` in opencode.json. OpenCode composes agent-visible MCP
29
+ * tool keys as `<client>_<tool>` (mcp/catalog.ts:119), so these names are
30
+ * what let the hooks recognise Elliot tools.
31
+ */
32
+ serverNames: z.array(z.string().min(1)).min(1).default(["elliot"]),
33
+ })
34
+ .strict()
35
+
36
+ export type ElliotPluginOptions = z.infer<typeof OptionsSchema>
37
+
38
+ /**
39
+ * Parse options, throwing a load-time error on unknown/invalid keys. A plugin
40
+ * that starts with wrong options and limps is worse than one that says why it
41
+ * refused to.
42
+ */
43
+ export function parseOptions(raw: unknown): ElliotPluginOptions {
44
+ return OptionsSchema.parse(raw ?? {})
45
+ }
46
+
47
+ /**
48
+ * Re-exported so existing importers (and the test suite) keep one entry point
49
+ * for the key rule, which now lives in `mcp-keys.ts` — see that module for why
50
+ * it had to become import-free.
51
+ */
52
+ export { KNOWLEDGE_TOOL, sanitizeKey }
53
+
54
+ /**
55
+ * Is this agent-visible tool key Elliot's knowledge tool on one of our
56
+ * configured servers? Exact composed-key match only — a suffix match would let
57
+ * an unrelated MCP server named e.g. "not-elliot" receive our scope hints.
58
+ */
59
+ export function isElliotKnowledgeTool(toolKey: string, options: ElliotPluginOptions): boolean {
60
+ return options.serverNames.some((server) => toolKey === knowledgeToolKey(server))
61
+ }
62
+
63
+ // -- Shell policy verdicts ---------------------------------------------------
64
+
65
+ /**
66
+ * What the plugin does to a shell permission decision, and — just as importantly —
67
+ * what it refuses to do.
68
+ *
69
+ * `undefined` means "leave the host's decision untouched". That is the default for
70
+ * anything read-shaped, and it matters: the org's glob policy already decided those,
71
+ * and a plugin that *widened* a permission it did not grant would be a privilege
72
+ * escalation wearing a helpful face. This function can therefore only ever tighten.
73
+ */
74
+ export function shellVerdict(
75
+ command: string,
76
+ ): { status: "deny" | "ask"; category: Category; reason: string } | undefined {
77
+ if (!command.trim()) {
78
+ // Nothing to inspect: say nothing rather than guess. The host's own policy
79
+ // (`bash *: ask` in the org default) remains in force.
80
+ return undefined
81
+ }
82
+
83
+ if (isHardDenied(command)) {
84
+ return {
85
+ status: "deny",
86
+ category: "DESTRUCTIVE",
87
+ reason:
88
+ "catastrophic command shape (org hard-deny list) — refused outright; " +
89
+ "confirmation adds no safety against a runaway loop",
90
+ }
91
+ }
92
+
93
+ if (killsAgentRuntime(command)) {
94
+ return {
95
+ status: "deny",
96
+ category: "DESTRUCTIVE",
97
+ reason:
98
+ "kills processes by name matching Elliot's own runtime (node/bun/opencode), " +
99
+ "which terminates this session mid-command and leaves the terminal in " +
100
+ "mouse-reporting mode — target a specific PID instead (Stop-Process -Id, " +
101
+ "taskkill /PID), or kill the service by its own process name",
102
+ }
103
+ }
104
+
105
+ if (looksLikeCredentialExfiltration(command)) {
106
+ return {
107
+ status: "deny",
108
+ category: "DESTRUCTIVE",
109
+ reason:
110
+ "names a secret-shaped path AND invokes a network tool — refused as possible " +
111
+ "credential exfiltration",
112
+ }
113
+ }
114
+
115
+ const category = classifyCommand(command)
116
+ if (category === "DESTRUCTIVE" || category === "MUTATION" || category === "UNKNOWN") {
117
+ return { status: "ask", category, reason: `classified ${category} — needs confirmation` }
118
+ }
119
+
120
+ // READ / TEST / BUILD: the org globs already allow these. Stay out of the way.
121
+ return undefined
122
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The Elliot proxy provider: a `config` hook that registers a custom
3
+ * `elliot` OpenCode provider pointed at the Elliot LightLLM proxy, and an
4
+ * `auth` hook whose `loader` returns a self-refreshing `fetch` that attaches
5
+ * a live bearer token per request.
6
+ *
7
+ * Mechanism pinned in `.superpowers/sdd/2026-09-03-eai-proxy-auth/task-1-findings.md`:
8
+ * - `config` hook mutates its input in place (no separate output param).
9
+ * - `auth.loader`'s return value is spread wholesale into the provider's
10
+ * resolved `options`, so `{ apiKey, fetch }` land there directly.
11
+ * - `apiKey` is baked into the AI-SDK client at construction time and
12
+ * never changes; the actually-rotating credential is injected per
13
+ * request by `options.fetch`'s `Authorization` header (never the static
14
+ * `apiKey`) — same pattern as OpenCode's bundled snowflake-cortex plugin.
15
+ * - The loader only runs once a stored auth entry exists for its
16
+ * `provider` id (`provider.ts:1584`); the plugin seeds that entry itself
17
+ * at init (see `index.ts`) so no interactive `opencode auth login` step
18
+ * is ever required.
19
+ *
20
+ * Everything here is a no-op when the user isn't logged in
21
+ * (`readElliotConfig()` returns null) — OpenCode then runs exactly as it
22
+ * would without this plugin.
23
+ */
24
+ import { freshToken, readElliotConfig } from "./elliot-auth.js"
25
+
26
+ export const ELLIOT_LLM_PROXY_URL = "https://backend.elliotsystemsonline.com/llm-proxy"
27
+ export const ELLIOT_PROVIDER_ID = "elliot"
28
+
29
+ /**
30
+ * Registers the `elliot` provider (baseURL = the LightLLM proxy) and
31
+ * defaults `cfg.model` to it, mutating `cfg` in place. No-op when the user
32
+ * isn't logged in. Never overwrites a model the user already configured, or
33
+ * any other existing key under `cfg.provider`/`cfg.plugin`/`cfg.mcp`/etc.
34
+ */
35
+ export function configHook(cfg: any): void {
36
+ if (!readElliotConfig()) return
37
+
38
+ cfg.provider ??= {}
39
+ cfg.provider[ELLIOT_PROVIDER_ID] = {
40
+ npm: "@ai-sdk/anthropic",
41
+ name: "Elliot (governed)",
42
+ options: { baseURL: ELLIOT_LLM_PROXY_URL },
43
+ models: { "elliot-governed": { name: "Elliot (governed)" } },
44
+ }
45
+
46
+ cfg.model ??= "elliot/elliot-governed"
47
+ }
48
+
49
+ /**
50
+ * Merge `Authorization: Bearer <token>` into whatever headers `init` already
51
+ * carries, without dropping them. Works whether `init.headers` is a plain
52
+ * object, a `Headers` instance, or an array of pairs.
53
+ */
54
+ function withAuthHeader(init: RequestInit | undefined, token: string): RequestInit {
55
+ const headers = new Headers(init?.headers)
56
+ headers.set("Authorization", `Bearer ${token}`)
57
+ return { ...init, headers }
58
+ }
59
+
60
+ /**
61
+ * The per-request fetch wrapper: reads `~/.elliot/config.json` fresh on
62
+ * every call (so a refresh picked up by another process is honored
63
+ * immediately), refreshes via `freshToken` when needed, and attaches the
64
+ * bearer token. Never throws — a missing/expired/unrefreshable token just
65
+ * means the request goes out unauthenticated and the proxy 401s.
66
+ */
67
+ async function elliotFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
68
+ try {
69
+ const cfg = readElliotConfig()
70
+ const token = cfg ? await freshToken(cfg) : null
71
+ if (token) {
72
+ return globalThis.fetch(input, withAuthHeader(init, token))
73
+ }
74
+ return globalThis.fetch(input, init)
75
+ } catch {
76
+ return globalThis.fetch(input, init)
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Builds the `auth` hook object per the Task-1 pinned `AuthHook` shape.
82
+ * `methods` is empty — this provider is never set up via interactive
83
+ * `opencode auth login`; the plugin seeds the stored auth entry itself
84
+ * (see `index.ts`) so the loader always fires once the user is logged into
85
+ * Elliot.
86
+ */
87
+ export function makeAuthHook() {
88
+ return {
89
+ provider: ELLIOT_PROVIDER_ID,
90
+ methods: [] as unknown[],
91
+ loader: async (_auth: () => Promise<unknown>, _provider: unknown) => ({
92
+ apiKey: "elliot-managed",
93
+ fetch: elliotFetch,
94
+ }),
95
+ }
96
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Repository scope from the session worktree — the trusted-context half of the
3
+ * "the model never names a repository" invariant.
4
+ *
5
+ * Ported (trimmed, not imported) from `elliot-cli/src/project.ts`: the same
6
+ * `execFile` discipline — a fixed, hardcoded argument array, never a string a
7
+ * shell would parse, and never anything the LLM supplies. The worktree path
8
+ * comes from OpenCode's own `PluginInput`/`ToolContext`, which is session
9
+ * truth, not model output.
10
+ *
11
+ * Authorization is NOT here and never will be: the derived `owner/name` is a
12
+ * hint the Elliot backend authorizes via `resolve_repo_scope`. A wrong value
13
+ * can therefore select nothing the tenant was not already granted — which is
14
+ * why every failure path below degrades to `null` (tenant-wide search) rather
15
+ * than throwing. Scope is an optimization, never a gate.
16
+ */
17
+ import { execFile } from "node:child_process"
18
+
19
+ export interface RepoScope {
20
+ owner: string
21
+ name: string
22
+ }
23
+
24
+ /**
25
+ * Handles SSH (git@host:owner/repo.git) and HTTPS (https://host/owner/repo.git)
26
+ * remote forms generically — not GitHub-specific. Same regex the CLI used.
27
+ */
28
+ export function parseOwnerRepo(remoteUrl: string | null | undefined): RepoScope | null {
29
+ if (!remoteUrl) return null
30
+ const match = remoteUrl.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/)
31
+ if (!match) return null
32
+ return { owner: match[1], name: match[2] }
33
+ }
34
+
35
+ /** `owner/name`, the wire form `repo_scope_hint` carries. */
36
+ export function formatScope(scope: RepoScope): string {
37
+ return `${scope.owner}/${scope.name}`
38
+ }
39
+
40
+ function gitRemoteUrl(worktree: string): Promise<string | null> {
41
+ return new Promise((resolve) => {
42
+ execFile(
43
+ "git",
44
+ ["remote", "get-url", "origin"],
45
+ { cwd: worktree, timeout: 5_000, windowsHide: true },
46
+ (err, stdout) => {
47
+ // Any failure — not a repo, no origin, git absent, timeout — is a
48
+ // normal discovery miss, never an error to surface.
49
+ if (err) return resolve(null)
50
+ const url = stdout.trim()
51
+ resolve(url.length ? url : null)
52
+ },
53
+ )
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Derive `owner/name` for a worktree, or null when it cannot be known.
59
+ *
60
+ * Cached per worktree path for the plugin's lifetime: the remote of an open
61
+ * session's repository does not meaningfully change mid-session, and the CLI's
62
+ * rule ("detected once, never re-run per tool call") carries over.
63
+ */
64
+ const cache = new Map<string, Promise<RepoScope | null>>()
65
+
66
+ export function scopeForWorktree(worktree: string | null | undefined): Promise<RepoScope | null> {
67
+ if (!worktree) return Promise.resolve(null)
68
+ const hit = cache.get(worktree)
69
+ if (hit) return hit
70
+ const pending = gitRemoteUrl(worktree).then(parseOwnerRepo)
71
+ cache.set(worktree, pending)
72
+ return pending
73
+ }
74
+
75
+ /** Test-only: clear the per-worktree cache. */
76
+ export function resetScopeCache(): void {
77
+ cache.clear()
78
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Structural mirror of the slice of `TuiPluginApi` this chrome uses.
3
+ *
4
+ * `@opencode-ai/plugin/tui` is deliberately absent from the module list the host
5
+ * shares with runtime-loaded plugins (see `host.d.ts`), so its types cannot be
6
+ * imported. The host validates plugins by SHAPE, not by nominal type, which is
7
+ * what makes a local mirror correct rather than a workaround.
8
+ *
9
+ * Only the members the chrome actually reads appear here. A mirror that copied
10
+ * the whole surface would be a second copy of upstream's API to keep in sync,
11
+ * and every unused member in it is a silent drift risk.
12
+ */
13
+ import type { RGBA } from "@opentui/core"
14
+
15
+ /** Theme roles this chrome paints with. Names match upstream's exactly. */
16
+ export interface ThemeCurrent {
17
+ readonly primary: RGBA
18
+ readonly text: RGBA
19
+ readonly textMuted: RGBA
20
+ readonly background: RGBA
21
+ readonly backgroundPanel: RGBA
22
+ readonly border: RGBA
23
+ readonly success: RGBA
24
+ readonly warning: RGBA
25
+ readonly error: RGBA
26
+ }
27
+
28
+ /** `TuiSidebarFileItem`. */
29
+ export interface FileItem {
30
+ readonly file: string
31
+ readonly additions: number
32
+ readonly deletions: number
33
+ }
34
+
35
+ /** `TuiSidebarMcpItem`. */
36
+ export interface McpStatusItem {
37
+ readonly name: string
38
+ readonly status: string
39
+ readonly error?: string
40
+ }
41
+
42
+ /** A `Message`, narrowed to the id needed to fetch its parts. */
43
+ export interface MessageLike {
44
+ readonly id: string
45
+ }
46
+
47
+ /** A `Part`, narrowed to what a knowledge citation needs. */
48
+ export interface PartLike {
49
+ readonly type: string
50
+ readonly tool?: string
51
+ readonly state?: {
52
+ readonly status?: string
53
+ readonly output?: string
54
+ }
55
+ }
56
+
57
+ /** `TuiState` — the read-only session view the chrome derives everything from. */
58
+ export interface StateApi {
59
+ readonly ready: boolean
60
+ readonly path: {
61
+ readonly worktree: string
62
+ readonly directory: string
63
+ }
64
+ readonly vcs: { readonly branch?: string; readonly default_branch?: string } | undefined
65
+ readonly session: {
66
+ count: () => number
67
+ diff: (sessionID: string) => ReadonlyArray<FileItem>
68
+ messages: (sessionID: string) => ReadonlyArray<MessageLike>
69
+ }
70
+ part: (messageID: string) => ReadonlyArray<PartLike>
71
+ mcp: () => ReadonlyArray<McpStatusItem>
72
+ }
73
+
74
+ /** `TuiPromptProps` — the host's prompt component, reused rather than rebuilt. */
75
+ export interface PromptProps {
76
+ sessionID?: string
77
+ visible?: boolean
78
+ disabled?: boolean
79
+ onSubmit?: () => void
80
+ ref?: (ref: unknown) => void
81
+ hint?: unknown
82
+ right?: unknown
83
+ showPlaceholder?: boolean
84
+ placeholders?: { normal?: string[]; shell?: string[] }
85
+ }
86
+
87
+ export interface TuiPluginApi {
88
+ readonly app: { readonly version: string }
89
+ readonly theme: {
90
+ readonly current: ThemeCurrent
91
+ /**
92
+ * Copies a theme file from the plugin root into a directory the host's
93
+ * theme discovery scans. Resolved relative to the plugin root by
94
+ * `createThemeInstaller` (packages/opencode/src/plugin/tui/runtime.ts:245).
95
+ */
96
+ install: (jsonPath: string) => Promise<void>
97
+ has: (name: string) => boolean
98
+ }
99
+ readonly state: StateApi
100
+ readonly ui: {
101
+ Prompt: (props: PromptProps) => unknown
102
+ }
103
+ readonly slots: {
104
+ register(input: { order?: number; slots: Record<string, unknown> }): unknown
105
+ }
106
+ }
107
+
108
+ export type TuiPlugin = (api: TuiPluginApi) => Promise<void>
109
+
110
+ /** Props the host passes to the session-scoped slots. */
111
+ export interface SessionSlotProps {
112
+ readonly session_id: string
113
+ readonly visible?: boolean
114
+ readonly disabled?: boolean
115
+ readonly on_submit?: () => void
116
+ readonly ref?: (ref: unknown) => void
117
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Display formatting for the Elliot TUI chrome. Pure functions only, so the
3
+ * decisions that are easy to get subtly wrong — where a path gets cut, what a
4
+ * missing branch renders as — are unit-testable without a terminal.
5
+ */
6
+
7
+ /** Separator-agnostic: the worktree arrives with `\` on Windows, `/` elsewhere. */
8
+ const SEPARATOR = /[\\/]+/g
9
+
10
+ /**
11
+ * A path short enough to sit on one line of chrome, without lying about it.
12
+ *
13
+ * Truncation happens on the LEFT and is marked with `…/`, because the
14
+ * informative end of a project path is its tail. A `home` prefix collapses to
15
+ * `~` first — that is both shorter and the form a developer recognises.
16
+ *
17
+ * Returns forward slashes on every platform: this string is chrome, not a path
18
+ * to hand back to the filesystem, and mixed separators in one line of UI read
19
+ * as a bug.
20
+ */
21
+ export function shortenPath(directory: string, home?: string, maxSegments = 3): string {
22
+ if (!directory) return ""
23
+
24
+ const normalized = directory.replace(SEPARATOR, "/").replace(/\/+$/, "")
25
+ const collapsed = collapseHome(normalized, home)
26
+ if (collapsed !== normalized) return collapsed
27
+
28
+ const segments = normalized.split("/").filter(Boolean)
29
+ if (segments.length <= maxSegments) return normalized
30
+ return `…/${segments.slice(-maxSegments).join("/")}`
31
+ }
32
+
33
+ /**
34
+ * Replace a `home` prefix with `~`, matching whole segments only.
35
+ *
36
+ * The segment check is the point: a plain `startsWith` would turn
37
+ * `C:/Users/ganesh-old/repo` into `~-old/repo` when home is `C:/Users/ganesh`.
38
+ * Comparison is case-insensitive because Windows paths are.
39
+ */
40
+ function collapseHome(normalized: string, home?: string): string {
41
+ if (!home) return normalized
42
+ const prefix = home.replace(SEPARATOR, "/").replace(/\/+$/, "")
43
+ if (!prefix) return normalized
44
+ if (normalized.toLowerCase() === prefix.toLowerCase()) return "~"
45
+ if (!normalized.toLowerCase().startsWith(`${prefix.toLowerCase()}/`)) return normalized
46
+ return `~${normalized.slice(prefix.length)}`
47
+ }
48
+
49
+ /**
50
+ * The home directory, or `undefined` where the runtime does not expose one.
51
+ *
52
+ * `process` is reached through `globalThis` rather than imported: this module is
53
+ * loaded into the TUI plugin runtime, whose shared module list does not include
54
+ * `node:process`, and an absent global must degrade to a full path rather than
55
+ * throwing during slot render.
56
+ */
57
+ export function homeDirectory(): string | undefined {
58
+ const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
59
+ return env?.HOME || env?.USERPROFILE || undefined
60
+ }
61
+
62
+ /** `+12 −3`, with either side omitted when it is zero. Empty when both are. */
63
+ export function formatDiffStat(additions: number, deletions: number): string {
64
+ const parts: string[] = []
65
+ if (additions > 0) parts.push(`+${additions}`)
66
+ if (deletions > 0) parts.push(`−${deletions}`)
67
+ return parts.join(" ")
68
+ }
69
+
70
+ /** `n thing` / `n things` — an English plural, not `n thing(s)`. */
71
+ export function pluralize(count: number, singular: string, plural = `${singular}s`): string {
72
+ return `${count} ${count === 1 ? singular : plural}`
73
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The welcome screen's copy, filling `home_bottom`.
3
+ *
4
+ * Three deliberate reductions from what shipped before. The four-key hint row is
5
+ * gone: the same keys are named in the tips, and printing them twice is the kind
6
+ * of chrome this redesign exists to remove. The context line is new, and is the
7
+ * one thing a terminal welcome genuinely owes the reader — where am I, on what
8
+ * branch. And the tips are numbered rather than bulleted, which is what makes a
9
+ * short list read as "here are three things" instead of as decoration.
10
+ *
11
+ * `tui.json` disables `internal:home-tips`; without that, upstream's tips
12
+ * carousel stacks under this block and the screen carries two tip lists.
13
+ */
14
+ import { For, Show } from "solid-js"
15
+
16
+ import type { TuiPluginApi } from "./api.js"
17
+ import { homeDirectory, shortenPath } from "./format.js"
18
+
19
+ const TAGLINE = "the engineering terminal"
20
+
21
+ /**
22
+ * Three tips, in the order a new user needs them.
23
+ *
24
+ * The first names Elliot's actual differentiator rather than a generic prompt
25
+ * idea. The second says `/init` and `AGENTS.md` — AGENTS.md is the file OpenCode
26
+ * really reads (`session/instruction.ts`); the original design's "AGENT.md" was
27
+ * a bug wearing a designer's clothes. The third collapses the old hint row.
28
+ */
29
+ const TIPS: ReadonlyArray<readonly [string, string]> = [
30
+ ["Ask about this repo", "Elliot searches your organisation's indexed sources"],
31
+ ["Run /init", "generates an AGENTS.md so every session starts informed"],
32
+ ["Press / or @", "commands and file context; tab switches agents"],
33
+ ]
34
+
35
+ /** `~/Documents/Elliot-AI · feat/tui-elliot-redesign` */
36
+ function ContextLine(props: { api: TuiPluginApi }) {
37
+ const theme = props.api.theme.current
38
+ const state = props.api.state
39
+ const directory = () => shortenPath(state.path.directory || state.path.worktree, homeDirectory())
40
+ const branch = () => state.vcs?.branch
41
+
42
+ return (
43
+ <box flexDirection="row" gap={1}>
44
+ <text fg={theme.textMuted} selectable={false}>
45
+ {directory()}
46
+ </text>
47
+ <Show when={branch()}>
48
+ <text fg={theme.border} selectable={false}>
49
+ ·
50
+ </text>
51
+ <text fg={theme.textMuted} selectable={false}>
52
+ {branch()}
53
+ </text>
54
+ </Show>
55
+ </box>
56
+ )
57
+ }
58
+
59
+ export function HomeBottom(props: { api: TuiPluginApi }) {
60
+ const theme = props.api.theme.current
61
+ return (
62
+ <box paddingTop={1} gap={1}>
63
+ <text fg={theme.textMuted} selectable={false}>
64
+ {TAGLINE}
65
+ </text>
66
+
67
+ <ContextLine api={props.api} />
68
+
69
+ <box>
70
+ <For each={TIPS}>
71
+ {(tip, index) => (
72
+ <box flexDirection="row" gap={1}>
73
+ <text fg={theme.border} selectable={false}>
74
+ {`${index() + 1}.`}
75
+ </text>
76
+ <text fg={theme.text} selectable={false}>
77
+ {tip[0]}
78
+ </text>
79
+ <text fg={theme.textMuted} selectable={false}>
80
+ {`— ${tip[1]}`}
81
+ </text>
82
+ </box>
83
+ )}
84
+ </For>
85
+ </box>
86
+ </box>
87
+ )
88
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Ambient declarations for the host modules this TUI plugin imports at runtime.
3
+ *
4
+ * `solid-js`, `solid-js/store`, `@opentui/core` and `@opentui/solid/jsx-runtime`
5
+ * are provided to runtime-loaded plugins by the host — verified in
6
+ * `@opentui/solid/scripts/runtime-plugin-support-configure.js`, whose shared
7
+ * specifier list is exactly:
8
+ * @opentui/core, @opentui/core/runtime-plugin, @opentui/solid,
9
+ * @opentui/solid/components, @opentui/solid/jsx-runtime,
10
+ * @opentui/solid/jsx-dev-runtime, solid-js, solid-js/store, bun
11
+ *
12
+ * They are therefore not RUNTIME dependencies of this package: they are declared
13
+ * as peers, and installed here only so the package type-checks and tests on its
14
+ * own. Declaring the handful of members we use keeps `tsc` meaningful without
15
+ * pulling the whole opencode workspace onto the module path — the same approach
16
+ * `src/index.ts` already takes for the server plugin's host types.
17
+ *
18
+ * Note `@opencode-ai/plugin/tui` is deliberately absent from the shared list, so
19
+ * the plugin's own API types are mirrored structurally in `index.tsx` rather than
20
+ * imported. The host validates plugins by shape, not by nominal type.
21
+ */
22
+
23
+ declare module "solid-js" {
24
+ export function For<T>(props: {
25
+ each: readonly T[] | undefined
26
+ children: (item: T, index: () => number) => unknown
27
+ }): unknown
28
+
29
+ /**
30
+ * Solid's conditional. `when` is truthiness-tested, and `children` may be an
31
+ * element or a function of the narrowed value — only the element form is
32
+ * declared, because that is the only form this chrome uses.
33
+ */
34
+ export function Show<T>(props: {
35
+ when: T | undefined | null | false
36
+ fallback?: unknown
37
+ children?: unknown
38
+ }): unknown
39
+ }
40
+
41
+ declare module "@opentui/core" {
42
+ /**
43
+ * Mostly opaque: produced by the host theme and handed straight back. Not
44
+ * FULLY opaque, though, and the difference matters -- `wordmark.tsx` has to
45
+ * construct one. A plain object of the same shape is rejected at opentui's FFI
46
+ * boundary ("Expected ArrayBufferView but received Cell"), so the constructor
47
+ * is declared here and the real class must be used.
48
+ */
49
+ export class RGBA {
50
+ readonly r: number
51
+ readonly g: number
52
+ readonly b: number
53
+ readonly a: number
54
+ static fromValues(r: number, g: number, b: number, a?: number): RGBA
55
+ }
56
+ export const TextAttributes: { readonly BOLD: number }
57
+ }
58
+
59
+ declare namespace JSX {
60
+ type Element = unknown
61
+
62
+ interface BoxProps {
63
+ flexDirection?: "row" | "column"
64
+ flexGrow?: number
65
+ flexShrink?: number
66
+ alignItems?: "center" | "flex-start" | "flex-end" | "stretch" | "baseline"
67
+ justifyContent?: "center" | "flex-start" | "flex-end" | "space-between"
68
+ gap?: number
69
+ width?: number | string
70
+ maxWidth?: number
71
+ height?: number
72
+ minHeight?: number
73
+ paddingTop?: number
74
+ paddingLeft?: number
75
+ paddingRight?: number
76
+ children?: unknown
77
+ }
78
+
79
+ interface TextProps {
80
+ fg?: unknown
81
+ bg?: unknown
82
+ attributes?: number
83
+ selectable?: boolean
84
+ children?: unknown
85
+ }
86
+
87
+ interface IntrinsicElements {
88
+ box: BoxProps
89
+ text: TextProps
90
+ }
91
+
92
+ interface ElementChildrenAttribute {
93
+ children: {}
94
+ }
95
+ }