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.
- package/dist/cli.js +247 -94
- package/dist/cli.js.map +1 -1
- package/package.json +8 -7
- package/templates/default/agents/chat.ts +7 -7
- package/templates/default/app/page.test.tsx +36 -0
- package/templates/default/app/page.tsx +36 -116
- package/templates/default/dot-claude/skills/theokit-agents/SKILL.md +3 -3
- package/templates/default/package.json.tmpl +5 -2
- package/templates/default/server/routes/health.ts +5 -5
- package/templates/default/theo.config.ts +2 -2
- package/templates/default/tsconfig.json +3 -1
- package/templates/surfaces/desktop/README-surface.md.tmpl +31 -0
- package/templates/surfaces/desktop/frontend/index.html.tmpl +39 -0
- package/templates/surfaces/desktop/frontend/src/main.ts +84 -0
- package/templates/surfaces/desktop/frontend/vite.config.ts +12 -0
- package/templates/surfaces/desktop/sidecar/sidecar-core.ts +37 -0
- package/templates/surfaces/desktop/sidecar/sidecar.ts +51 -0
- package/templates/surfaces/desktop/src-tauri/Cargo.toml.tmpl +16 -0
- package/templates/surfaces/desktop/src-tauri/build.rs +3 -0
- package/templates/surfaces/desktop/src-tauri/capabilities/default.json +14 -0
- package/templates/surfaces/desktop/src-tauri/src/lib.rs +82 -0
- package/templates/surfaces/desktop/src-tauri/src/main.rs +6 -0
- package/templates/surfaces/desktop/src-tauri/tauri.conf.json.tmpl +30 -0
- package/templates/surfaces/tui/README-surface.md.tmpl +26 -0
- package/templates/surfaces/tui/tui/App.tsx.tmpl +64 -0
- package/templates/surfaces/tui/tui/main.tsx.tmpl +14 -0
- package/LICENSE +0 -201
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"typecheck": "tsc --noEmit"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"theokit": "^0.
|
|
19
|
-
"@theokit/agents": "^0.
|
|
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 {
|
|
1
|
+
import { route } from 'theokit/server/define'
|
|
2
2
|
|
|
3
|
-
export const GET =
|
|
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 {
|
|
1
|
+
import { config } from 'theokit'
|
|
2
2
|
|
|
3
|
-
export default
|
|
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,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,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 />)
|