create-theokit 1.21.0 → 1.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-theokit",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold a new TheoKit project",
6
6
  "license": "Apache-2.0",
@@ -1,21 +1,35 @@
1
1
  import { homedir } from 'node:os'
2
2
 
3
3
  import { Box, Text, useApp, useInput } from 'ink'
4
- import { type ReactElement, useState } from 'react'
4
+ import { type ReactElement, useEffect, useState } from 'react'
5
5
  import {
6
6
  AgentStreaming,
7
7
  AgentTimeline,
8
8
  ChatComposer,
9
+ ContextWindowBar,
10
+ CostMeter,
9
11
  DEFAULT_COMPOSER_SHORTCUTS,
10
12
  findPendingApproval,
11
13
  InkInputProvider,
12
14
  KeyboardHelp,
13
15
  messagesToAgentEvents,
16
+ MultiStepProgress,
14
17
  Notice,
15
18
  PermissionPrompt,
19
+ PlanApproval,
20
+ ProgressActivity,
21
+ ProgressBar,
22
+ QuestionPrompt,
16
23
  readTurnUsage,
24
+ SelectList,
25
+ type SelectListItem,
26
+ Stack,
17
27
  StatusFooter,
18
28
  TheoTUIProvider,
29
+ Toast,
30
+ type TodoItem,
31
+ type TokenCategory,
32
+ TokenUsageChart,
19
33
  useTurnElapsed,
20
34
  type UIMessageLike,
21
35
  } from '@theokit/tui'
@@ -56,11 +70,40 @@ const GREETING: UIMessageLike = {
56
70
  const apiKey = (): string =>
57
71
  process.env.OPENROUTER_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? process.env.OPENAI_API_KEY ?? ''
58
72
 
73
+ // ── Slash-command demo data ────────────────────────────────────────────────────────────────────────
74
+ // The interactive `@theokit/tui` surfaces (`/plan`, `/ask`, `/select`, `/progress`) run live IN the app,
75
+ // triggered from the composer, with real handlers — not a separate gallery. Delete the ones you don't need.
76
+ const DEMO_PLAN = [
77
+ '## Proposed plan',
78
+ '',
79
+ '1. Scaffold the `reports` route',
80
+ '2. Add the `generateReport` server action',
81
+ '3. Stream results into the dashboard',
82
+ ].join('\n')
83
+
84
+ const DEMO_ASK_OPTIONS: readonly SelectListItem[] = [
85
+ { value: 'python', label: 'Python (FastAPI)', description: 'polyglot service via --backend python' },
86
+ { value: 'node', label: 'Node (Hono)', description: 'fetch-handler service' },
87
+ { value: 'none', label: 'TypeScript only', description: 'no external backend' },
88
+ ]
89
+
90
+ const DEMO_SELECT_ITEMS: readonly SelectListItem[] = [
91
+ { value: 'auth', label: 'Auth', description: 'sessions + requireAuth' },
92
+ { value: 'db', label: 'Database', description: 'SQLite by default' },
93
+ { value: 'ws', label: 'WebSocket', description: 'realtime channel' },
94
+ { value: 'deploy', label: 'Deploy', description: 'TheoCloud target' },
95
+ ]
96
+
97
+ const DEMO_STEP_LABELS = ['Plan', 'Generate', 'Validate', 'Ship'] as const
98
+
99
+ /** The composer mode — `chat` is the default; the others swap the composer for a live demo surface. */
100
+ type Mode = 'chat' | 'plan' | 'ask' | 'select' | 'progress'
101
+
59
102
  /**
60
103
  * The terminal surface, composed from `@theokit/tui` the way a Claude Code / OpenCode / Codex CLI is:
61
104
  * `WelcomeBanner` header, a scrolling `<AgentTimeline>` (assistant turns rendered as Markdown + fenced code,
62
105
  * tool calls as collapsible cards, thinking rows — the Claude-Code render), a live `<AgentStreaming>` spinner,
63
- * a bordered `<ChatComposer>`, and a persistent `<AppStatusBar>` footer. Driven by the unified `useAgent`
106
+ * a bordered `<ChatComposer>`, and a persistent `<StatusFooter>` footer. Driven by the unified `useAgent`
64
107
  * hook (M41).
65
108
  *
66
109
  * The conversation comes from `useAgent().thread` (M46), projected to the timeline's `AgentEvent[]` by the
@@ -152,15 +195,33 @@ export function App(): ReactElement {
152
195
  // a resolved state (it is keyed by its own id, distinct from the tool call), so we remember settled ids
153
196
  // to hide the prompt once answered instead of re-showing it forever.
154
197
  const [settledApprovals, setSettledApprovals] = useState<readonly string[]>([])
198
+ // The composer mode + the slash-command demo state (a plan/ask/select/progress surface swaps the composer).
199
+ const [mode, setMode] = useState<Mode>('chat')
200
+ const [progressStep, setProgressStep] = useState(0)
201
+ // `/usage` toggles a token-usage panel; a Toast surfaces transient outcomes (a demo answered, a task done).
202
+ const [showUsage, setShowUsage] = useState(false)
203
+ const [toast, setToast] = useState<{ message: string; variant: 'info' | 'success' | 'error' } | null>(
204
+ null,
205
+ )
155
206
 
156
207
  // The store owns the conversation (M46) — prepend the warm greeting and project to timeline events.
157
208
  const events = messagesToAgentEvents([GREETING, ...agent.thread])
158
209
 
159
210
  // The last turn's usage (readTurnUsage reads the totals the agent stream rides on each assistant
160
- // message's metadata) drives the streaming token count + the footer's context usage. Undefined until
161
- // the first turn reports usage.
211
+ // message's metadata) drives the streaming token count, the footer's context usage, AND the `/usage`
212
+ // observability panel (ContextWindowBar / TokenUsageChart / CostMeter). Undefined until the first turn.
162
213
  const lastUsage = agent.thread.map(readTurnUsage).filter((u) => u !== undefined).at(-1)
163
214
 
215
+ // Only PRESENT categories render in the chart — build the map from whatever the turn actually reported.
216
+ const usageChart: Partial<Record<TokenCategory, number>> = lastUsage
217
+ ? {
218
+ input: lastUsage.inputTokens,
219
+ output: lastUsage.outputTokens,
220
+ ...(lastUsage.cacheReadTokens !== undefined ? { cached: lastUsage.cacheReadTokens } : {}),
221
+ ...(lastUsage.reasoningTokens !== undefined ? { reasoning: lastUsage.reasoningTokens } : {}),
222
+ }
223
+ : {}
224
+
164
225
  // Human-in-the-loop: a gated tool (`send_notification`) pauses the run awaiting a decision. When one is
165
226
  // pending (and not already settled) we show the approval prompt IN PLACE OF the composer and settle it.
166
227
  const rawApproval = findPendingApproval(agent.thread)
@@ -172,13 +233,32 @@ export function App(): ReactElement {
172
233
  void agent.approve(approvalId, { approved })
173
234
  }
174
235
 
175
- // Global keys. While a gated tool awaits approval, the `<PermissionPrompt>` owns the keys (↑/↓/Enter/Esc)
176
- // stay out of its way. Otherwise: Esc cancels a running turn / closes help; Ctrl+C cancels a turn, else
177
- // arms → quits on a second press. The ChatComposer owns editing keys (/, @, !, ?, ↑↓, emacs chords).
236
+ // `/progress` runs a live multi-step task: advance one step every 700ms; when it finishes, toast + return
237
+ // to chat. This is the only mode driven by a timer the interactive modes complete on the user's input.
238
+ useEffect(() => {
239
+ if (mode !== 'progress') return
240
+ if (progressStep >= DEMO_STEP_LABELS.length) {
241
+ const done = setTimeout(() => {
242
+ setMode('chat')
243
+ setToast({ message: 'Task complete', variant: 'success' })
244
+ }, 600)
245
+ return () => clearTimeout(done)
246
+ }
247
+ const tick = setTimeout(() => setProgressStep((s) => s + 1), 700)
248
+ return () => clearTimeout(tick)
249
+ }, [mode, progressStep])
250
+
251
+ const inDemoInput = mode === 'plan' || mode === 'ask' || mode === 'select'
252
+
253
+ // Global keys. A gated tool (`PermissionPrompt`) or an interactive demo (`PlanApproval`/`QuestionPrompt`/
254
+ // `SelectList`) OWNS the keys while active — stay out of its way. Otherwise: Esc exits the progress demo /
255
+ // closes the usage panel / closes help / cancels a running turn; Ctrl+C cancels a turn, else arms → quits.
178
256
  useInput((input, key) => {
179
- if (pendingApproval) return
257
+ if (pendingApproval || inDemoInput) return
180
258
  if (key.escape) {
181
- if (showHelp) setShowHelp(false)
259
+ if (mode === 'progress') setMode('chat')
260
+ else if (showUsage) setShowUsage(false)
261
+ else if (showHelp) setShowHelp(false)
182
262
  else if (streaming) agent.abort()
183
263
  return
184
264
  }
@@ -197,43 +277,84 @@ export function App(): ReactElement {
197
277
  const handleSubmit = (text: string): void => {
198
278
  const trimmed = text.trim()
199
279
  if (trimmed.length === 0) return
200
- if (trimmed === '/clear') {
201
- agent.reset()
202
- return
203
- }
204
- if (trimmed === '/help') {
205
- setShowHelp((h) => !h)
206
- return
280
+ switch (trimmed) {
281
+ case '/clear':
282
+ agent.reset()
283
+ return
284
+ case '/help':
285
+ setShowHelp((h) => !h)
286
+ return
287
+ case '/usage':
288
+ setShowUsage((u) => !u)
289
+ return
290
+ case '/plan':
291
+ setMode('plan')
292
+ return
293
+ case '/ask':
294
+ setMode('ask')
295
+ return
296
+ case '/select':
297
+ setMode('select')
298
+ return
299
+ case '/progress':
300
+ setProgressStep(0)
301
+ setMode('progress')
302
+ return
303
+ default:
304
+ agent.send({ message: trimmed })
207
305
  }
208
- agent.send({ message: trimmed })
209
306
  }
210
307
 
308
+ // The `/progress` lanes: steps before the cursor are done, the cursor is active, the rest are pending.
309
+ const progressSteps: readonly TodoItem[] = DEMO_STEP_LABELS.map((label, i) => ({
310
+ id: label,
311
+ label,
312
+ status: i < progressStep ? 'done' : i === progressStep ? 'active' : 'pending',
313
+ }))
314
+ const progressPercent = Math.min(100, Math.round((progressStep / DEMO_STEP_LABELS.length) * 100))
315
+
211
316
  return (
212
317
  <TheoTUIProvider theme={THEME}>
213
- {/* InkInputProvider bridges Ink's stdin to @theokit/tui's interactive surfaces (PermissionPrompt's
214
- numbered choice menu) so they receive keys under plain Ink — the ChatComposer keeps Ink's own hooks. */}
318
+ {/* InkInputProvider bridges Ink's stdin to @theokit/tui's interactive surfaces (PermissionPrompt +
319
+ PlanApproval / QuestionPrompt / SelectList) so they receive keys under plain Ink — the ChatComposer
320
+ keeps Ink's own hooks. `Stack` gives the top-level sections the Claude-Code one-line cadence. */}
215
321
  <InkInputProvider>
216
- <Box flexDirection="column">
217
- <Banner />
218
-
219
- <AgentTimeline events={events} />
220
-
221
- {streaming ? (
222
- <AgentStreaming
223
- phrases={THINKING_PHRASES}
224
- shimmer
225
- elapsedSeconds={elapsed}
226
- tokens={lastUsage?.totalTokens}
227
- tokenDirection="down"
228
- showCancelHint
229
- />
230
- ) : null}
231
- {agent.error ? <Notice variant="error">{agent.error.message}</Notice> : null}
322
+ <Stack gap={1}>
323
+ <Banner />
232
324
 
233
- {showHelp ? <KeyboardHelp shortcuts={DEFAULT_COMPOSER_SHORTCUTS} /> : null}
325
+ <AgentTimeline events={events} />
234
326
 
235
- {/* A one-line top margin separates the input (or the approval prompt) from the conversation above. */}
236
- <Box marginTop={1} flexDirection="column">
327
+ {streaming ? (
328
+ <AgentStreaming
329
+ phrases={THINKING_PHRASES}
330
+ shimmer
331
+ elapsedSeconds={elapsed}
332
+ tokens={lastUsage?.totalTokens}
333
+ tokenDirection="down"
334
+ showCancelHint
335
+ />
336
+ ) : null}
337
+ {agent.error ? <Notice variant="error">{agent.error.message}</Notice> : null}
338
+
339
+ {/* `/usage` — the observability panel, from the last turn's real usage. ContextWindowBar shows the
340
+ context fill, TokenUsageChart the per-category breakdown, CostMeter the session cost (if reported). */}
341
+ {showUsage && lastUsage ? (
342
+ <Box flexDirection="column">
343
+ <ContextWindowBar usedTokens={lastUsage.inputTokens} limitTokens={AGENT.contextWindow} />
344
+ <TokenUsageChart usage={usageChart} />
345
+ {lastUsage.cost !== undefined ? <CostMeter costUsd={lastUsage.cost} /> : null}
346
+ </Box>
347
+ ) : null}
348
+
349
+ {/* A transient outcome banner (a demo answered, a task finished). Auto-dismisses after 5s. */}
350
+ {toast ? (
351
+ <Toast message={toast.message} variant={toast.variant} onDismiss={() => setToast(null)} />
352
+ ) : null}
353
+
354
+ {showHelp ? <KeyboardHelp shortcuts={DEFAULT_COMPOSER_SHORTCUTS} /> : null}
355
+
356
+ {/* The input area: a gated tool's approval card, an interactive demo surface (/plan /ask /select
357
+ /progress), or the composer. Each demo completes back to chat (Enter) or cancels (Esc). */}
237
358
  {pendingApproval ? (
238
359
  <PermissionPrompt
239
360
  toolType="Tool call"
@@ -250,6 +371,56 @@ export function App(): ReactElement {
250
371
  settleApproval(pendingApproval.approvalId, decision === 'yes')
251
372
  }}
252
373
  />
374
+ ) : mode === 'plan' ? (
375
+ <PlanApproval
376
+ plan={DEMO_PLAN}
377
+ onDecision={(d) => {
378
+ setMode('chat')
379
+ setToast({
380
+ message:
381
+ d.kind === 'approve'
382
+ ? 'Plan approved'
383
+ : `Revision requested${d.feedback ? `: ${d.feedback}` : ''}`,
384
+ variant: 'success',
385
+ })
386
+ }}
387
+ />
388
+ ) : mode === 'ask' ? (
389
+ <QuestionPrompt
390
+ header="Backend"
391
+ question="Which backend should the app ship next to?"
392
+ options={DEMO_ASK_OPTIONS}
393
+ allowFreeText
394
+ onAnswer={(a) => {
395
+ setMode('chat')
396
+ setToast({
397
+ message: `Answered: ${a.values.join(', ')}${a.text ? ` (${a.text})` : ''}`,
398
+ variant: 'info',
399
+ })
400
+ }}
401
+ />
402
+ ) : mode === 'select' ? (
403
+ <SelectList
404
+ items={DEMO_SELECT_ITEMS}
405
+ multi
406
+ onSubmit={(values) => {
407
+ setMode('chat')
408
+ setToast({ message: `Selected: ${values.join(', ') || '(none)'}`, variant: 'info' })
409
+ }}
410
+ />
411
+ ) : mode === 'progress' ? (
412
+ <Box flexDirection="column">
413
+ <MultiStepProgress steps={progressSteps} current={progressStep} groupLabel="Demo task" />
414
+ <ProgressActivity
415
+ label="Working…"
416
+ percent={progressPercent}
417
+ elapsedSeconds={elapsed}
418
+ tokens={lastUsage?.totalTokens}
419
+ tokenDirection="up"
420
+ />
421
+ <ProgressBar percent={progressPercent} />
422
+ <Text dimColor>esc to exit</Text>
423
+ </Box>
253
424
  ) : (
254
425
  <ChatComposer
255
426
  placeholder={PLACEHOLDER}
@@ -258,27 +429,31 @@ export function App(): ReactElement {
258
429
  commands={[
259
430
  { name: 'clear', description: 'clear the conversation' },
260
431
  { name: 'help', description: 'toggle the keyboard shortcuts panel' },
432
+ { name: 'usage', description: 'toggle the token-usage panel' },
433
+ { name: 'plan', description: 'demo: plan approval card' },
434
+ { name: 'ask', description: 'demo: question prompt' },
435
+ { name: 'select', description: 'demo: multi-select list' },
436
+ { name: 'progress', description: 'demo: multi-step progress' },
261
437
  ]}
262
438
  onHelpToggle={() => setShowHelp((h) => !h)}
263
439
  onSubmit={handleSubmit}
264
440
  />
265
441
  )}
266
- </Box>
267
442
 
268
- {/* Claude Code's two-line footer: top-left the model, top-right the context usage; bottom the
269
- shortcuts hint. `StatusFooter` justifies the top row to the terminal edges. */}
270
- <StatusFooter
271
- left={<Text>{MODEL}</Text>}
272
- right={
273
- lastUsage ? (
274
- <Text>
275
- {fmtK(lastUsage.inputTokens)}/{fmtK(AGENT.contextWindow)} context
276
- </Text>
277
- ) : undefined
278
- }
279
- hint="? for shortcuts"
280
- />
281
- </Box>
443
+ {/* Claude Code's two-line footer: top-left the model, top-right the context usage; bottom the
444
+ shortcuts hint. `StatusFooter` justifies the top row to the terminal edges. */}
445
+ <StatusFooter
446
+ left={<Text>{MODEL}</Text>}
447
+ right={
448
+ lastUsage ? (
449
+ <Text>
450
+ {fmtK(lastUsage.inputTokens)}/{fmtK(AGENT.contextWindow)} context
451
+ </Text>
452
+ ) : undefined
453
+ }
454
+ hint="? for shortcuts"
455
+ />
456
+ </Stack>
282
457
  </InkInputProvider>
283
458
  </TheoTUIProvider>
284
459
  )