No description.

A local-first personal AI agent that runs on your own machine.
The shape of Claude Code, all-file storage (no DB · no account · no cloud), and a visual Workflow Studio.

Ginno turns the capabilities an AI agent should have into a complete desktop app, instead of scattering them across a terminal and a dozen browser tabs.
Full feature walkthrough:
docs/user-guide.md.
| Workflow Studio | Local knowledge base |
|---|---|
![]() | ![]() |
One design rule: your data should not leave your machine.
| Ginno | |
|---|---|
| Database | ❌ none — all state is JSON / JSONL / Markdown under ~/.ginno/ |
| Account | ❌ none — open it and go |
| Cloud sync | ❌ none — everything stays local |
| Portable | ✅ copy the folder to move it; readable, hand-editable, git-able |
You bring your own model key (OpenAI / Anthropic / DeepSeek, or any compatible endpoint). Ginno is just a client — it never takes custody of your key or your data.
Option A — download (macOS, Apple Silicon): grab the latest .dmg from Releases. It is not code-signed yet, so on first launch use right-click → Open to get past Gatekeeper.
Option B — build from source:
git clone https://github.com/robscc/ginno
cd ginno
pnpm install # web + desktop shell
cd packages/runtime && uv sync # Python runtime
cd ../..
pnpm dev # web + runtime + desktop shell, all at once
Requires: Node ≥ 20 · pnpm ≥ 9 · Python ≥ 3.11 (via uv) · Rust (Tauri).
Package a .app: make app → apps/desktop/target/release/bundle/dmg/Ginno_*.dmg (see docs/p3-packaging-notes.md).
| Layer | Choice |
|---|---|
| Shell | Tauri 2 (Rust) — process management + webview only |
| UI | Next.js 14 (static export) + React 18 + Tailwind |
| Runtime | Python + FastAPI + LangGraph (sidecar, bundled with PyInstaller) |
| Storage | plain files (no DB); optional LanceDB for semantic-search vectors |
Architecture and subsystem designs:
docs/architecture.md.
MIT © 2026 ChuanchuanSong
hooks/schema.ts 27 lines1// The configSchema fixture: a `defineMod` module that declares both defaults
2// and the optional JSON-Schema subset the settings editor renders.
3
4import { defineMod } from '../../../../src/define-mod.ts'
5
6export default defineMod({
7 name: 'schema-mod',
8 version: '0.1.0',
9 userConfig: { units: 'metric', limit: 3 },
10 configSchema: {
11 type: 'object',
12 properties: {
13 units: {
14 type: 'string',
15 enum: ['metric', 'imperial'],
16 title: 'Units',
17 description: 'Display units',
18 default: 'metric',
19 'x-ginno-group': 'Display',
20 'x-ginno-order': 10,
21 },
22 apiKey: { type: 'string', default: '', 'x-ginno-secret': true },
23 tags: { type: 'array', items: { type: 'string' }, default: [] },
24 },
25 },
26 register() {},
27})../../../src/define-mod.ts 88 lines1/**
2 * `defineMod`: wrap a mod's `register(on, options)` as the default-export
3 * object the runner's loader reads (`userConfig` defaults, plugin identity).
4 * The Cordis mounting DSH adds is host-side work here — the broker mounts the
5 * mod from its manifests — so this wraps the spec and nothing more.
6 * Adapted from deepseek-harness (MIT), Copyright (c) 2026 DeepSeek. @license MIT
7 * @module
8 */
9
10import type { ModRegister, PluginOptions } from './types.ts'
11
12/**
13 * One property of a mod's `configSchema`: the JSON-Schema subset the settings
14 * editor renders, plus the `x-ginno-*` extensions it reads for grouping,
15 * ordering, and secret masking.
16 */
17export interface ConfigSchemaProperty {
18 readonly type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object'
19 readonly title?: string
20 readonly description?: string
21 /** The value the editor offers as "reset to default"; mirrors `userConfig`. */
22 readonly default?: unknown
23 readonly enum?: readonly unknown[]
24 /** Array items (one level is enough for the editor's tags/number-list controls). */
25 readonly items?: ConfigSchemaProperty
26 /** Nested object fields. */
27 readonly properties?: Readonly<Record<string, ConfigSchemaProperty>>
28 readonly required?: readonly string[]
29 readonly 'x-ginno-group'?: string
30 readonly 'x-ginno-order'?: number
31 readonly 'x-ginno-secret'?: boolean
32 readonly [extension: `x-ginno-${string}`]: unknown
33}
34
35/**
36 * A mod's optional config descriptor: the JSON-Schema subset `defineMod` may
37 * declare so the host can render a form without reverse-engineering defaults.
38 * Unknown keys pass through untouched (hosts ignore what they do not know).
39 */
40export interface ConfigSchema {
41 readonly type?: 'object'
42 readonly title?: string
43 readonly description?: string
44 readonly properties?: Readonly<Record<string, ConfigSchemaProperty>>
45 readonly required?: readonly string[]
46 readonly [extension: `x-ginno-${string}`]: unknown
47}
48
49/** What a mod's hooks module declares: the plugin identity and the `register`. */
50export interface ModSpec {
51 /** Plugin name, as `.claude-plugin/plugin.json` names it: letters, digits, `_` and `-`. */
52 readonly name: string
53 readonly version?: string
54 /** Absolute directory the mod ships in, reported by `$.plugin.root`; defaults to the process cwd. */
55 readonly root?: string
56 /** `userConfig` defaults: the `options` `register` receives before the deployment overlays its config. */
57 readonly userConfig?: PluginOptions
58 /** Optional config descriptor the host reports alongside the defaults. */
59 readonly configSchema?: ConfigSchema
60 readonly register: ModRegister
61}
62
63/** What `defineMod` produces: the default export a hooks module may ship. */
64export interface DefinedMod {
65 readonly name: string
66 readonly version: string | undefined
67 readonly root: string | undefined
68 readonly userConfig: PluginOptions
69 readonly configSchema: ConfigSchema | undefined
70 readonly register: ModRegister
71}
72
73/**
74 * Wrap one mod's spec as the loader expects it.
75 * @param spec - the mod's identity, `userConfig` defaults, optional config schema, and `register`.
76 * @returns the frozen default-export object.
77 */
78export function defineMod(spec: ModSpec): DefinedMod {
79 return Object.freeze({
80 name: spec.name,
81 version: spec.version,
82 root: spec.root,
83 userConfig: Object.freeze({ ...spec.userConfig }),
84 configSchema: spec.configSchema,
85 register: spec.register,
86 })
87}
88../../../src/types.ts 524 lines1/**
2 * Mods-facing types: the hooks-module contract (`register`, `on`, hooks,
3 * `next`), the event inputs and results this bridge raises, and the `$` mods
4 * API subset it serves. Field names follow Claude Code's declarations so an
5 * unmodified mod type-checks against the same names; only the members this
6 * bridge implements are declared.
7 * Adapted from deepseek-harness (MIT), Copyright (c) 2026 DeepSeek. @license MIT
8 * @module
9 */
10
11import type { UiElements, UiNode } from './elements.ts'
12
13/** Values a plugin manifest's `userConfig` fields and `register(on, options)` carry. */
14export type PluginOptionValue = string | number | boolean | readonly string[]
15
16/** The `options` argument of `register`: manifest `userConfig` defaults overlaid by deployment values. */
17export type PluginOptions = Readonly<Record<string, PluginOptionValue>>
18
19/** Tier a mod runs in; this bridge loads every mod as `user`, and its own engine behavior is `core`. */
20export type ModTier = 'prepend' | 'user' | 'append' | 'builtin' | 'core'
21
22/** Who raised an event: the engine itself, or the mod whose `$` call became the event. */
23export interface HookOrigin {
24 readonly plugin: string
25 readonly tier: ModTier
26}
27
28/** The failing hook's error as a `.catch` handler sees it on `next.error`. */
29export interface HookFailure {
30 readonly kind: 'throw' | 'timeout'
31 readonly message: string
32}
33
34/** The hook's own running-time limit: the whole limit and what is left now. */
35export interface HookBudget {
36 readonly ms: number
37 readonly remainingMs: number
38}
39
40/**
41 * The `next` handler every hook receives: calling it runs the hooks beneath,
42 * then the engine behavior, and resolves to the event's result. Time spent
43 * awaiting it does not count against the hook's budget.
44 */
45export interface HookNext<E, R> {
46 (e: E): Promise<R>
47 /** Fires when the event is abandoned (the owning agent cancelled). */
48 readonly signal: AbortSignal
49 /** Who raised the event. */
50 readonly origin: HookOrigin
51 /** The hook's running-time limit. */
52 readonly budget: HookBudget
53 /** Skipping tiers is a managed-settings feature this bridge does not implement; always throws. */
54 to(e: E, tier: ModTier): Promise<R>
55 /** In a `.catch` handler: how the hook failed. */
56 readonly error?: HookFailure
57 /** In a `.catch` handler: whether the failed hook had called `next`. */
58 readonly called?: boolean
59}
60
61/** A hook on one event: the mods API, the frozen event input, and the next handler. */
62export type ModHook<E, R> = ($: ModsApi, e: E, next: HookNext<E, R>) => R | Promise<R>
63
64/** A hook whose event and result the registry does not narrow. */
65export type AnyHook = ModHook<unknown, unknown>
66
67/** What `on` returns: `.catch` attaches the hook's error handler. */
68export interface HookRegistration {
69 catch(handler: AnyHook): void
70}
71
72/** A matcher field: one value, a list of allowed values, or a regular expression. */
73export type MatcherValue = string | number | boolean | null | RegExp | readonly (string | number | boolean | null)[]
74
75/** The optional second argument of `on`: top-level event fields the hook requires. */
76export type HookMatcher = Readonly<Record<string, MatcherValue>>
77
78/** The engine events this host raises, with their inputs and results, so a typed hook reads `e` without a cast. */
79export interface ModEvents {
80 'session.start': { input: SessionStartInput; result: SessionStartResult }
81 'session.end': { input: SessionEndInput; result: SessionEndResult }
82 'prompt.submit': { input: PromptSubmitInput; result: PromptSubmitResult }
83 'turn.start': { input: TurnStartInput; result: TurnStartResult }
84 'turn.complete': { input: TurnCompleteInput; result: TurnCompleteResult }
85 'tool.call': { input: ToolCallInput; result: ToolCallResult }
86 'command.run': { input: CommandRunInput; result: CommandRunResult }
87 'ui.render': { input: UiRenderInput; result: UiRenderResult }
88}
89
90/** The `on` function `register` receives: typed for the events this host raises, open for every other Claude Code event. */
91export interface ModOn {
92 <K extends keyof ModEvents>(event: K, hook: ModHook<ModEvents[K]['input'], ModEvents[K]['result']>): HookRegistration
93 <K extends keyof ModEvents>(event: K, matcher: HookMatcher, hook: ModHook<ModEvents[K]['input'], ModEvents[K]['result']>): HookRegistration
94 (event: string, hook: AnyHook): HookRegistration
95 (event: string, matcher: HookMatcher, hook: AnyHook): HookRegistration
96}
97
98/** The function a hooks module exports. */
99export type ModRegister = (on: ModOn, options: PluginOptions) => unknown
100
101/**
102 * One mod as a DSH plugin hands it to the bridge: the plugin identity Claude
103 * Code reads from `plugin.json`, the `register` its hooks module exports, and
104 * the `options` that `register` receives (the mod's validated plugin config).
105 */
106export interface ModDefinition {
107 /** Plugin name: letters, digits, `_` and `-`; the `plugin` of `$.state` refs and the `mcp__<plugin>__` tool prefix. */
108 readonly name: string
109 readonly version?: string
110 /** Absolute directory the mod ships in, as `$.plugin.root` reports it; defaults to the process cwd. */
111 readonly root?: string
112 readonly options?: PluginOptions
113 readonly register: ModRegister
114}
115
116// ---- Drawn surfaces, as a Client receives them ----
117
118/** A serialized element: callbacks replaced by the action id the host holds for them. */
119export interface SerializedElement {
120 readonly type: 'Box' | 'Text' | 'Button' | 'Input' | 'Select' | 'Markdown' | 'Code'
121 readonly props: Readonly<Record<string, string | number | boolean>>
122 readonly children: readonly SerializedNode[]
123 /** Set on a `Button` whose `onPress` the host holds. */
124 readonly actionId?: string
125}
126
127/** A serialized tree node: an element or a run of text. */
128export type SerializedNode = SerializedElement | string
129
130/** One drawn generation of a session's band above the prompt. */
131export interface SurfaceSnapshot {
132 /** Increases with every redraw; a press names the generation it saw. */
133 readonly generation: number
134 /** The serialized tree, or null when no mod drew. */
135 readonly tree: readonly SerializedNode[] | null
136}
137
138// ---- Event inputs and results ----
139
140/** `ui.render`: the host asks the mods to draw one surface; the first hook that returns a tree draws it. */
141export interface UiRenderInput {
142 /** The surface being drawn: the band above the prompt, or a pane a mod opened. */
143 component: 'AbovePrompt' | 'Pane'
144 /** Stable surface instance id: the pane id, or the component name for the band. */
145 surface: string
146 /** The pane id the mod chose in `$.ui.open`, on a `Pane`. */
147 requestId?: string
148 /** Surface-owned props: `bodyColumns`, `hasSurvey`, `isWorking`, `maxRows` on the band. */
149 props: Readonly<Record<string, unknown>>
150 /** Drawable size the host knows. */
151 viewport: { readonly columns: number; readonly rows?: number }
152}
153
154/** `ui.render` result: the tree to draw; `next(e)` yields the surface to the next mod and finally to the host, which draws nothing. */
155export type UiRenderResult = UiNode
156
157/** Where a prompt or command came from. */
158export type PromptOrigin =
159 | { kind: 'composer' }
160 | { kind: 'plugin'; name: string }
161
162/** `session.start`: once per root agent before its first prompt; every loaded mod's hook sees the same event. */
163export interface SessionStartInput {
164 cwd: string
165 /** The drawing surface's name (Claude Code: `terminal`, `desktop`); this host reports `null` when no surface draws mods. */
166 surface: string | null
167 /** Whether a human answerer is composed, so `$.ui.ask` can resolve. */
168 isInteractive: boolean
169}
170
171/** Result of `session.start`. */
172export interface SessionStartResult {
173 cwd: string
174}
175
176/** `session.end`: the agent was disposed. */
177export interface SessionEndInput {
178 reason: 'clear' | 'logout' | 'prompt_input_exit' | 'resume' | 'other'
179 sessionId: string
180}
181
182/** Result of `session.end`. */
183export interface SessionEndResult {
184 sessionId: string
185}
186
187/** `prompt.submit`: a prompt is about to enter a turn. */
188export interface PromptSubmitInput {
189 text: string
190 /** Extra text only the model reads, appended after the prompt. */
191 context?: readonly string[]
192 turnId?: string
193 wait: boolean
194 origin: PromptOrigin
195}
196
197/** Result of `prompt.submit`: the text that entered, or a drop with its reason. */
198export type PromptSubmitResult =
199 | { text: string; context?: readonly string[]; drop?: undefined }
200 | { drop: string; text?: undefined; context?: undefined }
201
202/** `turn.start`: a turn begins with this prompt text. */
203export interface TurnStartInput {
204 text: string
205 turnId: string
206 agentId?: string
207}
208
209/** Result of `turn.start`. */
210export interface TurnStartResult {
211 turnId: string
212}
213
214/** Token totals of one turn, in Claude Code's API vocabulary. */
215export interface TurnUsage {
216 input_tokens: number
217 output_tokens: number
218 cache_read_input_tokens: number
219 cache_creation_input_tokens: number
220 model: string
221}
222
223/** `turn.complete`: a turn ended. */
224export interface TurnCompleteInput {
225 turnId: string
226 answer: string
227 durationMs: number
228 isAborted: boolean
229 reason: 'answer' | 'aborted' | 'error'
230 agentId?: string
231 usage?: TurnUsage
232}
233
234/** Result of `turn.complete`: `text` is shown as a line under the answer. */
235export interface TurnCompleteResult {
236 text: string
237}
238
239/** `tool.call`: the tool's name and arguments as top-level fields. */
240export interface ToolCallInput {
241 tool: string
242 tool_use_id: string
243 agentId?: string
244 [argument: string]: unknown
245}
246
247/** Result of `tool.call`: a refusal, or the tool's result. */
248export type ToolCallResult =
249 | { deny: string; result?: undefined; isError?: undefined }
250 | { result: unknown; isError?: true; deny?: undefined }
251
252/** `command.run`: a registered command was typed. */
253export interface CommandRunInput {
254 command: string
255 /** Text typed after the command name, or an empty string. */
256 args: string
257 origin: PromptOrigin
258}
259
260/** Result of `command.run`: `text` prints in the transcript; `{}` prints nothing. */
261export interface CommandRunResult {
262 text?: string
263}
264
265/** Result of a mods API call as an event: the value, or a refusal. */
266export type OpResult = { value: unknown; deny?: undefined } | { deny: string; value?: undefined }
267
268// ---- The mods API (`$`) ----
269
270/** A command a mod registers for the user. */
271export interface CommandSpec {
272 name: string
273 description: string
274 argumentHint?: string
275 immediate?: true
276}
277
278/** A command in the effective list. */
279export interface CommandInfo {
280 name: string
281 description: string
282 source: 'builtin' | 'plugin' | 'user' | 'mcp'
283 plugin?: string
284}
285
286/** A tool a mod registers for the model. */
287export interface ToolSpec {
288 name: string
289 description: string
290 inputSchema?: Record<string, unknown>
291}
292
293/** A tool in the effective list. */
294export interface ToolInfo {
295 name: string
296 description: string
297}
298
299/** One message of the transcript as `$.session.messages()` returns it. */
300export interface SessionMessage {
301 role: 'user' | 'assistant'
302 text: string
303 toolUses: ToolUseSummary[]
304}
305
306/** One tool call inside an assistant message. */
307export interface ToolUseSummary {
308 tool_use_id: string
309 tool: string
310 input: Record<string, unknown>
311}
312
313/** Context-window occupancy and plan limits. */
314export interface SessionUsage {
315 startedAt: number
316 context: { tokens?: number; window: number; percent?: number }
317 rateLimits: never[]
318}
319
320/** The mods API version the host reports; the broker answers `$.session.version`. */
321export interface SessionVersion {
322 version: string
323 engine: string
324}
325
326/** `$.ui.ask` options. */
327export interface AskOptions {
328 options?: readonly string[]
329 header?: string
330 multiSelect?: true
331}
332
333/** Where `$.ui.log` writes. */
334export interface UiLogOptions {
335 to?: 'transcript' | 'debug'
336}
337
338/** `$.ui.toast` options. */
339export interface ToastOptions {
340 timeoutMs?: number
341}
342
343/** `$.ui.open` arguments. */
344export interface PaneOpenArgs {
345 id: string
346 title?: string
347 focus?: true
348 closeOnEscape?: true
349 holdToasts?: true
350 rows?: number
351 columns?: number
352}
353
354/** `$.ui.open` result: this bridge places no panes. */
355export interface PaneOpenResult {
356 /** The pane id the mod chose. */
357 id: string
358 isPlaced: boolean
359 reason?: string
360}
361
362/** A `$.state` value reference. */
363export interface StateRef {
364 plugin: string
365 key: string
366}
367
368/** A timer returned by `$.clock.after` and `$.clock.every`. */
369export interface ModTimer {
370 cancel(): void
371}
372
373/** A file entry from `$.fs.list`. */
374export interface FsEntry {
375 name: string
376 kind: 'file' | 'dir' | 'other'
377 size: number
378 isLink: boolean
379}
380
381/** `$.fs.stat` result. */
382export interface FsStat {
383 kind: 'file' | 'dir' | 'other'
384 size: number
385 mtimeMs: number
386 isLink: boolean
387}
388
389/** `$.process.run` options. */
390export interface ProcessRunInit {
391 cwd?: string
392 env?: Record<string, string>
393 timeoutMs?: number
394}
395
396/** `$.process.run` result, whatever the exit code. */
397export interface ProcessRunResult {
398 exitCode: number
399 stdout: string
400 stderr: string
401}
402
403/** `$.http.fetch` options. */
404export interface HttpInit {
405 method?: string
406 headers?: Record<string, string>
407 body?: string
408 timeoutMs?: number
409}
410
411/** `$.http.fetch` result once the body is read. */
412export interface HttpResponse {
413 status: number
414 ok: boolean
415 headers: Record<string, string>
416 text: string
417}
418
419/** `$.prompt.submit` arguments. */
420export interface PromptSubmitArgs {
421 text: string
422 /** Send the text as the user's own words, without the sentence naming the mod. */
423 asUser?: boolean
424}
425
426/**
427 * The mods API a hook receives as `$`. Every method except `$.plugin` and
428 * `$.ui.resolve` is also an event named `<namespace>.<method>` that mods
429 * earlier in the chain may observe, rewrite, or refuse.
430 */
431export interface ModsApi {
432 readonly plugin: { readonly name: string; readonly root: string }
433 readonly ui: {
434 log(text: string, options?: UiLogOptions): void
435 toast(text: string, options?: ToastOptions): void
436 status(text: string | undefined): void
437 invalidate(event: string): void
438 open(pane: PaneOpenArgs): Promise<PaneOpenResult>
439 close(pane: { id: string }): Promise<void>
440 panes(): Promise<never[]>
441 ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
442 /** The element constructors for the surface a `ui.render` event draws; not an event itself. */
443 resolve(e: UiRenderInput): UiElements
444 }
445 readonly command: {
446 register(command: CommandSpec): Promise<void>
447 run(input: { command: string; args?: string }): Promise<CommandRunResult>
448 list(): Promise<CommandInfo[]>
449 }
450 readonly tool: {
451 register(tool: ToolSpec): Promise<void>
452 call(input: { tool: string; [argument: string]: unknown }): Promise<ToolCallResult>
453 list(): Promise<ToolInfo[]>
454 }
455 readonly prompt: {
456 submit(input: PromptSubmitArgs): Promise<{ text: string }>
457 /** 把 text 写进该会话的 composer 草稿(claude 语义:接管草稿)。 */
458 fill(input: { text: string }): Promise<{ isFilled: boolean }>
459 /** composer 空闲时的幽灵建议(Tab 采纳);Ginno v1 前端无 ghost-text,
460 * 落字与否由前端按 suggest 语义决定。 */
461 suggest(input: { text: string }): Promise<{ ok: boolean }>
462 /** 读 composer 当前草稿(无活跃前端时 3s 超时降级空串)。 */
463 read(): Promise<{ text: string }>
464 /** 段落组装:整体重置会话段落模型并把合成文本推进 composer。 */
465 compose(input: { sections: readonly { id: string; text: string; settled?: boolean }[]; sep?: string }): Promise<{ text: string }>
466 /** 单段 get/set/settle:set 只改未冻结段并重推合成文本,settle 冻结。 */
467 section(input: { op: 'get' | 'set' | 'settle'; id: string; text?: string }): Promise<{ text: string; settled?: boolean; composed?: string }>
468 }
469 readonly model: {
470 /** 以会话 checkpoint 历史为上下文的单发 fork 调用(共享上下文摊薄
471 * 缓存)。isAnswered 恒 true——模型失败走 op 错误(promise 拒绝)。 */
472 fork(input: { prompt: string; model?: string; maxTokens?: number }): Promise<{
473 sessionId: string
474 text: string
475 isAnswered: boolean
476 }>
477 complete(input: { prompt: string; system?: string; maxTokens?: number; model?: string }): Promise<{ text: string }>
478 classify(input: { input: string; labels: readonly string[]; model?: string }): Promise<{ label: string }>
479 }
480 readonly session: {
481 id(): Promise<string>
482 cwd(): Promise<string>
483 root(): Promise<string>
484 model(): Promise<string>
485 turns(): Promise<number>
486 messages(): Promise<SessionMessage[]>
487 usage(): Promise<SessionUsage>
488 version(): Promise<SessionVersion>
489 }
490 readonly state: {
491 get(ref: StateRef): Promise<{ value: unknown }>
492 set(ref: StateRef, value: unknown): Promise<void>
493 }
494 readonly store: {
495 get(key: string): Promise<unknown>
496 set(key: string, value: unknown): Promise<void>
497 delete(key: string): Promise<void>
498 keys(): Promise<string[]>
499 }
500 readonly clock: {
501 now(): Promise<number>
502 sleep(ms: number): Promise<void>
503 after(ms: number, fn: () => unknown): ModTimer
504 every(ms: number, fn: () => unknown): ModTimer
505 }
506 readonly fs: {
507 read(path: string): Promise<string>
508 write(path: string, text: string): Promise<void>
509 list(path?: string): Promise<FsEntry[]>
510 exists(path: string): Promise<boolean>
511 stat(path: string): Promise<FsStat>
512 }
513 readonly process: {
514 run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
515 }
516 readonly http: {
517 fetch(url: string, init?: HttpInit): Promise<HttpResponse>
518 }
519 readonly env: {
520 get(name: string): Promise<string | undefined>
521 set(name: string, value: string | undefined): Promise<void>
522 }
523}
524../../../src/elements.ts 270 lines1/**
2 * The element vocabulary a `ui.render` hook answers with — Claude Code's
3 * `Box`, `Text`, and `Button` constructors — and what the host does with a
4 * tree: validate it, serialize it with callbacks replaced by action ids, and
5 * search it the way a mod's test does.
6 * Adapted from deepseek-harness (MIT), Copyright (c) 2026 DeepSeek. @license MIT
7 * @module
8 */
9
10import type { SerializedNode } from './types.ts'
11
12/** One element a constructor returns; `props` are the constructor's argument, frozen. */
13export interface UiElement {
14 readonly type: 'Box' | 'Text' | 'Button' | 'Input' | 'Select' | 'Markdown' | 'Code'
15 readonly props: Readonly<Record<string, unknown>>
16}
17
18/** What a `ui.render` hook returns: an element, text, nothing, or a list of those. */
19export type UiNode = UiElement | string | number | null | undefined | false | readonly UiNode[]
20
21/** `Box` props this host lays out; a prop set to `undefined` is one a mod left out. */
22export interface BoxProps {
23 readonly flexDirection?: 'row' | 'column' | undefined
24 readonly paddingX?: number | undefined
25 readonly paddingY?: number | undefined
26 readonly padding?: number | undefined
27 readonly gap?: number | undefined
28 readonly border?: boolean | string | undefined
29 readonly borderColor?: string | undefined
30 readonly children?: UiNode | undefined
31}
32
33/** `Text` props this host styles. */
34export interface TextProps {
35 readonly color?: string | undefined
36 readonly bold?: boolean | undefined
37 readonly dimColor?: boolean | undefined
38 readonly italic?: boolean | undefined
39 readonly underline?: boolean | undefined
40 readonly children?: UiNode | undefined
41}
42
43/** `Button` props: a label, an optional hotkey hint, and the callback a press runs on the host. */
44export interface ButtonProps {
45 readonly label: string
46 readonly hotkey?: string | undefined
47 readonly disabled?: boolean | undefined
48 readonly onPress?: (() => unknown) | undefined
49}
50
51/** `Input` props (P2 vocabulary): a one-line text field; `onInput(value)` rides
52 * the same action-id round trip as Button's onPress. */
53export interface InputProps {
54 readonly value?: string | undefined
55 readonly placeholder?: string | undefined
56 readonly onInput?: ((value: string) => unknown) | undefined
57}
58
59/** `Select` props: an options list; `onSelect(value)` on pick. */
60export interface SelectProps {
61 readonly options: readonly { readonly value: string; readonly label?: string | undefined }[]
62 readonly value?: string | undefined
63 readonly onSelect?: ((value: string) => unknown) | undefined
64}
65
66/** `Markdown` props: inline Markdown body (untrusted text — the host renders
67 * it read-only, no script execution). */
68export interface MarkdownProps {
69 readonly children?: string | undefined
70}
71
72/** `Code` props: preformatted body. */
73export interface CodeProps {
74 readonly children?: string | undefined
75 readonly language?: string | undefined
76}
77
78/** The constructors `$.ui.resolve(e)` returns. */
79export interface UiElements {
80 readonly Box: (props: BoxProps) => UiElement
81 readonly Text: (props: TextProps) => UiElement
82 readonly Button: (props: ButtonProps) => UiElement
83 readonly Input: (props: InputProps) => UiElement
84 readonly Select: (props: SelectProps) => UiElement
85 readonly Markdown: (props: MarkdownProps) => UiElement
86 readonly Code: (props: CodeProps) => UiElement
87}
88
89export type { SerializedElement, SerializedNode } from './types.ts'
90
91/** What a serialized tree may carry per prop: scalars only. `onInput`/`onSelect`
92 * ride the same action-id channel as `onPress`. */
93const CALLBACK_PROPS: ReadonlySet<string> = new Set(['onPress', 'onInput', 'onSelect'])
94function scalarProps(props: Readonly<Record<string, unknown>>): Record<string, string | number | boolean> {
95 const scalars: Record<string, string | number | boolean> = {}
96 for (const [name, value] of Object.entries(props)) {
97 if (name === 'children' || CALLBACK_PROPS.has(name)) continue
98 if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') scalars[name] = value
99 }
100 return scalars
101}
102
103const elementBrand = Symbol('claude-code-mods.element')
104
105type AnyElementProps = BoxProps | TextProps | ButtonProps | InputProps | SelectProps | MarkdownProps | CodeProps
106/** Build one branded element. Exported for the JSX `h` adapter (loader.ts):
107 * a mod's pragma global must produce the same branded shape the vocabulary
108 * constructors do, or the render tree fails validation. */
109export function element(type: UiElement['type'], props: AnyElementProps): UiElement {
110 return Object.freeze({ type, props: Object.freeze({ ...props }), [elementBrand]: true })
111}
112
113/**
114 * Whether a value is an element one of this host's constructors returned.
115 * @param value - the candidate.
116 * @returns true for a constructor-made element.
117 */
118export function isUiElement(value: unknown): value is UiElement {
119 return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[elementBrand] === true
120}
121
122/**
123 * The element constructors for one render.
124 * @returns `Box`, `Text`, `Button`, `Input`, `Select`, `Markdown`, and `Code`.
125 */
126export function uiElements(): UiElements {
127 return Object.freeze({
128 Box: (props: BoxProps) => element('Box', props),
129 Text: (props: TextProps) => element('Text', props),
130 Button: (props: ButtonProps) => element('Button', props),
131 Input: (props: InputProps) => element('Input', props),
132 Select: (props: SelectProps) => element('Select', props),
133 Markdown: (props: MarkdownProps) => element('Markdown', props),
134 Code: (props: CodeProps) => element('Code', props),
135 })
136}
137
138/** Flatten a node into the elements and text runs it draws, dropping what draws nothing. */
139function childrenOf(node: UiNode): (UiElement | string)[] {
140 if (node === null || node === undefined || node === false) return []
141 if (typeof node === 'string') return node.length === 0 ? [] : [node]
142 if (typeof node === 'number') return [String(node)]
143 if (isUiElement(node)) return [node]
144 if (Array.isArray(node)) return (node as readonly UiNode[]).flatMap(childrenOf)
145 throw new TypeError(`a tree node is ${typeof node}, not an element, text, or a list of them`)
146}
147
148/**
149 * Check a `ui.render` answer against the vocabulary.
150 * @param node - the hook's answer.
151 * @returns undefined for a valid tree, or the reason it does not validate.
152 */
153/** Element types whose payload is a single `children` text (Markdown/Code) —
154 * serialized like Box children (a string child flattens to itself). */
155const TEXT_BODY_TYPES: ReadonlySet<string> = new Set(['Markdown', 'Code'])
156/** Element types with a host-invoked callback and no children (Button/Input/Select). */
157const CALLBACK_TYPES: ReadonlySet<string> = new Set(['Button', 'Input', 'Select'])
158
159export function treeProblem(node: UiNode): string | undefined {
160 try {
161 for (const child of childrenOf(node)) {
162 if (typeof child === 'string') continue
163 if (child.type === 'Button') {
164 if (typeof child.props['label'] !== 'string') return 'a Button needs a string label'
165 if (child.props['onPress'] !== undefined && typeof child.props['onPress'] !== 'function') return 'a Button onPress must be a function'
166 continue
167 }
168 if (child.type === 'Input') {
169 if (child.props['onInput'] !== undefined && typeof child.props['onInput'] !== 'function') return 'an Input onInput must be a function'
170 continue
171 }
172 if (child.type === 'Select') {
173 if (!Array.isArray(child.props['options'])) return 'a Select needs an options array'
174 if (child.props['onSelect'] !== undefined && typeof child.props['onSelect'] !== 'function') return 'a Select onSelect must be a function'
175 continue
176 }
177 if (TEXT_BODY_TYPES.has(child.type)) {
178 if (child.props['children'] !== undefined && typeof child.props['children'] !== 'string') return `a ${child.type} body must be a string`
179 continue
180 }
181 const nested = treeProblem(child.props['children'] as UiNode)
182 if (nested !== undefined) return nested
183 }
184 return undefined
185 } catch (error: unknown) {
186 /* v8 ignore next -- childrenOf throws TypeErrors only; the String branch covers a future non-Error throw */
187 return error instanceof Error ? error.message : String(error)
188 }
189}
190
191/**
192 * Serialize a validated tree for a client: scalars kept, children flattened,
193 * each `onPress` replaced by the id `holdAction` returns for it.
194 * @param node - the hook's answer, already validated with {@link treeProblem}.
195 * @param holdAction - stores one callback and returns its id.
196 * @returns the serialized nodes, in drawing order.
197 */
198export function serializeTree(node: UiNode, holdAction: (callback: (...args: never[]) => unknown) => string): SerializedNode[] {
199 return childrenOf(node).map((child): SerializedNode => {
200 if (typeof child === 'string') return child
201 if (CALLBACK_TYPES.has(child.type)) {
202 // The single callback prop, whichever it is, becomes the action id.
203 const callback = child.props['onPress'] ?? child.props['onInput'] ?? child.props['onSelect']
204 return {
205 type: child.type,
206 props: scalarProps(child.props),
207 children: [],
208 ...typeof callback === 'function' ? { actionId: holdAction(callback as (...args: never[]) => unknown) } : {},
209 }
210 }
211 if (TEXT_BODY_TYPES.has(child.type)) {
212 // Markdown/Code: the body is a string child (already validated).
213 return { type: child.type, props: scalarProps(child.props), children: [String(child.props['children'] ?? '')] }
214 }
215 return { type: child.type, props: scalarProps(child.props), children: serializeTree(child.props['children'] as UiNode, holdAction) }
216 })
217}
218
219/** The text a node draws, with child texts joined in order. */
220function textOf(node: UiElement | string): string {
221 if (typeof node === 'string') return node
222 if (node.type === 'Button') return String(node.props['label'])
223 if (TEXT_BODY_TYPES.has(node.type)) return String(node.props['children'] ?? '')
224 return childrenOf(node.props['children'] as UiNode).map(textOf).join('')
225}
226
227/** What a tree search matches on. */
228export interface TreePattern {
229 readonly type?: UiElement['type']
230 /** Text drawn by the element, including its descendants; a string matches as a substring. */
231 readonly text?: string | RegExp
232 /** A Button's label. */
233 readonly label?: string | RegExp
234}
235
236function textMatches(expected: string | RegExp, actual: string): boolean {
237 return typeof expected === 'string' ? actual.includes(expected) : expected.test(actual)
238}
239
240/**
241 * Every element of a tree matching a pattern, in drawing order.
242 * @param node - the tree.
243 * @param pattern - the type, text, and label to match; an empty pattern matches every element.
244 * @returns the matching elements.
245 */
246export function findAll(node: UiNode, pattern: TreePattern): UiElement[] {
247 const found: UiElement[] = []
248 const visit = (child: UiElement | string): void => {
249 if (typeof child === 'string') return
250 const matchesType = pattern.type === undefined || child.type === pattern.type
251 const matchesText = pattern.text === undefined || textMatches(pattern.text, textOf(child))
252 const matchesLabel = pattern.label === undefined || (child.type === 'Button' && textMatches(pattern.label, String(child.props['label'])))
253 if (matchesType && matchesText && matchesLabel) found.push(child)
254 if (!CALLBACK_TYPES.has(child.type) && !TEXT_BODY_TYPES.has(child.type)) {
255 for (const grandchild of childrenOf(child.props['children'] as UiNode)) visit(grandchild)
256 }
257 }
258 for (const child of childrenOf(node)) visit(child)
259 return found
260}
261
262/**
263 * The plain text a tree draws, one line per top-level node: the test kit's and the host log's view of a surface.
264 * @param node - the tree.
265 * @returns the drawn text.
266 */
267export function renderText(node: UiNode): string {
268 return childrenOf(node).map(textOf).join('\n')
269}
270