@elliotsystems/eai 0.1.2 → 0.1.4

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,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
+ }
@@ -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
+ }