@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,99 @@
1
+ /**
2
+ * Elliot AI TUI chrome.
3
+ *
4
+ * Fills the host's public plugin slots — nothing here edits OpenCode. That is
5
+ * what keeps the whole redesign at zero fork divergence, and it is also the
6
+ * ceiling on it: the session transcript exposes no slot but the prompt, so
7
+ * message and tool-call rendering stay upstream's. The boundary, with call
8
+ * sites, is documented in `docs/opencode/TUI-REDESIGN.md`.
9
+ *
10
+ * Registration happens in two passes because `order` is per-registration, not
11
+ * per-slot, and the sidebar needs a different position than everything else.
12
+ */
13
+ import { HomeBottom } from "./home.js"
14
+ import { HomePrompt, SessionPrompt } from "./prompt.js"
15
+ import { SidebarContent, SidebarFooter, SidebarTitle } from "./sidebar.js"
16
+ import { Wordmark } from "./wordmark.js"
17
+ import type { SessionSlotProps, TuiPluginApi, TuiPlugin } from "./api.js"
18
+
19
+ /**
20
+ * Ahead of every built-in sidebar section (context 100, mcp 200, lsp 300,
21
+ * todo 400, files 500), so what Elliot adds is read before what OpenCode
22
+ * reports.
23
+ */
24
+ const SIDEBAR_ORDER = 50
25
+
26
+ /**
27
+ * Above the built-in home plugins (`internal:home-tips` and `internal:which-key`
28
+ * both register at 100–200) so Elliot's chrome sorts after theirs in append
29
+ * slots, and wins the `single_winner` ones.
30
+ */
31
+ const CHROME_ORDER = 200
32
+
33
+ /**
34
+ * The theme file, relative to the plugin root.
35
+ *
36
+ * Installing it is REQUIRED, not a nicety. Theme discovery scans only
37
+ * `themes/*.json` under the global config directory and each `.opencode`
38
+ * directory up from the cwd (`packages/tui/src/context/theme.tsx:38`), so
39
+ * `opencode-plugin/themes/elliot.json` is never found on its own — which is why
40
+ * `tui.json`'s `"theme": "elliot"` silently fell back to the default palette and
41
+ * the Elliot theme had never actually applied. `api.theme.install` copies the
42
+ * file to a scanned location, which is what the package's `oc-themes` field was
43
+ * always gesturing at.
44
+ */
45
+ const THEME_FILE = "themes/elliot.json"
46
+
47
+ /**
48
+ * Required, not optional, and it must also be carried on the DEFAULT export
49
+ * below -- `readV1Plugin` (`plugin/shared.ts:278`) hands the host `mod.default`,
50
+ * so a bare named export is never read. `tui.json` names this plugin by a relative path, so
51
+ * the host classifies it `source: "file"` (`plugin/shared.ts:57`) and
52
+ * `resolvePluginId` (`:306`) THROWS for a path plugin that exports no `id`.
53
+ * That throw is reported via `console.error`, which the alternate screen
54
+ * swallows -- so the plugin silently did not load, and the missing `id` was
55
+ * read for months as "TUI plugin loading is broken in the runtime"
56
+ * (docs/opencode/TUI-REDESIGN.md 6.2). It is not: this export is the fix.
57
+ */
58
+ export const id = "@elliotsystems/opencode-plugin"
59
+
60
+ export const tui: TuiPlugin = async (api) => {
61
+ // Never fatal: a chrome that refuses to load because a colour file could not
62
+ // be copied is worse than one that renders in the default palette.
63
+ await api.theme.install(THEME_FILE).catch(() => {})
64
+
65
+ api.slots.register({
66
+ order: SIDEBAR_ORDER,
67
+ slots: {
68
+ sidebar_content: (_context: unknown, slot: SessionSlotProps) => (
69
+ <SidebarContent api={api} slot={slot} />
70
+ ),
71
+ },
72
+ })
73
+
74
+ api.slots.register({
75
+ order: CHROME_ORDER,
76
+ slots: {
77
+ home_logo: () => <Wordmark api={api} />,
78
+ home_bottom: () => <HomeBottom api={api} />,
79
+ home_prompt: (_context: unknown, slot: { ref?: (ref: unknown) => void }) => (
80
+ <HomePrompt api={api} slot={slot} />
81
+ ),
82
+ session_prompt: (_context: unknown, slot: SessionSlotProps) => (
83
+ <SessionPrompt api={api} slot={slot} />
84
+ ),
85
+ sidebar_title: (_context: unknown, slot: SessionSlotProps & { title: string }) => (
86
+ <SidebarTitle api={api} slot={slot} />
87
+ ),
88
+ sidebar_footer: () => <SidebarFooter api={api} />,
89
+ },
90
+ })
91
+ }
92
+
93
+ /**
94
+ * The host requires a default export with `tui()` — and refuses a module that
95
+ * default-exports both `server()` and `tui()` (`plugin/shared.ts:294`). That is
96
+ * why this lives behind its own export subpath instead of the package root,
97
+ * which keeps default-exporting the server plugin.
98
+ */
99
+ export default { id, tui }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Reading Elliot's knowledge activity out of what the TUI already has.
3
+ *
4
+ * The sidebar shows two things a stock OpenCode sidebar cannot: whether the
5
+ * knowledge boundary is actually up, and which sources Elliot cited in THIS
6
+ * session. Both are derived from `api.state` alone — MCP status and the
7
+ * session's own tool parts.
8
+ *
9
+ * It deliberately does NOT call Elliot's backend. The server plugin's contract
10
+ * (`src/index.ts`) is that OAuth lifecycle belongs to OpenCode's native MCP
11
+ * client and a second credential path must not exist; a sidebar that fetched
12
+ * `get_index_stats` itself would be exactly that second path. So index totals
13
+ * are absent here rather than approximated, and everything below is either
14
+ * proven from session state or not shown.
15
+ */
16
+
17
+ import { knowledgeToolKey } from "../mcp-keys.js"
18
+
19
+ // ------------------------------------------------------------- host shapes --
20
+
21
+ /** `TuiSidebarMcpItem` (packages/plugin/src/tui.ts) — the fields we read. */
22
+ export interface McpItem {
23
+ readonly name: string
24
+ readonly status: string
25
+ readonly error?: string
26
+ }
27
+
28
+ /** A `ToolPart` in its completed state, narrowed to what a citation needs. */
29
+ export interface ToolPartLike {
30
+ readonly type: string
31
+ readonly tool?: string
32
+ readonly state?: {
33
+ readonly status?: string
34
+ readonly output?: string
35
+ }
36
+ }
37
+
38
+ // ------------------------------------------------------- connection status --
39
+
40
+ export type KnowledgeConnection = {
41
+ /** `undefined` when no Elliot MCP server is configured at all. */
42
+ readonly status: "connected" | "failed" | "pending" | undefined
43
+ readonly name?: string
44
+ readonly error?: string
45
+ }
46
+
47
+ /**
48
+ * Which MCP server is Elliot's.
49
+ *
50
+ * Matched by name rather than by URL because the name is what the operator
51
+ * chose in `opencode.json` and what the agent sees prefixed onto the tool
52
+ * (`elliot_knowledge_search`). A substring match keeps `elliot`, `elliot-dev`
53
+ * and `elliot-staging` all recognised.
54
+ */
55
+ function isElliotServer(name: string): boolean {
56
+ return name.toLowerCase().includes("elliot")
57
+ }
58
+
59
+ /**
60
+ * The knowledge boundary's real state.
61
+ *
62
+ * A failed server outranks a connected one when several match: the panel exists
63
+ * to surface the problem, and reporting "connected" while a configured Elliot
64
+ * server is down is the one output that would actively mislead.
65
+ */
66
+ export function knowledgeConnection(items: readonly McpItem[]): KnowledgeConnection {
67
+ const elliot = items.filter((item) => isElliotServer(item.name))
68
+ if (elliot.length === 0) return { status: undefined }
69
+
70
+ const failed = elliot.find((item) => item.status === "failed")
71
+ if (failed) return { status: "failed", name: failed.name, error: failed.error }
72
+
73
+ const connected = elliot.find((item) => item.status === "connected")
74
+ if (connected) return { status: "connected", name: connected.name }
75
+
76
+ return { status: "pending", name: elliot[0]!.name }
77
+ }
78
+
79
+ // -------------------------------------------------------------- citations --
80
+
81
+ /** One source Elliot cited, as rendered by the backend's evidence formatter. */
82
+ export interface Citation {
83
+ readonly path: string
84
+ readonly symbol?: string
85
+ }
86
+
87
+ export type KnowledgeResult = {
88
+ readonly status: "ok" | "no_results" | "repo_not_authorized" | "unavailable"
89
+ readonly repoScope?: string
90
+ readonly resultCount: number
91
+ readonly citations: readonly Citation[]
92
+ }
93
+
94
+ /** The Elliot-looking MCP servers currently configured, by name. */
95
+ export function elliotServerNames(items: readonly McpItem[]): string[] {
96
+ return items.filter((item) => isElliotServer(item.name)).map((item) => item.name)
97
+ }
98
+
99
+ /**
100
+ * Is this agent-visible tool key an Elliot knowledge lookup?
101
+ *
102
+ * Exact composed-key match against the configured servers, mirroring
103
+ * `isElliotKnowledgeTool` in `policy.ts` rather than matching the
104
+ * `knowledge_search` suffix. Suffix matching is rejected there because it would
105
+ * let an unrelated server named e.g. `not-elliot` be treated as ours; the same
106
+ * reasoning applies to a panel that would otherwise attribute a third party's
107
+ * results to Elliot.
108
+ */
109
+ export function isKnowledgeTool(tool: string | undefined, serverNames: readonly string[]): boolean {
110
+ if (typeof tool !== "string") return false
111
+ return serverNames.some((name) => tool === knowledgeToolKey(name))
112
+ }
113
+
114
+ /**
115
+ * Parse one completed `knowledge_search` output.
116
+ *
117
+ * The output string is the backend's envelope, JSON-encoded
118
+ * (`backend/app/routers/mcp_v1.py:229` wraps `render_knowledge_evidence` in a
119
+ * single text content block): `{status, repo_scope?, result_count?, evidence}`.
120
+ *
121
+ * This is a real coupling to that shape, so it is written to go SILENT rather
122
+ * than wrong if the shape changes — every branch returns `undefined` on
123
+ * anything unexpected, and `test/tui-knowledge.test.ts` locks the contract so a
124
+ * backend change breaks a test instead of the sidebar.
125
+ */
126
+ export function parseKnowledgeOutput(output: string | undefined): KnowledgeResult | undefined {
127
+ if (!output) return undefined
128
+
129
+ let envelope: unknown
130
+ try {
131
+ envelope = JSON.parse(output)
132
+ } catch {
133
+ return undefined
134
+ }
135
+ if (typeof envelope !== "object" || envelope === null) return undefined
136
+
137
+ const record = envelope as Record<string, unknown>
138
+ const status = record["status"]
139
+ if (
140
+ status !== "ok" &&
141
+ status !== "no_results" &&
142
+ status !== "repo_not_authorized" &&
143
+ status !== "unavailable"
144
+ ) {
145
+ return undefined
146
+ }
147
+
148
+ const repoScope = typeof record["repo_scope"] === "string" ? record["repo_scope"] : undefined
149
+ const resultCount = typeof record["result_count"] === "number" ? record["result_count"] : 0
150
+ const evidence = typeof record["evidence"] === "string" ? record["evidence"] : ""
151
+
152
+ return {
153
+ status,
154
+ repoScope,
155
+ resultCount,
156
+ citations: status === "ok" ? parseEvidence(evidence, resultCount) : [],
157
+ }
158
+ }
159
+
160
+ /** The backend joins evidence entries with this exact delimiter. */
161
+ const ENTRY_DELIMITER = "\n---\n"
162
+
163
+ /** `path` or `path — symbol`, as `_render_knowledge_result` builds the header. */
164
+ const HEADER = /^(\S+)(?: — (.+))?$/
165
+
166
+ /**
167
+ * Pull the header line out of each evidence entry.
168
+ *
169
+ * `limit` is load-bearing, not defensive tidying. Chunk CONTENT is embedded in
170
+ * the same string, so a markdown chunk containing a `---` horizontal rule
171
+ * produces extra delimiters and therefore phantom entries. `result_count` is
172
+ * the backend's authoritative count, so honouring it caps the damage, and the
173
+ * header regex rejects the rest — a prose line has spaces and so cannot match.
174
+ */
175
+ function parseEvidence(evidence: string, limit: number): Citation[] {
176
+ if (!evidence || limit <= 0) return []
177
+
178
+ const citations: Citation[] = []
179
+ const seen = new Set<string>()
180
+
181
+ for (const entry of evidence.split(ENTRY_DELIMITER)) {
182
+ if (citations.length >= limit) break
183
+
184
+ const header = entry.split("\n", 1)[0]?.trim()
185
+ if (!header) continue
186
+ const match = HEADER.exec(header)
187
+ if (!match) continue
188
+
189
+ const path = match[1]!
190
+ // Several chunks from one file are one source to a reader. The key is a
191
+ // JSON tuple rather than a joined string: any separator character could
192
+ // legitimately appear in a path or a symbol name, so joining risks two
193
+ // different (path, symbol) pairs collapsing into one key.
194
+ const key = JSON.stringify([path, match[2] ?? ""])
195
+ if (seen.has(key)) continue
196
+ seen.add(key)
197
+ citations.push(match[2] ? { path, symbol: match[2] } : { path })
198
+ }
199
+
200
+ return citations
201
+ }
202
+
203
+ /**
204
+ * Every knowledge lookup in this session, oldest first.
205
+ *
206
+ * Running and errored calls are skipped rather than shown as empty results:
207
+ * "cited nothing" and "has not finished" are different facts, and the sidebar
208
+ * must not merge them.
209
+ */
210
+ export function collectKnowledgeResults(
211
+ parts: readonly ToolPartLike[],
212
+ serverNames: readonly string[],
213
+ ): KnowledgeResult[] {
214
+ const results: KnowledgeResult[] = []
215
+ for (const part of parts) {
216
+ if (part.type !== "tool") continue
217
+ if (!isKnowledgeTool(part.tool, serverNames)) continue
218
+ if (part.state?.status !== "completed") continue
219
+ const parsed = parseKnowledgeOutput(part.state.output)
220
+ if (parsed) results.push(parsed)
221
+ }
222
+ return results
223
+ }
224
+
225
+ /**
226
+ * The distinct sources cited across a session, most recent lookup first.
227
+ *
228
+ * Ordering matters for a panel that is height-bounded: the sources backing the
229
+ * answer currently on screen are the ones worth the rows.
230
+ */
231
+ export function distinctCitations(results: readonly KnowledgeResult[], limit: number): Citation[] {
232
+ const out: Citation[] = []
233
+ const seen = new Set<string>()
234
+ for (const result of [...results].reverse()) {
235
+ for (const citation of result.citations) {
236
+ if (out.length >= limit) return out
237
+ if (seen.has(citation.path)) continue
238
+ seen.add(citation.path)
239
+ out.push(citation)
240
+ }
241
+ }
242
+ return out
243
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Elliot's prompt, filling `home_prompt` and `session_prompt`.
3
+ *
4
+ * It REUSES the host's `api.ui.Prompt` rather than rebuilding an editor. That is
5
+ * not a shortcut: the host's prompt carries autocomplete, file attachment,
6
+ * history, frecency, shell mode, paste handling and the leader-key layer
7
+ * (`component/prompt/index.tsx`, 1,716 lines). Reimplementing it to change the
8
+ * placeholder text would trade all of that away for copy.
9
+ *
10
+ * What Elliot changes is the copy, which is the most-read text in the whole TUI:
11
+ * upstream suggests "Fix a TODO in the codebase", which says nothing about why
12
+ * this terminal is different. The chrome around the input stays the host's, and
13
+ * is quieted through the theme instead — the rail is painted from `theme.border`
14
+ * (`component/prompt/index.tsx:1289`), so `themes/elliot.json` already softened
15
+ * it without a fork.
16
+ *
17
+ * Both slots are `mode="replace"`, so these components stand in for the host's
18
+ * own render. Every prop the host passes MUST therefore be forwarded. `ref` in
19
+ * particular is load-bearing: `routes/home.tsx` binds it to set and auto-submit
20
+ * `--prompt`, so dropping it would silently break `eai --prompt "..."` while the
21
+ * interactive TUI kept working.
22
+ *
23
+ * Props are passed as JSX attributes rather than by calling `Prompt(props)` or
24
+ * spreading an object: Solid compiles attributes into getters, and only that
25
+ * form keeps `visible` and `disabled` reactive as the session changes state.
26
+ */
27
+ import type { SessionSlotProps, TuiPluginApi } from "./api.js"
28
+
29
+ /**
30
+ * Placeholders that describe Elliot's actual capability.
31
+ *
32
+ * Knowledge-shaped, not TODO-shaped: the first thing a user tries should be the
33
+ * thing only this terminal can do. Shell placeholders stay as upstream's — shell
34
+ * mode is upstream's feature and its examples are already the right ones.
35
+ */
36
+ const PLACEHOLDERS = {
37
+ normal: [
38
+ "Ask why this service is built the way it is",
39
+ "Which PRs changed the auth flow?",
40
+ "Summarise what this repo does for a new joiner",
41
+ ],
42
+ shell: ["git status", "ls -la", "pwd"],
43
+ }
44
+
45
+ export function HomePrompt(props: { api: TuiPluginApi; slot: { ref?: (ref: unknown) => void } }) {
46
+ const Prompt = props.api.ui.Prompt
47
+ return <Prompt ref={props.slot.ref} placeholders={PLACEHOLDERS} />
48
+ }
49
+
50
+ export function SessionPrompt(props: { api: TuiPluginApi; slot: SessionSlotProps }) {
51
+ const Prompt = props.api.ui.Prompt
52
+ return (
53
+ <Prompt
54
+ sessionID={props.slot.session_id}
55
+ visible={props.slot.visible}
56
+ disabled={props.slot.disabled}
57
+ onSubmit={props.slot.on_submit}
58
+ ref={props.slot.ref}
59
+ placeholders={PLACEHOLDERS}
60
+ />
61
+ )
62
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The Elliot knowledge panel — `sidebar_content`, plus Elliot's identity in
3
+ * `sidebar_title` and `sidebar_footer`.
4
+ *
5
+ * It shows only what nothing else in the sidebar shows. Upstream's built-in
6
+ * sections already list MCP servers, LSP clients, todos and changed files, so
7
+ * restating any of them here would be exactly the duplication this redesign is
8
+ * removing. What is left — and what no stock sidebar can know — is the retrieval
9
+ * scope in force and the sources Elliot actually cited in this session.
10
+ *
11
+ * Knowledge health therefore earns a row only when it is NOT healthy. A problem
12
+ * explains a bad answer and is worth the space; "working normally" is already
13
+ * visible in the MCP section and is not.
14
+ */
15
+ import { For, Show } from "solid-js"
16
+
17
+ import type { SessionSlotProps, TuiPluginApi } from "./api.js"
18
+ import {
19
+ collectKnowledgeResults,
20
+ distinctCitations,
21
+ elliotServerNames,
22
+ knowledgeConnection,
23
+ type Citation,
24
+ type KnowledgeResult,
25
+ } from "./knowledge.js"
26
+
27
+ /** A height-bounded panel: past this, rows push the built-in sections off. */
28
+ const MAX_CITATIONS = 6
29
+
30
+ /** A small-caps-style section label, matching upstream's sidebar rhythm. */
31
+ function Label(props: { api: TuiPluginApi; children: string }) {
32
+ return (
33
+ <text fg={props.api.theme.current.border} selectable={false}>
34
+ {props.children}
35
+ </text>
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Every knowledge lookup in this session.
41
+ *
42
+ * Reads the transcript rather than calling the backend — see `knowledge.ts` for
43
+ * why a fetch here would violate the no-second-credential-path rule.
44
+ */
45
+ function resultsFor(api: TuiPluginApi, sessionID: string): KnowledgeResult[] {
46
+ const servers = elliotServerNames(api.state.mcp())
47
+ if (servers.length === 0) return []
48
+
49
+ const parts = api.state.session
50
+ .messages(sessionID)
51
+ .flatMap((message) => [...api.state.part(message.id)])
52
+ return collectKnowledgeResults(parts, servers)
53
+ }
54
+
55
+ /**
56
+ * The scope retrieval actually ran under.
57
+ *
58
+ * The backend echoes `repo_scope` in its envelope, so the most recent lookup is
59
+ * authoritative — that is the scope Elliot *used*, not the one we assume from
60
+ * the worktree. Falling back to the branch keeps the row useful before the first
61
+ * lookup, and a missing scope is reported as tenant-wide rather than left blank,
62
+ * because "unscoped" is a real and meaningful state.
63
+ */
64
+ function scopeLabel(results: readonly KnowledgeResult[]): string | undefined {
65
+ for (const result of [...results].reverse()) {
66
+ if (result.repoScope) return result.repoScope
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ function CitationRow(props: { api: TuiPluginApi; citation: Citation }) {
72
+ const theme = props.api.theme.current
73
+ return (
74
+ <box flexDirection="row" gap={1}>
75
+ <text fg={theme.textMuted} selectable={true}>
76
+ {props.citation.path}
77
+ </text>
78
+ <Show when={props.citation.symbol}>
79
+ <text fg={theme.border} selectable={false}>
80
+ {props.citation.symbol}
81
+ </text>
82
+ </Show>
83
+ </box>
84
+ )
85
+ }
86
+
87
+ /** The one row that appears only on trouble. */
88
+ function Trouble(props: { api: TuiPluginApi }) {
89
+ const theme = props.api.theme.current
90
+ const connection = () => knowledgeConnection(props.api.state.mcp())
91
+
92
+ return (
93
+ <Show when={connection().status === "failed" || connection().status === undefined}>
94
+ <box flexDirection="row" gap={1}>
95
+ <text fg={connection().status === "failed" ? theme.error : theme.warning} selectable={false}>
96
+ !
97
+ </text>
98
+ <text fg={theme.textMuted} selectable={false}>
99
+ {connection().status === "failed"
100
+ ? "knowledge unreachable — answers are unsourced"
101
+ : "no knowledge server configured"}
102
+ </text>
103
+ </box>
104
+ </Show>
105
+ )
106
+ }
107
+
108
+ export function SidebarContent(props: { api: TuiPluginApi; slot: SessionSlotProps }) {
109
+ const theme = props.api.theme.current
110
+ const results = () => resultsFor(props.api, props.slot.session_id)
111
+ const scope = () => scopeLabel(results())
112
+ const citations = () => distinctCitations(results(), MAX_CITATIONS)
113
+
114
+ return (
115
+ <box gap={1}>
116
+ <box>
117
+ <Label api={props.api}>KNOWLEDGE</Label>
118
+ <Trouble api={props.api} />
119
+ <Show
120
+ when={scope()}
121
+ fallback={
122
+ <text fg={theme.textMuted} selectable={false}>
123
+ organisation-wide
124
+ </text>
125
+ }
126
+ >
127
+ <text fg={theme.text} selectable={true}>
128
+ {scope()}
129
+ </text>
130
+ </Show>
131
+ </box>
132
+
133
+ <Show when={citations().length > 0}>
134
+ <box>
135
+ <Label api={props.api}>SOURCES CITED</Label>
136
+ <For each={citations()}>
137
+ {(citation) => <CitationRow api={props.api} citation={citation} />}
138
+ </For>
139
+ </box>
140
+ </Show>
141
+ </box>
142
+ )
143
+ }
144
+
145
+ /**
146
+ * The session title, with Elliot's mark ahead of it.
147
+ *
148
+ * `sidebar_title` is `single_winner`, so this replaces upstream's title block
149
+ * rather than stacking with it — which is why the title itself is reproduced
150
+ * here instead of only the branding.
151
+ */
152
+ export function SidebarTitle(props: { api: TuiPluginApi; slot: SessionSlotProps & { title: string } }) {
153
+ const theme = props.api.theme.current
154
+ return (
155
+ <box paddingRight={1}>
156
+ <box flexDirection="row" gap={1}>
157
+ <text fg={theme.primary} selectable={false}>
158
+ ●
159
+ </text>
160
+ <text fg={theme.text} selectable={true}>
161
+ {props.slot.title}
162
+ </text>
163
+ </box>
164
+ </box>
165
+ )
166
+ }
167
+
168
+ /** Replaces upstream's "OpenCode <version>" line; the build is Elliot's. */
169
+ export function SidebarFooter(props: { api: TuiPluginApi }) {
170
+ const theme = props.api.theme.current
171
+ return (
172
+ <box flexDirection="row" gap={1}>
173
+ <text fg={theme.primary} selectable={false}>
174
+ ●
175
+ </text>
176
+ <text fg={theme.text} selectable={false}>
177
+ Elliot AI
178
+ </text>
179
+ <text fg={theme.border} selectable={false}>
180
+ {props.api.app.version}
181
+ </text>
182
+ </box>
183
+ )
184
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The ELLIOT AI wordmark, filling `home_logo` (declared `mode="replace"`, so it
3
+ * supplants upstream's `<Logo/>`).
4
+ *
5
+ * Colour comes from the active theme, never from literals, so `themes/elliot.json`
6
+ * stays the single source of truth and the mark degrades correctly under another
7
+ * theme.
8
+ */
9
+ import { For } from "solid-js"
10
+ import { RGBA, TextAttributes } from "@opentui/core"
11
+
12
+ import type { TuiPluginApi } from "./api.js"
13
+
14
+ /**
15
+ * ELLIOT / AI, transcribed from the design's glyph grid.
16
+ *
17
+ * 4-column glyphs separated by one space, except `I` and `T` which are 3 — that
18
+ * is the design's own metric. `left` is 27 columns, `right` is 8.
19
+ *
20
+ * `_` and `^` are the host's shading marks (`packages/tui/src/logo.ts`): a cell
21
+ * painted on a tinted background, and `▀` on that same tint.
22
+ */
23
+ const WORDMARK = {
24
+ left: [
25
+ "█▀▀▀ █ █ ▀▀▀ █▀▀█ ▀▀▀",
26
+ "█^^^ █ █ █ █__█ █ ",
27
+ "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀▀ ▀ ",
28
+ ],
29
+ right: ["█▀▀█ ▀▀▀", "█^^█ █ ", "▀ ▀ ▀▀▀"],
30
+ } as const
31
+
32
+ /** The design sets ELLIOT and AI two columns apart, not one. */
33
+ const WORDMARK_GAP = 2
34
+
35
+ /**
36
+ * Tint a shadow from the theme, mirroring the host's `tint(background, fg, 0.25)`
37
+ * so a plugin-rendered wordmark shades identically to a host-rendered one.
38
+ * Reimplemented rather than imported because the host's theme context is not a
39
+ * shared module.
40
+ *
41
+ * It must return a real `RGBA`, not a matching plain object. The renderer hands
42
+ * colours to opentui's FFI (`textBufferSetDefaultBg`), which needs the
43
+ * Float32Array an `RGBA` carries; a structurally identical literal reaches the
44
+ * boundary and dies with "Expected ArrayBufferView but received Cell to a
45
+ * pointer", taking the whole TUI down. That crash was latent for as long as this
46
+ * plugin never loaded -- fixing the loader is what surfaced it.
47
+ */
48
+ function shadowOf(background: RGBA, fg: RGBA): RGBA {
49
+ const mix = (a: number, b: number) => a + (b - a) * 0.25
50
+ return RGBA.fromValues(
51
+ mix(background.r, fg.r),
52
+ mix(background.g, fg.g),
53
+ mix(background.b, fg.b),
54
+ background.a ?? 1,
55
+ )
56
+ }
57
+
58
+ function Glyphs(props: { line: string; fg: RGBA; shadow: RGBA; bold: boolean }) {
59
+ const attrs = props.bold ? TextAttributes.BOLD : undefined
60
+ return (
61
+ <box flexDirection="row">
62
+ {Array.from(props.line).map((char) => {
63
+ if (char === "_")
64
+ return (
65
+ <text fg={props.fg} bg={props.shadow} attributes={attrs} selectable={false}>
66
+ {" "}
67
+ </text>
68
+ )
69
+ if (char === "^")
70
+ return (
71
+ <text fg={props.fg} bg={props.shadow} attributes={attrs} selectable={false}>
72
+ ▀
73
+ </text>
74
+ )
75
+ return (
76
+ <text fg={props.fg} attributes={attrs} selectable={false}>
77
+ {char}
78
+ </text>
79
+ )
80
+ })}
81
+ </box>
82
+ )
83
+ }
84
+
85
+ export function Wordmark(props: { api: TuiPluginApi }) {
86
+ const theme = props.api.theme.current
87
+ return (
88
+ <box>
89
+ <For each={WORDMARK.left}>
90
+ {(line, index) => (
91
+ <box flexDirection="row" gap={WORDMARK_GAP}>
92
+ <Glyphs
93
+ line={line}
94
+ fg={theme.textMuted}
95
+ shadow={shadowOf(theme.background, theme.textMuted)}
96
+ bold={false}
97
+ />
98
+ <Glyphs
99
+ line={WORDMARK.right[index()] ?? ""}
100
+ fg={theme.primary}
101
+ shadow={shadowOf(theme.background, theme.primary)}
102
+ bold={true}
103
+ />
104
+ </box>
105
+ )}
106
+ </For>
107
+ </box>
108
+ )
109
+ }