create-theokit 1.0.16 → 1.1.0

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.
Files changed (27) hide show
  1. package/dist/cli.js +247 -94
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +8 -7
  4. package/templates/default/agents/chat.ts +7 -7
  5. package/templates/default/app/page.test.tsx +36 -0
  6. package/templates/default/app/page.tsx +36 -116
  7. package/templates/default/dot-claude/skills/theokit-agents/SKILL.md +3 -3
  8. package/templates/default/package.json.tmpl +5 -2
  9. package/templates/default/server/routes/health.ts +5 -5
  10. package/templates/default/theo.config.ts +2 -2
  11. package/templates/default/tsconfig.json +3 -1
  12. package/templates/surfaces/desktop/README-surface.md.tmpl +31 -0
  13. package/templates/surfaces/desktop/frontend/index.html.tmpl +39 -0
  14. package/templates/surfaces/desktop/frontend/src/main.ts +84 -0
  15. package/templates/surfaces/desktop/frontend/vite.config.ts +12 -0
  16. package/templates/surfaces/desktop/sidecar/sidecar-core.ts +37 -0
  17. package/templates/surfaces/desktop/sidecar/sidecar.ts +51 -0
  18. package/templates/surfaces/desktop/src-tauri/Cargo.toml.tmpl +16 -0
  19. package/templates/surfaces/desktop/src-tauri/build.rs +3 -0
  20. package/templates/surfaces/desktop/src-tauri/capabilities/default.json +14 -0
  21. package/templates/surfaces/desktop/src-tauri/src/lib.rs +82 -0
  22. package/templates/surfaces/desktop/src-tauri/src/main.rs +6 -0
  23. package/templates/surfaces/desktop/src-tauri/tauri.conf.json.tmpl +30 -0
  24. package/templates/surfaces/tui/README-surface.md.tmpl +26 -0
  25. package/templates/surfaces/tui/tui/App.tsx.tmpl +64 -0
  26. package/templates/surfaces/tui/tui/main.tsx.tmpl +14 -0
  27. package/LICENSE +0 -201
@@ -15,8 +15,8 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "theokit": "^0.15.1",
19
- "@theokit/agents": "^0.30.1",
18
+ "theokit": "^0.30.0",
19
+ "@theokit/agents": "^0.35.0",
20
20
  "@theokit/sdk": "^2.13.0",
21
21
  "@theokit/ui": "^1.0.0",
22
22
  "@usetheo/ui": "^0.14.0",
@@ -27,8 +27,11 @@
27
27
  "zod": "^4.0.0"
28
28
  },
29
29
  "devDependencies": {
30
+ "@testing-library/react": "^16.0.0",
31
+ "@types/node": "^22.0.0",
30
32
  "@types/react": "^19.0.0",
31
33
  "@types/react-dom": "^19.0.0",
34
+ "jsdom": "^25.0.0",
32
35
  "tailwindcss": "^4.0.0",
33
36
  "@tailwindcss/vite": "^4.0.0",
34
37
  "eslint": "^9.0.0",
@@ -1,9 +1,9 @@
1
- import { defineRoute } from 'theokit/server/define'
1
+ import { route } from 'theokit/server/define'
2
2
 
3
- export const GET = defineRoute({
4
- handler: () => ({
3
+ export const GET = route()
4
+ .handler(() => ({
5
5
  status: 'ok',
6
6
  timestamp: Date.now(),
7
7
  framework: 'TheoKit',
8
- }),
9
- })
8
+ }))
9
+ .build()
@@ -1,3 +1,3 @@
1
- import { defineConfig } from 'theokit'
1
+ import { config } from 'theokit'
2
2
 
3
- export default defineConfig({})
3
+ export default config().build()
@@ -9,7 +9,9 @@
9
9
  "skipLibCheck": true,
10
10
  "jsx": "react-jsx",
11
11
  "isolatedModules": true,
12
- "resolveJsonModule": true
12
+ "resolveJsonModule": true,
13
+ "experimentalDecorators": true,
14
+ "emitDecoratorMetadata": true
13
15
  },
14
16
  "include": ["app/**/*.ts", "app/**/*.tsx", "server/**/*.ts", "agents/**/*.ts"]
15
17
  }
@@ -0,0 +1,31 @@
1
+ # {{name}} — TheoKit agent (desktop / Tauri surface)
2
+
3
+ A desktop agent app scaffolded by `create-theokit --surface desktop`. Three tiers (ADR-0045):
4
+
5
+ 1. **Webview** (`frontend/`) — a Vite-bundled page that consumes the agent via the **unified client**:
6
+ `createAgentClient(new ChannelTransport({ source }))` from the React-FREE `theokit/client/core`
7
+ (M42 + M44). No React, no bespoke reader — the same client the web + terminal surfaces use.
8
+ 2. **Rust shell** (`src-tauri/`) — spawns the Node sidecar, pushes its stdout lines to the webview over
9
+ a Tauri `Channel`, and forwards HITL decisions to the sidecar's stdin.
10
+ 3. **Node sidecar** (`sidecar/`) — runs the agent in-process (`streamAgentTurnInProcess` → JSONL stdout,
11
+ M35/M36 server seam). No HTTP, no port.
12
+
13
+ ## Prerequisites (toolchain)
14
+
15
+ The desktop build needs the **Rust toolchain + Tauri v2 prerequisites** (see
16
+ <https://v2.tauri.app/start/prerequisites/>) in addition to Node. The Node/TypeScript tiers (webview,
17
+ sidecar) run without Rust; the packaged desktop app requires it.
18
+
19
+ ## Run
20
+
21
+ ```bash
22
+ export OPENROUTER_API_KEY=... # or ANTHROPIC_API_KEY
23
+ # 1. Build the sidecar to src-tauri/binaries/theo-sidecar-<target-triple> (see externalBin in tauri.conf.json).
24
+ # e.g. bundle sidecar/sidecar.ts with your Node launcher of choice, or run it via a `node` externalBin.
25
+ # 2. Launch the desktop shell (builds the webview via Vite, then runs Tauri):
26
+ npm run dev # tauri dev
27
+ ```
28
+
29
+ The agent lives in `agents/chat.ts` (edit its model / system prompt). Wire HITL by adding a
30
+ `@HumanInTheLoop`-gated tool — the sidecar emits `tool-approval-request`, the webview shows Approve/Deny,
31
+ and `createAgentClient(...).approve(id, { approved })` routes the decision through the transport's `settle`.
@@ -0,0 +1,39 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{name}} — TheoKit desktop</title>
7
+ <style>
8
+ body {
9
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
10
+ margin: 0;
11
+ padding: 16px;
12
+ background: #0b0e14;
13
+ color: #c8d3e0;
14
+ }
15
+ h3 { margin: 0 0 12px; color: #7dcfff; }
16
+ #out { white-space: pre-wrap; min-height: 55vh; line-height: 1.5; }
17
+ .err { color: #f7768e; }
18
+ form { display: flex; gap: 8px; margin-top: 12px; }
19
+ input {
20
+ flex: 1;
21
+ padding: 8px;
22
+ background: #151922;
23
+ color: inherit;
24
+ border: 1px solid #2a2f3a;
25
+ border-radius: 6px;
26
+ }
27
+ button { padding: 8px 14px; border-radius: 6px; }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <h3>◆ {{name}} — TheoKit agent (desktop)</h3>
32
+ <div id="out"></div>
33
+ <form id="composer">
34
+ <input id="input" placeholder="Message the agent…" autocomplete="off" />
35
+ <button type="submit">Send</button>
36
+ </form>
37
+ <script type="module" src="/src/main.ts"></script>
38
+ </body>
39
+ </html>
@@ -0,0 +1,84 @@
1
+ import {
2
+ ChannelTransport,
3
+ createAgentClient,
4
+ type ApprovalDecision,
5
+ type ChannelPushSource,
6
+ } from 'theokit/client/core'
7
+
8
+ /**
9
+ * M45 — the desktop webview on the UNIFIED client. It consumes the agent via
10
+ * `createAgentClient(new ChannelTransport({ source }))` from the React-FREE `theokit/client/core`
11
+ * (M42 + M44) — NO React in the webview, no bespoke `channel.onmessage` reader. The Tauri
12
+ * `Channel`/`invoke` is wrapped as an injected `ChannelPushSource` (M42), so this file is Tauri-aware
13
+ * but the client is the same one the web + terminal surfaces use.
14
+ */
15
+ interface TauriChannel {
16
+ onmessage: (line: string) => void
17
+ }
18
+ interface TauriCore {
19
+ invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
20
+ Channel: new () => TauriChannel
21
+ }
22
+ const tauri = (globalThis as { __TAURI__?: { core: TauriCore } }).__TAURI__
23
+ if (tauri === undefined) {
24
+ throw new Error('Tauri globals unavailable — run inside the desktop shell (`npm run dev`).')
25
+ }
26
+ const { invoke, Channel } = tauri.core
27
+
28
+ const out = document.getElementById('out') as HTMLDivElement
29
+ const form = document.getElementById('composer') as HTMLFormElement
30
+ const input = document.getElementById('input') as HTMLInputElement
31
+
32
+ /** The Tauri Channel/invoke bridge as an injected ChannelPushSource (M42 ADR-0051 D2). */
33
+ const source: ChannelPushSource = {
34
+ start(turn, { onLine, onClose, onError }) {
35
+ const channel = new Channel()
36
+ channel.onmessage = (line: string) => {
37
+ // A Stdout event may carry multiple newline-delimited JSON lines.
38
+ for (const part of String(line).split('\n')) {
39
+ const trimmed = part.trim()
40
+ if (trimmed) onLine(trimmed)
41
+ }
42
+ }
43
+ void invoke('run_turn', { message: turn.message, onChunk: channel }).then(onClose, (err) => {
44
+ // A Tauri command rejection is a real error — surface it, never swallow it as a clean close.
45
+ onError?.(err instanceof Error ? err : new Error(String(err)))
46
+ })
47
+ // The Rust shell kills the prior sidecar on the next run, so no explicit abort is needed.
48
+ return () => undefined
49
+ },
50
+ settle: async (id: string, decision: ApprovalDecision) => {
51
+ await invoke('approve', { approvalId: id, approved: decision.approved })
52
+ },
53
+ }
54
+
55
+ const client = createAgentClient(new ChannelTransport({ source }))
56
+
57
+ form.addEventListener('submit', (event) => {
58
+ event.preventDefault()
59
+ const message = input.value.trim()
60
+ if (!message) return
61
+ input.value = ''
62
+
63
+ const echo = document.createElement('div')
64
+ echo.textContent = `› ${message}`
65
+ out.appendChild(echo)
66
+ const bubble = document.createElement('div')
67
+ out.appendChild(bubble)
68
+
69
+ void (async () => {
70
+ try {
71
+ for await (const uiMessage of client.stream({ message })) {
72
+ bubble.textContent = uiMessage.parts
73
+ .filter((p): p is { type: 'text'; text: string } => p.type === 'text')
74
+ .map((p) => p.text)
75
+ .join('')
76
+ }
77
+ } catch (err) {
78
+ const errLine = document.createElement('div')
79
+ errLine.className = 'err'
80
+ errLine.textContent = `⚠ ${err instanceof Error ? err.message : String(err)}`
81
+ out.appendChild(errLine)
82
+ }
83
+ })()
84
+ })
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vite'
2
+
3
+ /**
4
+ * M45 — the desktop webview bundle. It imports `theokit/client/core` (bare specifier) so it must be
5
+ * bundled; Vite emits `frontend/dist`, which `src-tauri/tauri.conf.json` serves as `frontendDist`.
6
+ */
7
+ export default defineConfig({
8
+ root: __dirname,
9
+ build: { outDir: 'dist', emptyOutDir: true },
10
+ server: { port: 5173, strictPort: true },
11
+ clearScreen: false,
12
+ })
@@ -0,0 +1,37 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { streamAgentTurnInProcess } from 'theokit/server'
4
+
5
+ type WriteLine = (line: string) => void
6
+ type AwaitApproval = (req: {
7
+ approvalId: string
8
+ toolName: string
9
+ opts: unknown
10
+ }) => Promise<boolean>
11
+
12
+ /**
13
+ * M45 / M36 (ADR-0045 D2) — run ONE agent turn via the in-process seam and emit each `UIMessageChunk`
14
+ * as a single JSONL line to `write`. This is the SERVER side of the desktop app (a Node sidecar); the
15
+ * CLIENT (the webview) consumes these lines via the unified `createAgentClient(ChannelTransport)`.
16
+ * A thrown error is surfaced as a trailing `{type:'error'}` line, never swallowed (Rule 8).
17
+ */
18
+ export async function runTurnToJsonl(
19
+ mod: unknown,
20
+ apiKey: string,
21
+ message: string,
22
+ write: WriteLine,
23
+ awaitApproval?: AwaitApproval,
24
+ ): Promise<void> {
25
+ try {
26
+ for await (const chunk of streamAgentTurnInProcess(mod, apiKey, {
27
+ message,
28
+ sessionId: `desktop-${randomUUID()}`,
29
+ awaitApproval,
30
+ })) {
31
+ write(`${JSON.stringify(chunk)}\n`)
32
+ }
33
+ } catch (err) {
34
+ const errorText = err instanceof Error ? err.message : String(err)
35
+ write(`${JSON.stringify({ type: 'error', errorText })}\n`)
36
+ }
37
+ }
@@ -0,0 +1,51 @@
1
+ import { createInterface } from 'node:readline'
2
+
3
+ import * as chatAgent from '../agents/chat.js'
4
+
5
+ import { runTurnToJsonl } from './sidecar-core.js'
6
+
7
+ /**
8
+ * M45 / M36 (ADR-0045) — the desktop sidecar entry. The Rust shell spawns this with the user message as
9
+ * argv[2] and streams its stdout (JSONL chunks) to the webview over a Tauri `Channel`. HITL: a gated
10
+ * tool pauses the run; the sidecar emits a `tool-approval-request` line and awaits the decision on stdin
11
+ * (`{approvalId, approved}\n`, forwarded by the Rust `approve` command).
12
+ */
13
+ const message = process.argv[2] ?? ''
14
+ const apiKey = process.env.OPENROUTER_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? ''
15
+
16
+ const pending = new Map<string, (approved: boolean) => void>()
17
+ const rl = createInterface({ input: process.stdin })
18
+
19
+ rl.on('line', (line) => {
20
+ const trimmed = line.trim()
21
+ if (!trimmed) return
22
+ try {
23
+ const msg = JSON.parse(trimmed) as { approvalId?: string; approved?: boolean }
24
+ if (typeof msg.approvalId === 'string' && typeof msg.approved === 'boolean') {
25
+ pending.get(msg.approvalId)?.(msg.approved)
26
+ pending.delete(msg.approvalId)
27
+ }
28
+ } catch {
29
+ // ignore malformed stdin lines
30
+ }
31
+ })
32
+
33
+ // Fail-closed: if the shell kills the sidecar, deny every pending approval (Rule 8).
34
+ rl.on('close', () => {
35
+ for (const resolve of pending.values()) resolve(false)
36
+ pending.clear()
37
+ })
38
+
39
+ const write = (line: string): void => {
40
+ process.stdout.write(line)
41
+ }
42
+
43
+ await runTurnToJsonl(chatAgent, apiKey, message, write, ({ approvalId, toolName }) => {
44
+ return new Promise<boolean>((resolve) => {
45
+ pending.set(approvalId, resolve)
46
+ write(`${JSON.stringify({ type: 'tool-approval-request', approvalId, toolName })}\n`)
47
+ })
48
+ })
49
+
50
+ rl.close()
51
+ process.exit(0)
@@ -0,0 +1,16 @@
1
+ [package]
2
+ name = "{{name}}-desktop"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [lib]
7
+ name = "app_lib"
8
+ crate-type = ["staticlib", "cdylib", "rlib"]
9
+
10
+ [build-dependencies]
11
+ tauri-build = { version = "2", features = [] }
12
+
13
+ [dependencies]
14
+ tauri = { version = "2", features = [] }
15
+ tauri-plugin-shell = "2"
16
+ serde_json = "1"
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ tauri_build::build()
3
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "../gen/schemas/desktop-schema.json",
3
+ "identifier": "default",
4
+ "description": "Allow the webview to run the agent turn + settle HITL via the sidecar.",
5
+ "windows": ["main"],
6
+ "permissions": [
7
+ "core:default",
8
+ "shell:allow-spawn",
9
+ {
10
+ "identifier": "shell:allow-execute",
11
+ "allow": [{ "name": "theo-sidecar", "sidecar": true }]
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,82 @@
1
+ use std::sync::Mutex;
2
+
3
+ use tauri::ipc::Channel;
4
+ use tauri::State;
5
+ use tauri_plugin_shell::process::{CommandChild, CommandEvent};
6
+ use tauri_plugin_shell::ShellExt;
7
+
8
+ /// Holds the live sidecar child so `approve` can write the HITL decision to its stdin.
9
+ #[derive(Default)]
10
+ struct SidecarState(Mutex<Option<CommandChild>>);
11
+
12
+ fn decision_line(approval_id: &str, approved: bool) -> String {
13
+ format!(
14
+ "{}\n",
15
+ serde_json::json!({ "approvalId": approval_id, "approved": approved })
16
+ )
17
+ }
18
+
19
+ /// Run one agent turn: spawn the Node sidecar with the message, stream its stdout (JSONL chunks) to
20
+ /// the webview over a `Channel<String>` (the push transport, ADR-0045 D3). Kills any in-flight sidecar.
21
+ #[tauri::command]
22
+ async fn run_turn(
23
+ app: tauri::AppHandle,
24
+ message: String,
25
+ on_chunk: Channel<String>,
26
+ state: State<'_, SidecarState>,
27
+ ) -> Result<(), String> {
28
+ let (mut rx, child) = app
29
+ .shell()
30
+ .sidecar("theo-sidecar")
31
+ .map_err(|e| e.to_string())?
32
+ .args([message])
33
+ .spawn()
34
+ .map_err(|e| e.to_string())?;
35
+
36
+ {
37
+ let mut guard = state.0.lock().map_err(|e| e.to_string())?;
38
+ if let Some(old) = guard.take() {
39
+ let _ = old.kill();
40
+ }
41
+ *guard = Some(child);
42
+ }
43
+
44
+ tauri::async_runtime::spawn(async move {
45
+ while let Some(event) = rx.recv().await {
46
+ match event {
47
+ CommandEvent::Stdout(bytes) => {
48
+ let line = String::from_utf8_lossy(&bytes).to_string();
49
+ let _ = on_chunk.send(line);
50
+ }
51
+ CommandEvent::Terminated(_) => break,
52
+ _ => {}
53
+ }
54
+ }
55
+ });
56
+ Ok(())
57
+ }
58
+
59
+ /// Forward a human approve/deny decision to the sidecar's stdin (the HITL round-trip).
60
+ #[tauri::command]
61
+ fn approve(
62
+ approval_id: String,
63
+ approved: bool,
64
+ state: State<'_, SidecarState>,
65
+ ) -> Result<(), String> {
66
+ let line = decision_line(&approval_id, approved);
67
+ let mut guard = state.0.lock().map_err(|e| e.to_string())?;
68
+ if let Some(child) = guard.as_mut() {
69
+ child.write(line.as_bytes()).map_err(|e| e.to_string())?;
70
+ }
71
+ Ok(())
72
+ }
73
+
74
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
75
+ pub fn run() {
76
+ tauri::Builder::default()
77
+ .plugin(tauri_plugin_shell::init())
78
+ .manage(SidecarState::default())
79
+ .invoke_handler(tauri::generate_handler![run_turn, approve])
80
+ .run(tauri::generate_context!())
81
+ .expect("error while running tauri application");
82
+ }
@@ -0,0 +1,6 @@
1
+ // Prevents an extra console window on Windows in release; no-op elsewhere.
2
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3
+
4
+ fn main() {
5
+ app_lib::run();
6
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://schema.tauri.app/config/2",
3
+ "productName": "{{name}}",
4
+ "version": "0.1.0",
5
+ "identifier": "dev.theokit.{{name}}",
6
+ "build": {
7
+ "frontendDist": "../frontend/dist",
8
+ "devUrl": "http://localhost:5173",
9
+ "beforeDevCommand": "vite frontend",
10
+ "beforeBuildCommand": "vite build frontend"
11
+ },
12
+ "app": {
13
+ "windows": [
14
+ {
15
+ "label": "main",
16
+ "title": "{{name}} — TheoKit desktop",
17
+ "width": 900,
18
+ "height": 680
19
+ }
20
+ ],
21
+ "security": {
22
+ "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
23
+ }
24
+ },
25
+ "bundle": {
26
+ "active": true,
27
+ "targets": "all",
28
+ "externalBin": ["binaries/theo-sidecar"]
29
+ }
30
+ }
@@ -0,0 +1,26 @@
1
+ # {{name}} — TheoKit agent (terminal / TUI surface)
2
+
3
+ A terminal agent app scaffolded by `create-theokit --surface tui`. It runs the agent **in-process**
4
+ (no HTTP server, no port) and renders the streamed response in the terminal with [Ink](https://github.com/vadimdemedes/ink).
5
+
6
+ ## How it consumes the agent (the unified client)
7
+
8
+ `tui/App.tsx` uses the SAME `useAgent` hook as the web surface — over an `InProcessTransport`:
9
+
10
+ ```tsx
11
+ const transport = new InProcessTransport({
12
+ run: (input) => streamAgentTurnInProcess(chatAgent, apiKey(), input),
13
+ })
14
+ const agent = useAgent({ message: string }>(transport) // messages, status, send, …
15
+ ```
16
+
17
+ No bespoke terminal reader — the terminal, web, and desktop surfaces share one client (M41-M44).
18
+
19
+ ## Run
20
+
21
+ ```bash
22
+ export OPENROUTER_API_KEY=... # or ANTHROPIC_API_KEY
23
+ npm run dev # tsx tui/main.tsx
24
+ ```
25
+
26
+ Type a message + Enter; Esc to quit. The agent lives in `agents/chat.ts` (edit its model / system prompt).
@@ -0,0 +1,64 @@
1
+ import { Box, Text, useApp, useInput } from 'ink'
2
+ import { useMemo, useState } from 'react'
3
+ import { InProcessTransport, useAgent } from 'theokit/client'
4
+ import { streamAgentTurnInProcess } from 'theokit/server'
5
+
6
+ import * as chatAgent from '../agents/chat.js'
7
+
8
+ /** Resolve the provider key from the environment (OpenRouter / Anthropic). */
9
+ const apiKey = (): string => process.env.OPENROUTER_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? ''
10
+
11
+ /**
12
+ * M45 — the terminal surface on the UNIFIED client. `useAgent` (M41) drives an `InProcessTransport`
13
+ * whose runner binds the framework's in-process seam (`streamAgentTurnInProcess`) — the SAME hook the
14
+ * web surface uses, no bespoke terminal reader. Ink is React, so the hook works unchanged.
15
+ */
16
+ const transport = new InProcessTransport({
17
+ run: (input) => streamAgentTurnInProcess(chatAgent, apiKey(), input),
18
+ })
19
+
20
+ export function App(): JSX.Element {
21
+ const agent = useAgent<{ message: string }>(transport)
22
+ const [input, setInput] = useState('')
23
+ const { exit } = useApp()
24
+
25
+ useInput((char, key) => {
26
+ if (key.escape) {
27
+ exit()
28
+ } else if (key.return) {
29
+ if (input.trim().length > 0) agent.send({ message: input })
30
+ setInput('')
31
+ } else if (key.backspace || key.delete) {
32
+ setInput((s) => s.slice(0, -1))
33
+ } else if (char && !key.ctrl && !key.meta) {
34
+ setInput((s) => s + char)
35
+ }
36
+ })
37
+
38
+ const assistant = useMemo(
39
+ () =>
40
+ agent.messages
41
+ .flatMap((m) => m.parts)
42
+ .filter((p): p is { type: 'text'; text: string } => p.type === 'text')
43
+ .map((p) => p.text)
44
+ .join(''),
45
+ [agent.messages],
46
+ )
47
+
48
+ return (
49
+ <Box flexDirection="column" padding={1}>
50
+ <Text color="cyan">◆ {{name}} — TheoKit agent (terminal). Type a message + Enter · Esc to quit.</Text>
51
+ {assistant.length > 0 ? (
52
+ <Box marginTop={1}>
53
+ <Text>{assistant}</Text>
54
+ </Box>
55
+ ) : null}
56
+ {agent.status === 'streaming' ? <Text color="yellow">…thinking</Text> : null}
57
+ {agent.error ? <Text color="red">⚠ {agent.error.message}</Text> : null}
58
+ <Box marginTop={1}>
59
+ <Text color="green">› </Text>
60
+ <Text>{input}</Text>
61
+ </Box>
62
+ </Box>
63
+ )
64
+ }
@@ -0,0 +1,14 @@
1
+ import { render } from 'ink'
2
+
3
+ import { App } from './App.js'
4
+
5
+ // Load .env if present (Node native — no dependency). Provider key: OPENROUTER_API_KEY or ANTHROPIC_API_KEY.
6
+ if (typeof process.loadEnvFile === 'function') {
7
+ try {
8
+ process.loadEnvFile()
9
+ } catch {
10
+ // no .env on disk — rely on the ambient environment
11
+ }
12
+ }
13
+
14
+ render(<App />)