@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.
- package/README.md +10 -5
- package/bin/eai +53 -3
- package/config/opencode.json +4 -0
- package/config/themes/elliot.json +105 -0
- package/config/tui.json +9 -0
- package/lib/resolve.js +44 -12
- package/lib/setup.js +231 -0
- package/lib/stage.js +116 -0
- package/package.json +8 -10
- 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/identity.ts +69 -0
- package/plugin/opencode-plugin/src/index.ts +211 -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,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
|
+
}
|