@soederpop/luca 0.2.2 → 0.2.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.
@@ -2,208 +2,437 @@ import { z } from 'zod'
2
2
  import { commands, CommandOptionsSchema } from '@soederpop/luca'
3
3
  import type { ContainerContext } from '@soederpop/luca'
4
4
  import { AGIContainer } from '../src/agi/container.server.js'
5
- import { tmpdir } from 'os'
6
5
 
7
6
  export const argsSchema = CommandOptionsSchema.extend({
8
7
  model: z.string().optional().describe('OpenAI model to use'),
9
8
  })
10
9
 
10
+ // ─── Scene Types ──────────────────────────────────────────────────────
11
+
12
+ interface SceneState {
13
+ code: string
14
+ component: ((...args: any[]) => any) | null
15
+ error: string
16
+ status: 'idle' | 'rendered' | 'interactive' | 'complete' | 'failed'
17
+ interactive: boolean
18
+ }
19
+
20
+ type Layout = 'split' | 'canvas' | 'chat'
21
+ const LAYOUT_CYCLE: Layout[] = ['split', 'canvas', 'chat']
22
+
23
+ function seedMentalState(ms: any) {
24
+ ms.set('activeSceneId', null)
25
+ ms.set('scenes', {} as Record<string, SceneState>)
26
+ ms.set('focus', 'chat' as 'chat' | 'canvas')
27
+ ms.set('layout', 'split' as Layout)
28
+ ms.set('thoughts', [] as Array<{ at: string; text: string }>)
29
+ ms.set('observations', {} as Record<string, string>)
30
+ ms.set('plan', '')
31
+ ms.set('mood', 'ready')
32
+ }
33
+
34
+ // ─── Safe Eval ────────────────────────────────────────────────────────
35
+
36
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
37
+
38
+ const EVAL_TIMEOUT = 15_000
39
+
40
+ async function evalWithTimeout<T>(fn: () => Promise<T>, ms: number): Promise<T> {
41
+ let timer: ReturnType<typeof setTimeout>
42
+ const timeout = new Promise<never>((_, reject) => {
43
+ timer = setTimeout(() => reject(new Error(`Scene evaluation timed out after ${ms}ms`)), ms)
44
+ })
45
+ try {
46
+ return await Promise.race([fn(), timeout])
47
+ } finally {
48
+ clearTimeout(timer!)
49
+ }
50
+ }
51
+
52
+ // ─── Main Handler ─────────────────────────────────────────────────────
53
+
11
54
  export async function inkbot(options: z.infer<typeof argsSchema>, context: ContainerContext) {
12
55
  const container = new AGIContainer()
13
56
 
14
- // ─── Load Ink ────────────────────────────────────────────────────────
57
+ // ─── Load Ink ──────────────────────────────────────────────────────
15
58
  const ink = container.feature('ink', { enable: true, patchConsole: true })
16
59
  await ink.loadModules()
17
60
  const React = ink.React
18
61
  const h = React.createElement
19
- const { Box, Text } = ink.components
62
+ const { Box, Text, Spacer, Newline } = ink.components
20
63
  const { useInput, useApp, useStdout } = ink.hooks
21
- const { useState, useEffect } = React
22
-
23
- // ─── Scene Runner ────────────────────────────────────────────────────
24
- // Scenes are code strings run as bun subprocesses.
25
- // The stage entity tracks which scenes exist and which is active.
26
- // Scene entities track individual code, output, error, status.
64
+ const { useState, useEffect, useRef, useCallback, useMemo } = React
27
65
 
28
- const stage = container.entity('inkbot:stage')
29
- stage.setState({ activeSceneId: null, sceneIds: [] as string[], tick: 0 })
66
+ // ─── Assistant ─────────────────────────────────────────────────────
67
+ const mgr = container.feature('assistantsManager')
68
+ await mgr.discover()
69
+ const assistant = mgr.create('inkbot', { model: options.model })
30
70
 
31
- const sceneMap: Record<string, ReturnType<typeof container.entity>> = {}
71
+ seedMentalState(assistant.mentalState)
32
72
 
33
- function bumpStage() {
34
- stage.setState({ tick: ((stage.state.get('tick') as number) || 0) + 1 })
73
+ // ─── Container features for scene scope ────────────────────────────
74
+ const fs = container.feature('fs')
75
+ const proc = container.feature('proc')
76
+ const ui = container.feature('ui')
77
+ const yaml = container.feature('yaml')
78
+ const grep = container.feature('grep')
79
+ const git = container.feature('git')
80
+
81
+ // ─── Scene Focus Context ───────────────────────────────────────────
82
+ // Scenes use useSceneInput() which is only active when canvas is focused.
83
+ const SceneFocusContext = React.createContext(false)
84
+
85
+ function makeUseSceneInput(onError: (err: Error) => void) {
86
+ return function useSceneInput(handler: (ch: string, key: any) => void) {
87
+ const focused = React.useContext(SceneFocusContext)
88
+ useInput(
89
+ (ch: string, key: any) => {
90
+ // Reserve Tab, Escape, and Ctrl+L (layout toggle) for the host app
91
+ if (key.tab || key.escape) return
92
+ if (key.ctrl && ch === 'l') return
93
+ try {
94
+ handler(ch, key)
95
+ } catch (err: any) {
96
+ onError(err)
97
+ }
98
+ },
99
+ { isActive: focused },
100
+ )
101
+ }
35
102
  }
36
103
 
37
- function getOrCreateScene(id: string, code: string) {
38
- if (sceneMap[id]) {
39
- sceneMap[id].setState({ code })
40
- return sceneMap[id]
104
+ // ─── Error Boundary ────────────────────────────────────────────────
105
+
106
+ class SceneErrorBoundary extends React.Component<
107
+ { children: any; onError?: (err: Error) => void; fallback?: any },
108
+ { error: Error | null }
109
+ > {
110
+ constructor(props: any) {
111
+ super(props)
112
+ this.state = { error: null }
113
+ }
114
+ static getDerivedStateFromError(error: Error) {
115
+ return { error }
116
+ }
117
+ componentDidCatch(error: Error) {
118
+ this.props.onError?.(error)
119
+ }
120
+ render() {
121
+ if (this.state.error) {
122
+ return h(
123
+ Box,
124
+ { flexDirection: 'column', paddingX: 1 },
125
+ h(Text, { color: 'red', bold: true }, 'Render Error'),
126
+ h(Text, { color: 'red', wrap: 'wrap' }, this.state.error.message),
127
+ h(
128
+ Text,
129
+ { dimColor: true, wrap: 'wrap' },
130
+ this.state.error.stack?.split('\n').slice(1, 4).join('\n') || '',
131
+ ),
132
+ )
133
+ }
134
+ return this.props.children
41
135
  }
42
- const scene = container.entity(`inkbot:scene:${id}`)
43
- scene.setState({ code, output: '', error: '', status: 'idle', exitCode: null })
44
- sceneMap[id] = scene
45
- const ids = [...((stage.state.get('sceneIds') || []) as string[])]
46
- if (!ids.includes(id)) ids.push(id)
47
- stage.setState({ sceneIds: ids })
48
- if (!stage.state.get('activeSceneId')) stage.setState({ activeSceneId: id })
49
- return scene
50
136
  }
51
137
 
52
- const proc = container.feature('proc')
53
- const fs = container.feature('fs')
54
- const sceneTmpDir = `${tmpdir()}/inkbot-scenes`
55
- fs.ensureFolder(sceneTmpDir)
138
+ // ─── Scene State Management ────────────────────────────────────────
56
139
 
57
- async function runScene(id: string): Promise<{ output: string; error: string; exitCode: number }> {
58
- const scene = sceneMap[id]
59
- if (!scene) throw new Error(`Scene "${id}" not found`)
140
+ let busy = false
141
+ assistant.on('turnStart', () => { busy = true })
142
+ assistant.on('response', () => { busy = false })
60
143
 
61
- const code = scene.state.get('code') as string
62
- scene.setState({ status: 'running', output: '', error: '' })
63
- bumpStage()
144
+ function getScenes(): Record<string, SceneState> {
145
+ return { ...(assistant.mentalState.get('scenes') || {}) }
146
+ }
64
147
 
65
- // Write to tmpdir, shell out to `luca run` — gets full container context + process isolation
66
- const file = `${sceneTmpDir}/${id.replace(/[^a-zA-Z0-9-]/g, '_')}_${Date.now()}.ts`
67
- fs.writeFile(file, code)
148
+ function setScene(id: string, patch: Partial<SceneState>) {
149
+ const scenes = getScenes()
150
+ scenes[id] = {
151
+ ...(scenes[id] || { code: '', component: null, error: '', status: 'idle', interactive: false }),
152
+ ...patch,
153
+ }
154
+ assistant.mentalState.set('scenes', scenes)
155
+ }
68
156
 
69
- try {
70
- const result = await proc.spawnAndCapture('luca', ['run', file], {
71
- cwd: container.cwd,
72
- onOutput(data: string) {
73
- scene.setState({ output: ((scene.state.get('output') || '') as string) + data })
74
- bumpStage()
75
- },
76
- onError(data: string) {
77
- scene.setState({ error: ((scene.state.get('error') || '') as string) + data })
78
- bumpStage()
79
- },
80
- })
157
+ // ─── Pending Responders ────────────────────────────────────────────
158
+ // For interactive scenes: draw() blocks until the component calls respond().
159
+ const pendingResponders = new Map<string, (result: any) => void>()
160
+
161
+ function createRespondForScene(sceneId: string) {
162
+ return (data: any) => {
163
+ const resolver = pendingResponders.get(sceneId)
164
+ if (resolver) {
165
+ pendingResponders.delete(sceneId)
166
+ setScene(sceneId, { status: 'complete' })
167
+ assistant.mentalState.set('focus', 'chat')
168
+ resolver({ status: 'completed', sceneId, response: data })
169
+ }
170
+ }
171
+ }
172
+
173
+ // ─── Scene Error Reporter ──────────────────────────────────────────
174
+ // When a scene errors and the assistant is idle, feed the error back
175
+ // so it can self-correct.
176
+ function reportSceneError(sceneId: string, error: string) {
177
+ setScene(sceneId, { error, status: 'failed' })
178
+ if (!busy) {
179
+ const errMsg = `[Scene "${sceneId}" render error]\n${error}\n\nFix the component code and redraw.`
180
+ assistant.conversation?.pushMessage({ role: 'developer', content: errMsg })
181
+ assistant.ask(errMsg).catch(() => {})
182
+ }
183
+ }
81
184
 
82
- const status = result.exitCode === 0 ? 'complete' : 'failed'
83
- scene.setState({ status, exitCode: result.exitCode })
84
- bumpStage()
85
- try { fs.unlink(file) } catch {}
185
+ // ─── Scene Evaluator ──────────────────────────────────────────────
186
+ // Evaluates scene code in an async function scope with all APIs injected.
187
+ // The code must `return` a React component function.
188
+
189
+ async function evaluateScene(
190
+ code: string,
191
+ sceneId: string,
192
+ interactive: boolean,
193
+ ): Promise<{ component: ((...args: any[]) => any) | null; error: string | null }> {
194
+ const respondFn = createRespondForScene(sceneId)
195
+ const sceneErrorHandler = (err: Error) => reportSceneError(sceneId, err.message)
196
+ const useSceneInput = makeUseSceneInput(sceneErrorHandler)
197
+
198
+ const paramNames = [
199
+ // React core
200
+ 'h', 'React', 'Box', 'Text', 'Spacer', 'Newline',
201
+ // React hooks
202
+ 'useState', 'useEffect', 'useRef', 'useCallback', 'useMemo',
203
+ // Scene input (focus-aware, error-safe)
204
+ 'useSceneInput',
205
+ // Canvas API
206
+ 'setMental', 'getMental', 'respond',
207
+ // Container
208
+ 'container', 'fs', 'proc', 'ui', 'yaml', 'grep', 'git',
209
+ // Utilities
210
+ 'fetch', 'URL', 'Buffer', 'JSON', 'Date', 'Math', 'console',
211
+ ]
212
+
213
+ const paramValues = [
214
+ h, React, Box, Text, Spacer, Newline,
215
+ useState, useEffect, useRef, useCallback, useMemo,
216
+ useSceneInput,
217
+ (k: string, v: any) => assistant.mentalState.set(k, v),
218
+ (k: string) => assistant.mentalState.get(k),
219
+ respondFn,
220
+ container, fs, proc, ui, yaml, grep, git,
221
+ fetch, URL, Buffer, JSON, Date, Math, console,
222
+ ]
86
223
 
87
- return {
88
- output: (scene.state.get('output') || '') as string,
89
- error: (scene.state.get('error') || '') as string,
90
- exitCode: result.exitCode ?? 1,
224
+ try {
225
+ const factory = new AsyncFunction(...paramNames, code)
226
+ const result = await evalWithTimeout(() => factory(...paramValues), EVAL_TIMEOUT)
227
+
228
+ if (typeof result !== 'function') {
229
+ return {
230
+ component: null,
231
+ error: `Scene code must return a React component function, got ${typeof result}. End your code with: return function Scene() { return h(Box, {}, h(Text, {}, "hello")) }`,
232
+ }
91
233
  }
234
+
235
+ return { component: result, error: null }
92
236
  } catch (err: any) {
93
- scene.setState({ status: 'failed', error: err.message, exitCode: 1 })
94
- bumpStage()
95
- try { fs.unlink(file) } catch {}
96
- return { output: '', error: err.message, exitCode: 1 }
237
+ return { component: null, error: err.message }
97
238
  }
98
239
  }
99
240
 
100
- // ─── Assistant ───────────────────────────────────────────────────────
101
- const mgr = container.feature('assistantsManager')
102
- await mgr.discover()
103
- const assistant = mgr.create('inkbot', { model: options.model })
104
-
105
- // Scene completion → inject result into conversation so the assistant sees it
106
- stage.on('sceneComplete' as any, (id: string, output: string) => {
107
- const msg = output.trim()
108
- ? `[Scene "${id}" completed]\n${output.trim()}`
109
- : `[Scene "${id}" completed with no output]`
110
- assistant.conversation?.pushMessage({ role: 'developer', content: msg })
111
- })
112
-
113
- stage.on('sceneFailed' as any, (id: string, error: string) => {
114
- const msg = `[Scene "${id}" failed]\n${error.trim()}`
115
- assistant.conversation?.pushMessage({ role: 'developer', content: msg })
116
- // Auto-ask the assistant to fix it
117
- assistant.ask(msg).catch(() => {})
118
- })
241
+ // ─── Canvas Tools ─────────────────────────────────────────────────
119
242
 
120
- // Canvas tools — registered directly so they close over stage/sceneMap
121
243
  assistant.addTool(
122
244
  'draw',
123
- async (args: { code: string; sceneId?: string }) => {
245
+ async (args: { code: string; sceneId?: string; interactive?: boolean }) => {
124
246
  const id = args.sceneId || 'default'
125
- getOrCreateScene(id, args.code)
126
- stage.setState({ activeSceneId: id })
127
- bumpStage()
128
- // Fire and forget — result feeds back via stage events
129
- runScene(id).then(result => {
130
- if (result.exitCode === 0) {
131
- stage.emit('sceneComplete' as any, id, result.output)
132
- } else {
133
- stage.emit('sceneFailed' as any, id, result.error)
134
- }
135
- })
136
- return { status: 'running', sceneId: id }
247
+ const interactive = !!args.interactive
248
+
249
+ const { component, error } = await evaluateScene(args.code, id, interactive)
250
+
251
+ if (error) {
252
+ setScene(id, { code: args.code, component: null, error, status: 'failed', interactive })
253
+ assistant.mentalState.set('activeSceneId', id)
254
+ return { status: 'failed', sceneId: id, error }
255
+ }
256
+
257
+ setScene(id, { code: args.code, component, error: '', status: interactive ? 'interactive' : 'rendered', interactive })
258
+ assistant.mentalState.set('activeSceneId', id)
259
+
260
+ if (interactive) {
261
+ // Block until the component calls respond()
262
+ return new Promise<any>((resolve) => {
263
+ pendingResponders.set(id, resolve)
264
+ })
265
+ }
266
+
267
+ return { status: 'rendered', sceneId: id }
137
268
  },
138
269
  z.object({
139
- code: z.string().describe('TypeScript code to execute. Use console.log() for visible output.'),
270
+ code: z
271
+ .string()
272
+ .describe(
273
+ 'Async function body that returns a React component function. Use h() for elements. Has access to: h, React, Box, Text, Spacer, Newline, useState, useEffect, useRef, useCallback, useMemo, useSceneInput, setMental, getMental, respond, container, fs, proc, ui, yaml, grep, git, fetch.',
274
+ ),
140
275
  sceneId: z.string().optional().describe('Scene id (defaults to "default").'),
141
- }).describe('Draw or redraw the canvas. Returns immediately — output streams to the canvas. If the scene fails, you will be notified automatically.'),
276
+ interactive: z
277
+ .boolean()
278
+ .optional()
279
+ .describe('When true, the tool call blocks until the component calls respond(data). The component should use useSceneInput() for keyboard input.'),
280
+ }).describe(
281
+ 'Render a React Ink component in the canvas pane. The code runs as an async function body and must return a React component function. Interactive scenes block until respond(data) is called.',
282
+ ),
142
283
  )
143
284
 
144
285
  assistant.addTool(
145
286
  'create_scene',
146
- async (args: { id: string; code: string }) => {
147
- getOrCreateScene(args.id, args.code)
148
- return { created: args.id, allScenes: stage.state.get('sceneIds') }
287
+ async (args: { id: string; code: string; interactive?: boolean }) => {
288
+ const { component, error } = await evaluateScene(args.code, args.id, !!args.interactive)
289
+ if (error) {
290
+ setScene(args.id, { code: args.code, component: null, error, status: 'failed', interactive: !!args.interactive })
291
+ return { created: args.id, error }
292
+ }
293
+ setScene(args.id, { code: args.code, component, error: '', status: 'idle', interactive: !!args.interactive })
294
+ if (!assistant.mentalState.get('activeSceneId')) {
295
+ assistant.mentalState.set('activeSceneId', args.id)
296
+ }
297
+ return { created: args.id, allScenes: Object.keys(getScenes()) }
149
298
  },
150
299
  z.object({
151
300
  id: z.string().describe('Unique scene identifier'),
152
- code: z.string().describe('TypeScript code for this scene'),
153
- }).describe('Create a named scene without running it yet.'),
301
+ code: z.string().describe('Async function body returning a React component function'),
302
+ interactive: z.boolean().optional().describe('Whether this scene uses interactive APIs'),
303
+ }).describe('Create a named scene without activating it. Validates the code immediately.'),
154
304
  )
155
305
 
156
306
  assistant.addTool(
157
- 'run_scene',
158
- async (args: { id: string }) => runScene(args.id),
307
+ 'activate_scene',
308
+ async (args: { id: string }) => {
309
+ const scenes = getScenes()
310
+ if (!scenes[args.id]) return { error: `Scene "${args.id}" not found` }
311
+ assistant.mentalState.set('activeSceneId', args.id)
312
+ return { activeSceneId: args.id }
313
+ },
159
314
  z.object({
160
- id: z.string().describe('Scene id to run'),
161
- }).describe('Run a specific scene by its id.'),
315
+ id: z.string().describe('Scene id to display in the canvas'),
316
+ }).describe('Switch the canvas to display a different scene.'),
162
317
  )
163
318
 
164
319
  assistant.addTool(
165
- 'run_all',
320
+ 'get_canvas',
166
321
  async () => {
167
- const ids = (stage.state.get('sceneIds') || []) as string[]
168
- const results: any[] = []
169
- for (const id of ids) results.push({ id, ...(await runScene(id)) })
170
- return results
322
+ const activeId = assistant.mentalState.get('activeSceneId') as string | null
323
+ const scenes = getScenes()
324
+ if (!activeId || !scenes[activeId]) return { status: 'empty', allScenes: [] }
325
+ const s = scenes[activeId]
326
+ return { sceneId: activeId, status: s.status, error: s.error, interactive: s.interactive, allScenes: Object.keys(scenes) }
171
327
  },
172
- z.object({}).describe('Run every scene in order and return all results.'),
328
+ z.object({}).describe('Inspect the current canvas state: active scene, status, errors, scene list.'),
173
329
  )
174
330
 
331
+ // ─── Mental State Tools ───────────────────────────────────────────
332
+
175
333
  assistant.addTool(
176
- 'get_canvas',
334
+ 'think',
335
+ async (args: { text: string }) => {
336
+ const thoughts = [...(assistant.mentalState.get('thoughts') || [])]
337
+ thoughts.push({ at: new Date().toISOString(), text: args.text })
338
+ if (thoughts.length > 50) thoughts.splice(0, thoughts.length - 50)
339
+ assistant.mentalState.set('thoughts', thoughts)
340
+ return { recorded: true, totalThoughts: thoughts.length }
341
+ },
342
+ z.object({
343
+ text: z.string().describe('Your thought, observation, or internal note.'),
344
+ }).describe('Record a thought in your mental state.'),
345
+ )
346
+
347
+ assistant.addTool(
348
+ 'observe',
349
+ async (args: { key: string; value: string }) => {
350
+ const observations = { ...(assistant.mentalState.get('observations') || {}) }
351
+ observations[args.key] = args.value
352
+ assistant.mentalState.set('observations', observations)
353
+ return { recorded: true, key: args.key }
354
+ },
355
+ z.object({
356
+ key: z.string().describe('A short label for what you observed.'),
357
+ value: z.string().describe('Your observation.'),
358
+ }).describe('Record a named observation.'),
359
+ )
360
+
361
+ assistant.addTool(
362
+ 'set_plan',
363
+ async (args: { plan: string }) => {
364
+ assistant.mentalState.set('plan', args.plan)
365
+ return { updated: true }
366
+ },
367
+ z.object({
368
+ plan: z.string().describe('Your current plan of action.'),
369
+ }).describe('Set or update your current plan.'),
370
+ )
371
+
372
+ assistant.addTool(
373
+ 'set_mood',
374
+ async (args: { mood: string }) => {
375
+ assistant.mentalState.set('mood', args.mood)
376
+ return { updated: true }
377
+ },
378
+ z.object({
379
+ mood: z.string().describe('A word or short phrase describing your current state.'),
380
+ }).describe('Update your mood/status displayed in the UI header.'),
381
+ )
382
+
383
+ assistant.addTool(
384
+ 'reflect',
177
385
  async () => {
178
- const activeId = stage.state.get('activeSceneId') as string | null
179
- if (!activeId || !sceneMap[activeId]) return { status: 'empty', allScenes: [] }
180
- const s = sceneMap[activeId]
181
386
  return {
182
- sceneId: activeId,
183
- status: s.state.get('status'),
184
- output: s.state.get('output'),
185
- error: s.state.get('error'),
186
- code: s.state.get('code'),
187
- allScenes: stage.state.get('sceneIds'),
387
+ mood: assistant.mentalState.get('mood'),
388
+ plan: assistant.mentalState.get('plan'),
389
+ thoughts: assistant.mentalState.get('thoughts'),
390
+ observations: assistant.mentalState.get('observations'),
391
+ scenes: Object.keys(getScenes()),
392
+ activeScene: assistant.mentalState.get('activeSceneId'),
188
393
  }
189
394
  },
190
- z.object({}).describe('Inspect the current canvas: active scene output, error, code, status.'),
395
+ z.object({}).describe('Review your full mental state.'),
191
396
  )
192
397
 
398
+ // ─── Coder Subagent ───────────────────────────────────────────────
399
+
400
+ let coderAssistant: any = null
401
+
402
+ const CODER_PREFIX = `You are answering a question from Inkbot, a canvas-rendering assistant that renders React Ink components directly in a split-pane terminal UI.
403
+
404
+ IMPORTANT CONTEXT about Inkbot's execution environment:
405
+ - Scene code is an async function body that must RETURN a React component function
406
+ - Uses h() (React.createElement) instead of JSX — no JSX compilation available
407
+ - Available in scope: h, React, Box, Text, Spacer, Newline, useState, useEffect, useRef, useCallback, useMemo
408
+ - useSceneInput(handler) for keyboard input (focus-aware, error-safe — NOT raw useInput)
409
+ - setMental(key, value) and getMental(key) for assistant mental state
410
+ - respond(data) to complete interactive scenes
411
+ - container, fs, proc, ui, yaml, grep, git — all Luca container features
412
+ - fetch, URL, Buffer, JSON, Date, Math, console — standard globals
413
+ - Top-level await works (the wrapper is async)
414
+ - No imports possible — everything via scope injection
415
+
416
+ When answering, return a SINGLE code snippet that works as a scene code body. Must end with \`return function SceneName() { ... }\`.
417
+
418
+ Inkbot's question: `
419
+
193
420
  assistant.addTool(
194
- 'activate_scene',
195
- async (args: { id: string }) => {
196
- if (!sceneMap[args.id]) return { error: `Scene "${args.id}" not found` }
197
- stage.setState({ activeSceneId: args.id })
198
- bumpStage()
199
- return { activeSceneId: args.id }
421
+ 'ask_coder',
422
+ async (args: { question: string }) => {
423
+ if (!coderAssistant) {
424
+ coderAssistant = mgr.create('codingAssistant', { model: options.model || 'gpt-5.4' })
425
+ await coderAssistant.start()
426
+ }
427
+ const answer = await coderAssistant.ask(CODER_PREFIX + args.question)
428
+ return { answer }
200
429
  },
201
430
  z.object({
202
- id: z.string().describe('Scene id to make active in the canvas'),
203
- }).describe('Switch the canvas to display a different scene.'),
431
+ question: z.string().describe('Your question about the Luca framework, container APIs, or how to write scene components.'),
432
+ }).describe('Ask the coding assistant a question about the Luca framework or how to build scene components.'),
204
433
  )
205
434
 
206
- // ─── Ink App ─────────────────────────────────────────────────────────
435
+ // ─── Ink App ──────────────────────────────────────────────────────
207
436
 
208
437
  type Msg = { role: 'user' | 'assistant' | 'system'; content: string }
209
438
 
@@ -213,7 +442,13 @@ export async function inkbot(options: z.infer<typeof argsSchema>, context: Conta
213
442
  const [streaming, setStreaming] = useState('')
214
443
  const [thinking, setThinking] = useState(false)
215
444
  const [activity, setActivity] = useState('')
216
- const [canvas, setCanvas] = useState({ output: '', error: '', status: 'empty' })
445
+ const [canvasError, setCanvasError] = useState('')
446
+ const [canvasStatus, setCanvasStatus] = useState('empty')
447
+ const [mood, setMood] = useState('ready')
448
+ const [focus, setFocus] = useState<'chat' | 'canvas'>('chat')
449
+ const [layout, setLayout] = useState<Layout>('split')
450
+ const [activeComponent, setActiveComponent] = useState<((...args: any[]) => any) | null>(null)
451
+ const [errorBoundaryKey, setErrorBoundaryKey] = useState(0)
217
452
  const { exit } = useApp()
218
453
  const { stdout } = useStdout()
219
454
  const rows = stdout?.rows ?? 24
@@ -221,127 +456,224 @@ export async function inkbot(options: z.infer<typeof argsSchema>, context: Conta
221
456
  // --- assistant events ---
222
457
  useEffect(() => {
223
458
  const onPreview = (text: string) => setStreaming(text)
224
- const onResponse = (text: string) => {
225
- setStreaming('')
226
- setThinking(false)
227
- setActivity('')
228
- setMessages(prev => [...prev, { role: 'assistant', content: text }])
229
- }
230
459
  const onToolCall = (name: string) => setActivity(`${name}`)
231
460
  const onToolResult = () => setActivity('')
232
461
 
233
462
  assistant.on('preview', onPreview)
234
- assistant.on('response', onResponse)
235
463
  assistant.on('toolCall', onToolCall)
236
464
  assistant.on('toolResult', onToolResult)
237
465
  return () => {
238
466
  assistant.off('preview', onPreview)
239
- assistant.off('response', onResponse)
240
467
  assistant.off('toolCall', onToolCall)
241
468
  assistant.off('toolResult', onToolResult)
242
469
  }
243
470
  }, [])
244
471
 
245
- // --- stage entity ticks canvas state ---
472
+ // --- mentalStateUI state ---
246
473
  useEffect(() => {
247
- const unsub = stage.state.observe((_changeType: any, key: any) => {
248
- if (key !== 'tick') return
249
- const activeId = stage.state.get('activeSceneId') as string | null
250
- if (!activeId || !sceneMap[activeId]) {
251
- setCanvas({ output: '', error: '', status: 'empty' })
252
- return
474
+ const unsub = assistant.mentalState.observe((_changeType: any, key: any) => {
475
+ if (key === 'scenes' || key === 'activeSceneId') {
476
+ const activeId = assistant.mentalState.get('activeSceneId') as string | null
477
+ const scenes = (assistant.mentalState.get('scenes') as Record<string, SceneState>) || {}
478
+ if (!activeId || !scenes[activeId]) {
479
+ setActiveComponent(null)
480
+ setCanvasError('')
481
+ setCanvasStatus('empty')
482
+ return
483
+ }
484
+ const s = scenes[activeId]
485
+ setActiveComponent(() => s.component)
486
+ setCanvasError(s.error)
487
+ setCanvasStatus(s.status)
488
+ // Reset error boundary when component changes
489
+ setErrorBoundaryKey((k) => k + 1)
490
+ }
491
+ if (key === 'mood') {
492
+ setMood((assistant.mentalState.get('mood') || 'ready') as string)
493
+ }
494
+ if (key === 'focus') {
495
+ setFocus((assistant.mentalState.get('focus') || 'chat') as 'chat' | 'canvas')
496
+ }
497
+ if (key === 'layout') {
498
+ setLayout((assistant.mentalState.get('layout') || 'split') as Layout)
253
499
  }
254
- const s = sceneMap[activeId]
255
- setCanvas({
256
- output: (s.state.get('output') || '') as string,
257
- error: (s.state.get('error') || '') as string,
258
- status: (s.state.get('status') || 'idle') as string,
259
- })
260
500
  })
261
501
  return unsub
262
502
  }, [])
263
503
 
264
- // --- keyboard ---
504
+ // --- keyboard (host-level) ---
265
505
  useInput((ch, key) => {
266
- if (key.escape) { exit(); return }
506
+ if (key.escape) {
507
+ exit()
508
+ return
509
+ }
510
+
511
+ // Tab toggles focus
512
+ if (key.tab) {
513
+ const next = focus === 'chat' ? 'canvas' : 'chat'
514
+ setFocus(next)
515
+ assistant.mentalState.set('focus', next)
516
+ return
517
+ }
267
518
 
519
+ // When canvas is focused, only the scene component handles input
520
+ // (via useSceneInput which filters Tab/Escape)
521
+ if (focus === 'canvas') return
522
+
523
+ // Chat-focused input
268
524
  if (key.return) {
269
525
  if (thinking) return
270
526
  const msg = input.trim()
271
527
  if (!msg) return
272
528
  setInput('')
273
- setMessages(prev => [...prev, { role: 'user', content: msg }])
529
+ setMessages((prev) => [...prev, { role: 'user', content: msg }])
274
530
  setThinking(true)
275
- assistant.ask(msg).catch((err: any) => {
276
- setMessages(prev => [...prev, { role: 'system', content: `error: ${err.message}` }])
277
- setThinking(false)
278
- })
531
+ assistant
532
+ .ask(msg)
533
+ .then((text: string) => {
534
+ setStreaming('')
535
+ setThinking(false)
536
+ setActivity('')
537
+ setMessages((prev) => [...prev, { role: 'assistant', content: text }])
538
+ })
539
+ .catch((err: any) => {
540
+ setStreaming('')
541
+ setThinking(false)
542
+ setActivity('')
543
+ setMessages((prev) => [...prev, { role: 'system', content: `error: ${err.message}` }])
544
+ })
279
545
  return
280
546
  }
281
547
 
282
548
  if (key.backspace || key.delete) {
283
- setInput(prev => prev.slice(0, -1))
549
+ setInput((prev) => prev.slice(0, -1))
284
550
  return
285
551
  }
286
552
 
287
553
  if (ch && !key.ctrl && !key.meta) {
288
- setInput(prev => prev + ch)
554
+ setInput((prev) => prev + ch)
289
555
  }
290
556
  })
291
557
 
292
558
  // --- render ---
293
559
  const visible = messages.slice(-30)
294
- const sceneIds = (stage.state.get('sceneIds') || []) as string[]
295
- const activeId = (stage.state.get('activeSceneId') || '') as string
560
+ const scenes = (assistant.mentalState.get('scenes') as Record<string, SceneState>) || {}
561
+ const sceneIds = Object.keys(scenes)
562
+ const activeId = (assistant.mentalState.get('activeSceneId') || '') as string
563
+ const chatFocused = focus === 'chat'
564
+ const canvasFocused = focus === 'canvas'
296
565
 
297
566
  const chatChildren: any[] = []
298
-
299
567
  visible.forEach((m, i) => {
300
568
  const color = m.role === 'user' ? 'green' : m.role === 'system' ? 'red' : 'white'
301
569
  const prefix = m.role === 'user' ? '> ' : ' '
302
570
  chatChildren.push(h(Text, { key: `msg-${i}`, wrap: 'wrap', color }, `${prefix}${m.content}`))
303
571
  })
304
-
305
572
  if (streaming) chatChildren.push(h(Text, { key: 'chat-stream', wrap: 'wrap', dimColor: true }, ` ${streaming}`))
306
573
  if (thinking && !streaming) chatChildren.push(h(Text, { key: 'chat-think', color: 'yellow' }, ' thinking...'))
307
574
  if (activity) chatChildren.push(h(Text, { key: 'chat-act', color: 'blue' }, ` [${activity}]`))
308
575
 
309
- const canvasBody = canvas.output
310
- ? h(Text, { key: 'cvs-out', wrap: 'wrap' }, canvas.output)
311
- : canvas.status === 'empty'
312
- ? h(Text, { key: 'cvs-empty', dimColor: true }, ' ask inkbot to draw something')
313
- : null
314
-
315
- // border + header + status = 4 rows overhead per panel
316
- const panelHeight = rows - 2
576
+ // Canvas content — either the active scene component or placeholders
577
+ let canvasContent: any
578
+ if (activeComponent) {
579
+ canvasContent = h(
580
+ SceneFocusContext.Provider,
581
+ { value: canvasFocused },
582
+ h(
583
+ SceneErrorBoundary,
584
+ {
585
+ key: errorBoundaryKey,
586
+ onError: (err: Error) => reportSceneError(activeId, err.message),
587
+ },
588
+ h(activeComponent),
589
+ ),
590
+ )
591
+ } else if (canvasError) {
592
+ canvasContent = h(
593
+ Box,
594
+ { flexDirection: 'column', paddingX: 1 },
595
+ h(Text, { color: 'red', bold: true }, 'Error'),
596
+ h(Text, { color: 'red', wrap: 'wrap' }, canvasError),
597
+ )
598
+ } else {
599
+ canvasContent = h(Text, { dimColor: true, key: 'cvs-empty' }, ' ask inkbot to draw something')
600
+ }
317
601
 
318
- return h(Box, { flexDirection: 'row', width: '100%', height: rows },
319
- // ── Chat ──
320
- h(Box, { key: 'chat', flexDirection: 'column', width: '50%', height: rows, borderStyle: 'round', borderColor: 'cyan', paddingX: 1 },
321
- h(Text, { bold: true, color: 'cyan' }, ' inkbot '),
602
+ return h(
603
+ Box,
604
+ { flexDirection: 'row', width: '100%', height: rows },
605
+ // ── Chat Pane ──
606
+ h(
607
+ Box,
608
+ {
609
+ key: 'chat',
610
+ flexDirection: 'column',
611
+ width: '50%',
612
+ height: rows,
613
+ borderStyle: 'round',
614
+ borderColor: chatFocused ? 'cyan' : 'gray',
615
+ paddingX: 1,
616
+ },
617
+ h(
618
+ Text,
619
+ { bold: true, color: chatFocused ? 'cyan' : 'gray' },
620
+ ' inkbot ',
621
+ h(Text, { dimColor: true }, `[${mood}]`),
622
+ !chatFocused ? h(Text, { dimColor: true }, ' (tab to focus)') : null,
623
+ ),
322
624
  h(Box, { flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }, ...chatChildren),
323
- h(Box, { borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
324
- h(Text, { color: 'green' }, '> '),
325
- h(Text, null, input),
326
- h(Text, { dimColor: true }, '\u2588'),
625
+ h(
626
+ Box,
627
+ {
628
+ borderStyle: 'single',
629
+ borderColor: chatFocused ? 'gray' : 'blackBright',
630
+ paddingX: 1,
631
+ },
632
+ h(Text, { color: chatFocused ? 'green' : 'blackBright' }, '> '),
633
+ h(Text, { dimColor: !chatFocused }, input),
634
+ chatFocused ? h(Text, { dimColor: true }, '\u2588') : null,
327
635
  ),
328
636
  ),
329
- // ── Canvas ──
330
- h(Box, { key: 'canvas', flexDirection: 'column', width: '50%', height: rows, borderStyle: 'round', borderColor: 'magenta', paddingX: 1 },
331
- h(Text, { bold: true, color: 'magenta' }, ' canvas '),
332
- h(Box, { flexDirection: 'column', height: panelHeight - 4, overflow: 'hidden' },
333
- canvasBody,
334
- canvas.error ? h(Text, { key: 'cvs-err', color: 'red', wrap: 'wrap' }, canvas.error) : null,
637
+ // ── Canvas Pane ──
638
+ h(
639
+ Box,
640
+ {
641
+ key: 'canvas',
642
+ flexDirection: 'column',
643
+ width: '50%',
644
+ height: rows,
645
+ borderStyle: 'round',
646
+ borderColor: canvasFocused ? 'magenta' : 'gray',
647
+ paddingX: 1,
648
+ },
649
+ h(
650
+ Text,
651
+ { bold: true, color: canvasFocused ? 'magenta' : 'gray' },
652
+ ' canvas ',
653
+ canvasStatus === 'interactive'
654
+ ? h(Text, { color: 'yellow' }, '[interactive]')
655
+ : null,
656
+ !canvasFocused && canvasStatus === 'interactive'
657
+ ? h(Text, { dimColor: true }, ' (tab to focus)')
658
+ : null,
659
+ ),
660
+ h(
661
+ Box,
662
+ { flexDirection: 'column', flexGrow: 1, overflow: 'hidden' },
663
+ canvasContent,
335
664
  ),
336
- h(Box, null,
337
- h(Text, { dimColor: true }, ` ${canvas.status}`),
338
- sceneIds.length > 1 ? h(Text, { dimColor: true }, ` scenes: ${sceneIds.join(', ')} active: ${activeId}`) : null,
665
+ h(
666
+ Box,
667
+ null,
668
+ h(Text, { dimColor: true }, ` ${canvasStatus}`),
669
+ sceneIds.length > 1
670
+ ? h(Text, { dimColor: true }, ` scenes: ${sceneIds.join(', ')} active: ${activeId}`)
671
+ : null,
339
672
  ),
340
673
  ),
341
674
  )
342
675
  }
343
676
 
344
- // Mount and hold
345
677
  await ink.render(h(App))
346
678
  await ink.waitUntilExit()
347
679
  }