@elliotsystems/eai 0.1.2 → 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.
- package/README.md +51 -5
- package/bin/eai +32 -3
- package/config/opencode.json +4 -0
- package/config/themes/elliot.json +105 -0
- package/config/tui.json +9 -0
- package/lib/resolve.js +42 -4
- package/lib/stage.js +116 -0
- package/package.json +9 -8
- package/plugin/opencode-plugin/README.md +64 -0
- package/plugin/opencode-plugin/package.json +32 -0
- package/plugin/opencode-plugin/src/classifier.ts +238 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +133 -0
- package/plugin/opencode-plugin/src/index.ts +199 -0
- package/plugin/opencode-plugin/src/mcp-keys.ts +30 -0
- package/plugin/opencode-plugin/src/policy.ts +122 -0
- package/plugin/opencode-plugin/src/proxy-provider.ts +96 -0
- package/plugin/opencode-plugin/src/scope.ts +78 -0
- package/plugin/opencode-plugin/src/tui/api.ts +117 -0
- package/plugin/opencode-plugin/src/tui/format.ts +73 -0
- package/plugin/opencode-plugin/src/tui/home.tsx +88 -0
- package/plugin/opencode-plugin/src/tui/host.d.ts +95 -0
- package/plugin/opencode-plugin/src/tui/index.tsx +99 -0
- package/plugin/opencode-plugin/src/tui/knowledge.ts +243 -0
- package/plugin/opencode-plugin/src/tui/prompt.tsx +62 -0
- package/plugin/opencode-plugin/src/tui/sidebar.tsx +184 -0
- package/plugin/opencode-plugin/src/tui/wordmark.tsx +109 -0
- package/plugin/opencode-plugin/themes/elliot.json +105 -0
- package/plugin/opencode-plugin/tsconfig.json +19 -0
|
@@ -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
|
+
}
|