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