create-theokit 1.22.0 → 1.23.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.22.0",
3
+ "version": "1.23.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold a new TheoKit project",
6
6
  "license": "Apache-2.0",
@@ -24,3 +24,48 @@ npm run dev # tsx tui/main.tsx
24
24
  ```
25
25
 
26
26
  Type a message + Enter; Esc to quit. The agent lives in `agents/chat.ts` (edit its model / system prompt).
27
+
28
+ ## Architecture (System Design)
29
+
30
+ The TUI surface is a small React (Ink) app with ONE composition root and three presentational pieces.
31
+
32
+ ### Component tree
33
+
34
+ ```
35
+ tui/main.tsx render(<App/>, { exitOnCtrlC: false })
36
+ └─ tui/App.tsx COMPOSITION ROOT — owns useAgent + state + key routing
37
+ ├─ components/Banner.tsx welcome box (wordmark · model · cwd · tips) — presentational
38
+ ├─ <AgentTimeline/> the conversation (from @theokit/tui)
39
+ ├─ components/UsagePanel.tsx /usage: ContextWindowBar · TokenUsageChart · CostMeter — prop-driven
40
+ ├─ components/Demos.tsx /plan /ask /select /progress showcase — DELETABLE (see below)
41
+ └─ <ChatComposer/> | <PermissionPrompt/> the input area (composer, or a gated-tool approval card)
42
+ ```
43
+
44
+ ### Data flow
45
+
46
+ ```
47
+ you type ─▶ App.handleSubmit ─▶ agent.send() ─▶ InProcessTransport ─▶ streamAgentTurnInProcess (theokit)
48
+ │ UIMessageStream
49
+ useAgent().thread ◀───────────────────────────────────────────────────────────┘
50
+ │ messagesToAgentEvents (ai-free projection, M46) readTurnUsage
51
+ ▼ │
52
+ <AgentTimeline/> lastUsage ─▶ footer + <UsagePanel/>
53
+ ```
54
+
55
+ ### Layer boundaries
56
+
57
+ | Layer | Owns | The surface imports it as |
58
+ |---|---|---|
59
+ | `shared/agent.ts` | branding (name · model label · greeting · contextWindow) — **single source of truth** | `AGENT` (Banner + App) |
60
+ | `agents/chat.ts` | the agent's model + system prompt (persona) | `chatAgent` (the transport) |
61
+ | `server/` (via `theokit`) | the in-process runtime (`streamAgentTurnInProcess`) | one import |
62
+ | `tui/` | the terminal surface (this folder) | — |
63
+
64
+ The surface never imports `ai` — `@theokit/tui` renders the conversation structurally; only theokit's runtime touches the stream.
65
+
66
+ ### Extension points
67
+
68
+ - **Restyle** → `tui/theme.ts` (accent, banner wordmark/copy, spinner words, placeholder) — one file, no component edits.
69
+ - **Rebrand** → `shared/agent.ts` (name · greeting · model label · contextWindow).
70
+ - **Change behavior** → `agents/chat.ts` (model + system prompt).
71
+ - **Delete the demos** → remove `tui/components/Demos.tsx`, the `<DemoSurface>` branch in `App.tsx`, the 4 demo entries in the `<ChatComposer commands>` list, and the `/plan|/ask|/select|/progress` cases in `handleSubmit`. Everything else keeps working.
@@ -1,35 +1,21 @@
1
- import { homedir } from 'node:os'
2
-
3
- import { Box, Text, useApp, useInput } from 'ink'
4
- import { type ReactElement, useEffect, useState } from 'react'
1
+ import { Text, useApp, useInput } from 'ink'
2
+ import { type ReactElement, useCallback, useState } from 'react'
5
3
  import {
6
4
  AgentStreaming,
7
5
  AgentTimeline,
8
6
  ChatComposer,
9
- ContextWindowBar,
10
- CostMeter,
11
7
  DEFAULT_COMPOSER_SHORTCUTS,
12
8
  findPendingApproval,
13
9
  InkInputProvider,
14
10
  KeyboardHelp,
15
11
  messagesToAgentEvents,
16
- MultiStepProgress,
17
12
  Notice,
18
13
  PermissionPrompt,
19
- PlanApproval,
20
- ProgressActivity,
21
- ProgressBar,
22
- QuestionPrompt,
23
14
  readTurnUsage,
24
- SelectList,
25
- type SelectListItem,
26
15
  Stack,
27
16
  StatusFooter,
28
17
  TheoTUIProvider,
29
18
  Toast,
30
- type TodoItem,
31
- type TokenCategory,
32
- TokenUsageChart,
33
19
  useTurnElapsed,
34
20
  type UIMessageLike,
35
21
  } from '@theokit/tui'
@@ -38,23 +24,14 @@ import { streamAgentTurnInProcess } from 'theokit/server/agent'
38
24
 
39
25
  import * as chatAgent from '../agents/chat.js'
40
26
  import { AGENT } from '../shared/agent.js'
41
- // Everything visual (accent, banner wordmark + copy, spinner words, placeholder) lives in ONE file
42
- // edit `tui/theme.ts` to restyle; this component never needs touching for a rebrand.
43
- import {
44
- ACCENT,
45
- BANNER_TIPS,
46
- BANNER_WHATS_NEW,
47
- LOGO,
48
- PLACEHOLDER,
49
- THEME,
50
- THINKING_PHRASES,
51
- WIDE_COLS,
52
- } from './theme.js'
27
+ // Composed from `tui/components/*`; restyle everything in the ONE file `tui/theme.ts`.
28
+ import { Banner } from './components/Banner.js'
29
+ import { DemoSurface, type Mode, type ToastPayload } from './components/Demos.js'
30
+ import { UsagePanel } from './components/UsagePanel.js'
31
+ import { PLACEHOLDER, THEME, THINKING_PHRASES } from './theme.js'
53
32
 
54
- /** Shown in the status bar (from `shared/agent.ts`). */
33
+ /** Model label (footer) from `shared/agent.ts`, the single branding source. */
55
34
  const MODEL = AGENT.model
56
- /** cwd with the home dir tildeified (`~/…`), so the banner line stays short. */
57
- const CWD = process.cwd().replace(homedir(), '~')
58
35
  /** Compact token count for the footer: `128000 → 128k`, `12300 → 12.3k`, `462 → 462`. */
59
36
  const fmtK = (n: number): string =>
60
37
  n >= 1000 ? `${(Math.round(n / 100) / 10).toString().replace(/\.0$/, '')}k` : `${n}`
@@ -70,160 +47,32 @@ const GREETING: UIMessageLike = {
70
47
  const apiKey = (): string =>
71
48
  process.env.OPENROUTER_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? process.env.OPENAI_API_KEY ?? ''
72
49
 
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
-
102
- /**
103
- * The terminal surface, composed from `@theokit/tui` the way a Claude Code / OpenCode / Codex CLI is:
104
- * `WelcomeBanner` header, a scrolling `<AgentTimeline>` (assistant turns rendered as Markdown + fenced code,
105
- * tool calls as collapsible cards, thinking rows — the Claude-Code render), a live `<AgentStreaming>` spinner,
106
- * a bordered `<ChatComposer>`, and a persistent `<StatusFooter>` footer. Driven by the unified `useAgent`
107
- * hook (M41).
108
- *
109
- * The conversation comes from `useAgent().thread` (M46), projected to the timeline's `AgentEvent[]` by the
110
- * ai-free `messagesToAgentEvents` — so this terminal surface never imports the `ai` SDK. Prepend the greeting.
111
- */
50
+ // The unified in-process transport (M41): each turn drives the agent through `streamAgentTurnInProcess`.
112
51
  const transport = new InProcessTransport({
113
52
  run: (input) => streamAgentTurnInProcess(chatAgent, apiKey(), input),
114
53
  })
115
54
 
116
- /** The app name `{{name}}` is substituted at scaffold time. */
117
- const APP_NAME = '{{name}}'
118
-
119
- /**
120
- * The Claude-Code welcome box: a full-width rounded accent border with margins on every side. On a wide
121
- * terminal it lays out two columns — left is the `Theo` wordmark + a `✻` welcome line + model + cwd; right
122
- * is the getting-started tips + what's new. Below `WIDE_COLS` it collapses to a single column.
123
- */
124
- function Banner(): ReactElement {
125
- const cols = process.stdout.columns ?? 80
126
- const wide = cols >= WIDE_COLS
127
- return (
128
- <Box
129
- // Full width, with a one-cell margin on every side.
130
- width={cols - 2}
131
- marginX={1}
132
- marginY={1}
133
- paddingX={2}
134
- paddingY={1}
135
- borderStyle="round"
136
- borderColor={ACCENT}
137
- flexDirection="row"
138
- >
139
- {/* Fixed-width left column (fits the 34-wide wordmark) so a long cwd truncates instead of pushing
140
- the right column off-screen — the box stays full width, the content stays grouped on the left. */}
141
- <Box flexDirection="column" width={38} flexShrink={0}>
142
- <Text color={ACCENT}>{LOGO}</Text>
143
- <Box marginTop={1} flexDirection="column">
144
- <Text color={ACCENT} bold wrap="truncate-end">
145
- ✻ Welcome to {APP_NAME}
146
- </Text>
147
- <Text dimColor wrap="truncate-end">
148
- {MODEL}
149
- </Text>
150
- <Text dimColor wrap="truncate-start">
151
- cwd: {CWD}
152
- </Text>
153
- </Box>
154
- </Box>
155
- {wide ? (
156
- <Box flexDirection="column" flexShrink={0} marginLeft={4}>
157
- <Text color={ACCENT} bold>
158
- Tips for getting started
159
- </Text>
160
- <Box marginTop={1} flexDirection="column">
161
- {BANNER_TIPS.map((tip) => (
162
- <Text key={tip} dimColor>
163
- {tip}
164
- </Text>
165
- ))}
166
- </Box>
167
- <Box marginTop={1} flexDirection="column">
168
- <Text color={ACCENT} bold>
169
- What&apos;s new
170
- </Text>
171
- <Box marginTop={1} flexDirection="column">
172
- {BANNER_WHATS_NEW.map((line) => (
173
- <Text key={line} dimColor>
174
- {line}
175
- </Text>
176
- ))}
177
- </Box>
178
- </Box>
179
- </Box>
180
- ) : null}
181
- </Box>
182
- )
183
- }
184
-
55
+ /** The surface's composition root: owns state (`useAgent` M41 + mode) and composes the components under `<Stack>`. */
185
56
  export function App(): ReactElement {
186
57
  const agent = useAgent<{ message: string }>(transport)
187
58
  const streaming = agent.status === 'streaming'
188
59
  const elapsed = useTurnElapsed(streaming)
189
60
  const { exit } = useApp()
190
61
  const [showHelp, setShowHelp] = useState(false)
191
- // Ctrl+C is a two-step quit (Claude Code): the first press arms the exit + shows a hint, the second
192
- // quits. Any other key disarms. `render(<App/>, { exitOnCtrlC: false })` in main.tsx hands us Ctrl+C.
62
+ // Ctrl+C is a two-step quit (Claude Code): first press arms + hints, second quits; any other key disarms.
193
63
  const [exitArmed, setExitArmed] = useState(false)
194
- // Approval ids already decided — the `approval-requested` part is a STANDALONE gate that never flips to
195
- // a resolved state (it is keyed by its own id, distinct from the tool call), so we remember settled ids
196
- // to hide the prompt once answered instead of re-showing it forever.
64
+ // Settled approval ids — the gate never flips to resolved, so we hide answered prompts by id.
197
65
  const [settledApprovals, setSettledApprovals] = useState<readonly string[]>([])
198
- // The composer mode + the slash-command demo state (a plan/ask/select/progress surface swaps the composer).
66
+ // Composer mode (`chat` = composer; others swap it for a `<DemoSurface>`), `/usage` toggle, `<Toast>`.
199
67
  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
68
  const [showUsage, setShowUsage] = useState(false)
203
- const [toast, setToast] = useState<{ message: string; variant: 'info' | 'success' | 'error' } | null>(
204
- null,
205
- )
69
+ const [toast, setToast] = useState<ToastPayload | null>(null)
70
+ // Stable so the `/progress` timer effect (in ProgressDemo) never restarts on an unrelated re-render.
71
+ const backToChat = useCallback(() => setMode('chat'), [])
206
72
 
207
- // The store owns the conversation (M46) prepend the warm greeting and project to timeline events.
73
+ // Derived from the store (M46): timeline events (greeting prepended), the last turn's usage, HITL approval.
208
74
  const events = messagesToAgentEvents([GREETING, ...agent.thread])
209
-
210
- // The last turn's usage (readTurnUsage reads the totals the agent stream rides on each assistant
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.
213
75
  const lastUsage = agent.thread.map(readTurnUsage).filter((u) => u !== undefined).at(-1)
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
-
225
- // Human-in-the-loop: a gated tool (`send_notification`) pauses the run awaiting a decision. When one is
226
- // pending (and not already settled) we show the approval prompt IN PLACE OF the composer and settle it.
227
76
  const rawApproval = findPendingApproval(agent.thread)
228
77
  const pendingApproval =
229
78
  rawApproval && !settledApprovals.includes(rawApproval.approvalId) ? rawApproval : undefined
@@ -233,26 +82,9 @@ export function App(): ReactElement {
233
82
  void agent.approve(approvalId, { approved })
234
83
  }
235
84
 
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
85
  const inDemoInput = mode === 'plan' || mode === 'ask' || mode === 'select'
252
86
 
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.
87
+ // Global keys skipped while a gated tool / interactive demo owns input. Esc backs out; Ctrl+C cancels/quits.
256
88
  useInput((input, key) => {
257
89
  if (pendingApproval || inDemoInput) return
258
90
  if (key.escape) {
@@ -297,7 +129,6 @@ export function App(): ReactElement {
297
129
  setMode('select')
298
130
  return
299
131
  case '/progress':
300
- setProgressStep(0)
301
132
  setMode('progress')
302
133
  return
303
134
  default:
@@ -305,19 +136,9 @@ export function App(): ReactElement {
305
136
  }
306
137
  }
307
138
 
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
-
316
139
  return (
317
140
  <TheoTUIProvider theme={THEME}>
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. */}
141
+ {/* InkInputProvider bridges Ink stdin to the interactive surfaces; `Stack` gives the Claude-Code cadence. */}
321
142
  <InkInputProvider>
322
143
  <Stack gap={1}>
323
144
  <Banner />
@@ -335,17 +156,10 @@ export function App(): ReactElement {
335
156
  />
336
157
  ) : null}
337
158
  {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). */}
159
+ {/* `/usage` — the observability panel, from the last turn's real usage. */}
341
160
  {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>
161
+ <UsagePanel usage={lastUsage} contextWindow={AGENT.contextWindow} />
347
162
  ) : null}
348
-
349
163
  {/* A transient outcome banner (a demo answered, a task finished). Auto-dismisses after 5s. */}
350
164
  {toast ? (
351
165
  <Toast message={toast.message} variant={toast.variant} onDismiss={() => setToast(null)} />
@@ -353,8 +167,7 @@ export function App(): ReactElement {
353
167
 
354
168
  {showHelp ? <KeyboardHelp shortcuts={DEFAULT_COMPOSER_SHORTCUTS} /> : null}
355
169
 
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). */}
170
+ {/* The input area: a gated tool's approval card, a demo surface, or the composer. */}
358
171
  {pendingApproval ? (
359
172
  <PermissionPrompt
360
173
  toolType="Tool call"
@@ -365,62 +178,18 @@ export function App(): ReactElement {
365
178
  : undefined
366
179
  }
367
180
  onDecision={(decision) => {
368
- // PermissionPrompt's default choices are `yes`/`no` (Esc → the last one, `no`, the safe
369
- // default). Only `yes` approves. Settle (remembering the id so the standalone gate part
370
- // stops re-showing) and forward the decision to the client.
181
+ // Default choices are `yes`/`no` (Esc → `no`, the safe default); only `yes` approves.
371
182
  settleApproval(pendingApproval.approvalId, decision === 'yes')
372
183
  }}
373
184
  />
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
- }}
185
+ ) : mode !== 'chat' ? (
186
+ <DemoSurface
187
+ mode={mode}
188
+ elapsed={elapsed}
189
+ tokens={lastUsage?.totalTokens}
190
+ onComplete={backToChat}
191
+ onToast={setToast}
410
192
  />
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>
424
193
  ) : (
425
194
  <ChatComposer
426
195
  placeholder={PLACEHOLDER}
@@ -440,8 +209,7 @@ export function App(): ReactElement {
440
209
  />
441
210
  )}
442
211
 
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. */}
212
+ {/* Claude Code's two-line footer: model (left) · context usage (right) · shortcuts hint (bottom). */}
445
213
  <StatusFooter
446
214
  left={<Text>{MODEL}</Text>}
447
215
  right={
@@ -0,0 +1,81 @@
1
+ import { homedir } from 'node:os'
2
+
3
+ import { Box, Text } from 'ink'
4
+ import { type ReactElement } from 'react'
5
+
6
+ import { AGENT } from '../../shared/agent.js'
7
+ // The banner reads its visual tokens from the ONE restyle file — edit `tui/theme.ts` to rebrand.
8
+ import { ACCENT, BANNER_TIPS, BANNER_WHATS_NEW, LOGO, WIDE_COLS } from '../theme.js'
9
+
10
+ /** The app name — `{{name}}` is substituted at scaffold time. */
11
+ const APP_NAME = '{{name}}'
12
+ /** Model display label (from `shared/agent.ts` — the single branding source). */
13
+ const MODEL = AGENT.model
14
+ /** cwd with the home dir tildeified (`~/…`), so the banner line stays short. */
15
+ const CWD = process.cwd().replace(homedir(), '~')
16
+
17
+ /**
18
+ * The Claude-Code welcome box: a full-width rounded accent border with margins on every side. On a wide
19
+ * terminal it lays out two columns — left is the `Theo` wordmark + a `✻` welcome line + model + cwd; right
20
+ * is the getting-started tips + what's new. Below `WIDE_COLS` it collapses to a single column.
21
+ */
22
+ export function Banner(): ReactElement {
23
+ const cols = process.stdout.columns ?? 80
24
+ const wide = cols >= WIDE_COLS
25
+ return (
26
+ <Box
27
+ // Full width, with a one-cell margin on every side.
28
+ width={cols - 2}
29
+ marginX={1}
30
+ marginY={1}
31
+ paddingX={2}
32
+ paddingY={1}
33
+ borderStyle="round"
34
+ borderColor={ACCENT}
35
+ flexDirection="row"
36
+ >
37
+ {/* Fixed-width left column (fits the 34-wide wordmark) so a long cwd truncates instead of pushing
38
+ the right column off-screen — the box stays full width, the content stays grouped on the left. */}
39
+ <Box flexDirection="column" width={38} flexShrink={0}>
40
+ <Text color={ACCENT}>{LOGO}</Text>
41
+ <Box marginTop={1} flexDirection="column">
42
+ <Text color={ACCENT} bold wrap="truncate-end">
43
+ ✻ Welcome to {APP_NAME}
44
+ </Text>
45
+ <Text dimColor wrap="truncate-end">
46
+ {MODEL}
47
+ </Text>
48
+ <Text dimColor wrap="truncate-start">
49
+ cwd: {CWD}
50
+ </Text>
51
+ </Box>
52
+ </Box>
53
+ {wide ? (
54
+ <Box flexDirection="column" flexShrink={0} marginLeft={4}>
55
+ <Text color={ACCENT} bold>
56
+ Tips for getting started
57
+ </Text>
58
+ <Box marginTop={1} flexDirection="column">
59
+ {BANNER_TIPS.map((tip) => (
60
+ <Text key={tip} dimColor>
61
+ {tip}
62
+ </Text>
63
+ ))}
64
+ </Box>
65
+ <Box marginTop={1} flexDirection="column">
66
+ <Text color={ACCENT} bold>
67
+ What&apos;s new
68
+ </Text>
69
+ <Box marginTop={1} flexDirection="column">
70
+ {BANNER_WHATS_NEW.map((line) => (
71
+ <Text key={line} dimColor>
72
+ {line}
73
+ </Text>
74
+ ))}
75
+ </Box>
76
+ </Box>
77
+ </Box>
78
+ ) : null}
79
+ </Box>
80
+ )
81
+ }
@@ -0,0 +1,179 @@
1
+ import { Box, Text } from 'ink'
2
+ import { type ReactElement, useEffect, useState } from 'react'
3
+ import {
4
+ MultiStepProgress,
5
+ PlanApproval,
6
+ ProgressActivity,
7
+ ProgressBar,
8
+ QuestionPrompt,
9
+ SelectList,
10
+ type SelectListItem,
11
+ type TodoItem,
12
+ } from '@theokit/tui'
13
+
14
+ /**
15
+ * ────────────────────────────────────────────────────────────────────────────────────────────────
16
+ * tui/components/Demos.tsx — the slash-command showcase.
17
+ *
18
+ * A live tour of the interactive `@theokit/tui@0.40.0` surfaces, reachable from the composer:
19
+ * /plan → PlanApproval /ask → QuestionPrompt
20
+ * /select → SelectList /progress → MultiStepProgress + ProgressActivity + ProgressBar
21
+ *
22
+ * Everything demo-only lives HERE — to drop the showcase from your app, delete this file and the
23
+ * `<DemoSurface>` branch + the `/plan|/ask|/select|/progress` cases in `App.tsx`.
24
+ * ────────────────────────────────────────────────────────────────────────────────────────────────
25
+ */
26
+
27
+ /** The composer mode — `chat` shows the composer; the others swap it for a live demo surface. */
28
+ export type Mode = 'chat' | 'plan' | 'ask' | 'select' | 'progress'
29
+
30
+ /** A transient outcome the app surfaces as a `<Toast>`. */
31
+ export interface ToastPayload {
32
+ message: string
33
+ variant: 'info' | 'success' | 'error'
34
+ }
35
+
36
+ const DEMO_PLAN = [
37
+ '## Proposed plan',
38
+ '',
39
+ '1. Scaffold the `reports` route',
40
+ '2. Add the `generateReport` server action',
41
+ '3. Stream results into the dashboard',
42
+ ].join('\n')
43
+
44
+ const DEMO_ASK_OPTIONS: readonly SelectListItem[] = [
45
+ { value: 'python', label: 'Python (FastAPI)', description: 'polyglot service via --backend python' },
46
+ { value: 'node', label: 'Node (Hono)', description: 'fetch-handler service' },
47
+ { value: 'none', label: 'TypeScript only', description: 'no external backend' },
48
+ ]
49
+
50
+ const DEMO_SELECT_ITEMS: readonly SelectListItem[] = [
51
+ { value: 'auth', label: 'Auth', description: 'sessions + requireAuth' },
52
+ { value: 'db', label: 'Database', description: 'SQLite by default' },
53
+ { value: 'ws', label: 'WebSocket', description: 'realtime channel' },
54
+ { value: 'deploy', label: 'Deploy', description: 'TheoCloud target' },
55
+ ]
56
+
57
+ const DEMO_STEP_LABELS = ['Plan', 'Generate', 'Validate', 'Ship'] as const
58
+
59
+ /**
60
+ * The `/progress` demo owns its OWN step counter + 700ms timer (an implementation detail, not app state).
61
+ * It advances one step per tick, then calls `onComplete` (back to chat) + `onToast`. `onComplete`/`onToast`
62
+ * must be stable (the parent passes a `useCallback` + the stable `setToast`) so the timer effect re-runs on
63
+ * `step` only — never restarting mid-run on an unrelated parent re-render.
64
+ */
65
+ function ProgressDemo({
66
+ elapsed,
67
+ tokens,
68
+ onComplete,
69
+ onToast,
70
+ }: {
71
+ elapsed: number
72
+ tokens: number | undefined
73
+ onComplete: () => void
74
+ onToast: (t: ToastPayload) => void
75
+ }): ReactElement {
76
+ const [step, setStep] = useState(0)
77
+
78
+ useEffect(() => {
79
+ if (step >= DEMO_STEP_LABELS.length) {
80
+ const done = setTimeout(() => {
81
+ onComplete()
82
+ onToast({ message: 'Task complete', variant: 'success' })
83
+ }, 600)
84
+ return () => clearTimeout(done)
85
+ }
86
+ const tick = setTimeout(() => setStep((s) => s + 1), 700)
87
+ return () => clearTimeout(tick)
88
+ }, [step, onComplete, onToast])
89
+
90
+ const steps: readonly TodoItem[] = DEMO_STEP_LABELS.map((label, i) => ({
91
+ id: label,
92
+ label,
93
+ status: i < step ? 'done' : i === step ? 'active' : 'pending',
94
+ }))
95
+ const percent = Math.min(100, Math.round((step / DEMO_STEP_LABELS.length) * 100))
96
+
97
+ return (
98
+ <Box flexDirection="column">
99
+ <MultiStepProgress steps={steps} current={step} groupLabel="Demo task" />
100
+ <ProgressActivity
101
+ label="Working…"
102
+ percent={percent}
103
+ elapsedSeconds={elapsed}
104
+ tokens={tokens}
105
+ tokenDirection="up"
106
+ />
107
+ <ProgressBar percent={percent} />
108
+ <Text dimColor>esc to exit</Text>
109
+ </Box>
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Renders the demo surface for a non-`chat` `mode`, IN PLACE OF the composer. Each interactive demo
115
+ * completes back to chat via `onComplete` and reports its outcome via `onToast`.
116
+ */
117
+ export function DemoSurface({
118
+ mode,
119
+ elapsed,
120
+ tokens,
121
+ onComplete,
122
+ onToast,
123
+ }: {
124
+ mode: Exclude<Mode, 'chat'>
125
+ elapsed: number
126
+ tokens: number | undefined
127
+ onComplete: () => void
128
+ onToast: (t: ToastPayload) => void
129
+ }): ReactElement {
130
+ switch (mode) {
131
+ case 'plan':
132
+ return (
133
+ <PlanApproval
134
+ plan={DEMO_PLAN}
135
+ onDecision={(d) => {
136
+ onComplete()
137
+ onToast({
138
+ message:
139
+ d.kind === 'approve'
140
+ ? 'Plan approved'
141
+ : `Revision requested${d.feedback ? `: ${d.feedback}` : ''}`,
142
+ variant: 'success',
143
+ })
144
+ }}
145
+ />
146
+ )
147
+ case 'ask':
148
+ return (
149
+ <QuestionPrompt
150
+ header="Backend"
151
+ question="Which backend should the app ship next to?"
152
+ options={DEMO_ASK_OPTIONS}
153
+ allowFreeText
154
+ onAnswer={(a) => {
155
+ onComplete()
156
+ onToast({
157
+ message: `Answered: ${a.values.join(', ')}${a.text ? ` (${a.text})` : ''}`,
158
+ variant: 'info',
159
+ })
160
+ }}
161
+ />
162
+ )
163
+ case 'select':
164
+ return (
165
+ <SelectList
166
+ items={DEMO_SELECT_ITEMS}
167
+ multi
168
+ onSubmit={(values) => {
169
+ onComplete()
170
+ onToast({ message: `Selected: ${values.join(', ') || '(none)'}`, variant: 'info' })
171
+ }}
172
+ />
173
+ )
174
+ case 'progress':
175
+ return (
176
+ <ProgressDemo elapsed={elapsed} tokens={tokens} onComplete={onComplete} onToast={onToast} />
177
+ )
178
+ }
179
+ }
@@ -0,0 +1,37 @@
1
+ import { Box } from 'ink'
2
+ import { type ReactElement } from 'react'
3
+ import {
4
+ ContextWindowBar,
5
+ CostMeter,
6
+ type TokenCategory,
7
+ TokenUsageChart,
8
+ type TurnUsage,
9
+ } from '@theokit/tui'
10
+
11
+ /**
12
+ * The `/usage` observability panel, built from the last turn's REAL usage (never fabricated). `ContextWindowBar`
13
+ * shows the context fill (`used/limit`), `TokenUsageChart` the per-category breakdown, `CostMeter` the session
14
+ * cost — the last only when the turn actually reported one. Toggle it with `/usage` in the composer.
15
+ */
16
+ export function UsagePanel({
17
+ usage,
18
+ contextWindow,
19
+ }: {
20
+ usage: TurnUsage
21
+ contextWindow: number
22
+ }): ReactElement {
23
+ // Only PRESENT categories render in the chart — build the map from whatever the turn actually reported.
24
+ const chart: Partial<Record<TokenCategory, number>> = {
25
+ input: usage.inputTokens,
26
+ output: usage.outputTokens,
27
+ ...(usage.cacheReadTokens !== undefined ? { cached: usage.cacheReadTokens } : {}),
28
+ ...(usage.reasoningTokens !== undefined ? { reasoning: usage.reasoningTokens } : {}),
29
+ }
30
+ return (
31
+ <Box flexDirection="column">
32
+ <ContextWindowBar usedTokens={usage.inputTokens} limitTokens={contextWindow} />
33
+ <TokenUsageChart usage={chart} />
34
+ {usage.cost !== undefined ? <CostMeter costUsd={usage.cost} /> : null}
35
+ </Box>
36
+ )
37
+ }