create-theokit 1.21.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.21.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,7 +1,5 @@
1
- import { homedir } from 'node:os'
2
-
3
- import { Box, Text, useApp, useInput } from 'ink'
4
- import { type ReactElement, 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,
@@ -14,8 +12,10 @@ import {
14
12
  Notice,
15
13
  PermissionPrompt,
16
14
  readTurnUsage,
15
+ Stack,
17
16
  StatusFooter,
18
17
  TheoTUIProvider,
18
+ Toast,
19
19
  useTurnElapsed,
20
20
  type UIMessageLike,
21
21
  } from '@theokit/tui'
@@ -24,23 +24,14 @@ import { streamAgentTurnInProcess } from 'theokit/server/agent'
24
24
 
25
25
  import * as chatAgent from '../agents/chat.js'
26
26
  import { AGENT } from '../shared/agent.js'
27
- // Everything visual (accent, banner wordmark + copy, spinner words, placeholder) lives in ONE file
28
- // edit `tui/theme.ts` to restyle; this component never needs touching for a rebrand.
29
- import {
30
- ACCENT,
31
- BANNER_TIPS,
32
- BANNER_WHATS_NEW,
33
- LOGO,
34
- PLACEHOLDER,
35
- THEME,
36
- THINKING_PHRASES,
37
- WIDE_COLS,
38
- } 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'
39
32
 
40
- /** Shown in the status bar (from `shared/agent.ts`). */
33
+ /** Model label (footer) from `shared/agent.ts`, the single branding source. */
41
34
  const MODEL = AGENT.model
42
- /** cwd with the home dir tildeified (`~/…`), so the banner line stays short. */
43
- const CWD = process.cwd().replace(homedir(), '~')
44
35
  /** Compact token count for the footer: `128000 → 128k`, `12300 → 12.3k`, `462 → 462`. */
45
36
  const fmtK = (n: number): string =>
46
37
  n >= 1000 ? `${(Math.round(n / 100) / 10).toString().replace(/\.0$/, '')}k` : `${n}`
@@ -56,113 +47,32 @@ const GREETING: UIMessageLike = {
56
47
  const apiKey = (): string =>
57
48
  process.env.OPENROUTER_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? process.env.OPENAI_API_KEY ?? ''
58
49
 
59
- /**
60
- * The terminal surface, composed from `@theokit/tui` the way a Claude Code / OpenCode / Codex CLI is:
61
- * `WelcomeBanner` header, a scrolling `<AgentTimeline>` (assistant turns rendered as Markdown + fenced code,
62
- * 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`
64
- * hook (M41).
65
- *
66
- * The conversation comes from `useAgent().thread` (M46), projected to the timeline's `AgentEvent[]` by the
67
- * ai-free `messagesToAgentEvents` — so this terminal surface never imports the `ai` SDK. Prepend the greeting.
68
- */
50
+ // The unified in-process transport (M41): each turn drives the agent through `streamAgentTurnInProcess`.
69
51
  const transport = new InProcessTransport({
70
52
  run: (input) => streamAgentTurnInProcess(chatAgent, apiKey(), input),
71
53
  })
72
54
 
73
- /** The app name `{{name}}` is substituted at scaffold time. */
74
- const APP_NAME = '{{name}}'
75
-
76
- /**
77
- * The Claude-Code welcome box: a full-width rounded accent border with margins on every side. On a wide
78
- * terminal it lays out two columns — left is the `Theo` wordmark + a `✻` welcome line + model + cwd; right
79
- * is the getting-started tips + what's new. Below `WIDE_COLS` it collapses to a single column.
80
- */
81
- function Banner(): ReactElement {
82
- const cols = process.stdout.columns ?? 80
83
- const wide = cols >= WIDE_COLS
84
- return (
85
- <Box
86
- // Full width, with a one-cell margin on every side.
87
- width={cols - 2}
88
- marginX={1}
89
- marginY={1}
90
- paddingX={2}
91
- paddingY={1}
92
- borderStyle="round"
93
- borderColor={ACCENT}
94
- flexDirection="row"
95
- >
96
- {/* Fixed-width left column (fits the 34-wide wordmark) so a long cwd truncates instead of pushing
97
- the right column off-screen — the box stays full width, the content stays grouped on the left. */}
98
- <Box flexDirection="column" width={38} flexShrink={0}>
99
- <Text color={ACCENT}>{LOGO}</Text>
100
- <Box marginTop={1} flexDirection="column">
101
- <Text color={ACCENT} bold wrap="truncate-end">
102
- ✻ Welcome to {APP_NAME}
103
- </Text>
104
- <Text dimColor wrap="truncate-end">
105
- {MODEL}
106
- </Text>
107
- <Text dimColor wrap="truncate-start">
108
- cwd: {CWD}
109
- </Text>
110
- </Box>
111
- </Box>
112
- {wide ? (
113
- <Box flexDirection="column" flexShrink={0} marginLeft={4}>
114
- <Text color={ACCENT} bold>
115
- Tips for getting started
116
- </Text>
117
- <Box marginTop={1} flexDirection="column">
118
- {BANNER_TIPS.map((tip) => (
119
- <Text key={tip} dimColor>
120
- {tip}
121
- </Text>
122
- ))}
123
- </Box>
124
- <Box marginTop={1} flexDirection="column">
125
- <Text color={ACCENT} bold>
126
- What&apos;s new
127
- </Text>
128
- <Box marginTop={1} flexDirection="column">
129
- {BANNER_WHATS_NEW.map((line) => (
130
- <Text key={line} dimColor>
131
- {line}
132
- </Text>
133
- ))}
134
- </Box>
135
- </Box>
136
- </Box>
137
- ) : null}
138
- </Box>
139
- )
140
- }
141
-
55
+ /** The surface's composition root: owns state (`useAgent` M41 + mode) and composes the components under `<Stack>`. */
142
56
  export function App(): ReactElement {
143
57
  const agent = useAgent<{ message: string }>(transport)
144
58
  const streaming = agent.status === 'streaming'
145
59
  const elapsed = useTurnElapsed(streaming)
146
60
  const { exit } = useApp()
147
61
  const [showHelp, setShowHelp] = useState(false)
148
- // Ctrl+C is a two-step quit (Claude Code): the first press arms the exit + shows a hint, the second
149
- // 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.
150
63
  const [exitArmed, setExitArmed] = useState(false)
151
- // Approval ids already decided — the `approval-requested` part is a STANDALONE gate that never flips to
152
- // a resolved state (it is keyed by its own id, distinct from the tool call), so we remember settled ids
153
- // 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.
154
65
  const [settledApprovals, setSettledApprovals] = useState<readonly string[]>([])
155
-
156
- // The store owns the conversation (M46) — prepend the warm greeting and project to timeline events.
66
+ // Composer mode (`chat` = composer; others swap it for a `<DemoSurface>`), `/usage` toggle, `<Toast>`.
67
+ const [mode, setMode] = useState<Mode>('chat')
68
+ const [showUsage, setShowUsage] = useState(false)
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'), [])
72
+
73
+ // Derived from the store (M46): timeline events (greeting prepended), the last turn's usage, HITL approval.
157
74
  const events = messagesToAgentEvents([GREETING, ...agent.thread])
158
-
159
- // 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.
162
75
  const lastUsage = agent.thread.map(readTurnUsage).filter((u) => u !== undefined).at(-1)
163
-
164
- // Human-in-the-loop: a gated tool (`send_notification`) pauses the run awaiting a decision. When one is
165
- // pending (and not already settled) we show the approval prompt IN PLACE OF the composer and settle it.
166
76
  const rawApproval = findPendingApproval(agent.thread)
167
77
  const pendingApproval =
168
78
  rawApproval && !settledApprovals.includes(rawApproval.approvalId) ? rawApproval : undefined
@@ -172,13 +82,15 @@ export function App(): ReactElement {
172
82
  void agent.approve(approvalId, { approved })
173
83
  }
174
84
 
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).
85
+ const inDemoInput = mode === 'plan' || mode === 'ask' || mode === 'select'
86
+
87
+ // Global keys skipped while a gated tool / interactive demo owns input. Esc backs out; Ctrl+C cancels/quits.
178
88
  useInput((input, key) => {
179
- if (pendingApproval) return
89
+ if (pendingApproval || inDemoInput) return
180
90
  if (key.escape) {
181
- if (showHelp) setShowHelp(false)
91
+ if (mode === 'progress') setMode('chat')
92
+ else if (showUsage) setShowUsage(false)
93
+ else if (showHelp) setShowHelp(false)
182
94
  else if (streaming) agent.abort()
183
95
  return
184
96
  }
@@ -197,43 +109,65 @@ export function App(): ReactElement {
197
109
  const handleSubmit = (text: string): void => {
198
110
  const trimmed = text.trim()
199
111
  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
112
+ switch (trimmed) {
113
+ case '/clear':
114
+ agent.reset()
115
+ return
116
+ case '/help':
117
+ setShowHelp((h) => !h)
118
+ return
119
+ case '/usage':
120
+ setShowUsage((u) => !u)
121
+ return
122
+ case '/plan':
123
+ setMode('plan')
124
+ return
125
+ case '/ask':
126
+ setMode('ask')
127
+ return
128
+ case '/select':
129
+ setMode('select')
130
+ return
131
+ case '/progress':
132
+ setMode('progress')
133
+ return
134
+ default:
135
+ agent.send({ message: trimmed })
207
136
  }
208
- agent.send({ message: trimmed })
209
137
  }
210
138
 
211
139
  return (
212
140
  <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. */}
141
+ {/* InkInputProvider bridges Ink stdin to the interactive surfaces; `Stack` gives the Claude-Code cadence. */}
215
142
  <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}
232
-
233
- {showHelp ? <KeyboardHelp shortcuts={DEFAULT_COMPOSER_SHORTCUTS} /> : null}
234
-
235
- {/* A one-line top margin separates the input (or the approval prompt) from the conversation above. */}
236
- <Box marginTop={1} flexDirection="column">
143
+ <Stack gap={1}>
144
+ <Banner />
145
+
146
+ <AgentTimeline events={events} />
147
+
148
+ {streaming ? (
149
+ <AgentStreaming
150
+ phrases={THINKING_PHRASES}
151
+ shimmer
152
+ elapsedSeconds={elapsed}
153
+ tokens={lastUsage?.totalTokens}
154
+ tokenDirection="down"
155
+ showCancelHint
156
+ />
157
+ ) : null}
158
+ {agent.error ? <Notice variant="error">{agent.error.message}</Notice> : null}
159
+ {/* `/usage` — the observability panel, from the last turn's real usage. */}
160
+ {showUsage && lastUsage ? (
161
+ <UsagePanel usage={lastUsage} contextWindow={AGENT.contextWindow} />
162
+ ) : null}
163
+ {/* A transient outcome banner (a demo answered, a task finished). Auto-dismisses after 5s. */}
164
+ {toast ? (
165
+ <Toast message={toast.message} variant={toast.variant} onDismiss={() => setToast(null)} />
166
+ ) : null}
167
+
168
+ {showHelp ? <KeyboardHelp shortcuts={DEFAULT_COMPOSER_SHORTCUTS} /> : null}
169
+
170
+ {/* The input area: a gated tool's approval card, a demo surface, or the composer. */}
237
171
  {pendingApproval ? (
238
172
  <PermissionPrompt
239
173
  toolType="Tool call"
@@ -244,12 +178,18 @@ export function App(): ReactElement {
244
178
  : undefined
245
179
  }
246
180
  onDecision={(decision) => {
247
- // PermissionPrompt's default choices are `yes`/`no` (Esc → the last one, `no`, the safe
248
- // default). Only `yes` approves. Settle (remembering the id so the standalone gate part
249
- // stops re-showing) and forward the decision to the client.
181
+ // Default choices are `yes`/`no` (Esc → `no`, the safe default); only `yes` approves.
250
182
  settleApproval(pendingApproval.approvalId, decision === 'yes')
251
183
  }}
252
184
  />
185
+ ) : mode !== 'chat' ? (
186
+ <DemoSurface
187
+ mode={mode}
188
+ elapsed={elapsed}
189
+ tokens={lastUsage?.totalTokens}
190
+ onComplete={backToChat}
191
+ onToast={setToast}
192
+ />
253
193
  ) : (
254
194
  <ChatComposer
255
195
  placeholder={PLACEHOLDER}
@@ -258,27 +198,30 @@ export function App(): ReactElement {
258
198
  commands={[
259
199
  { name: 'clear', description: 'clear the conversation' },
260
200
  { name: 'help', description: 'toggle the keyboard shortcuts panel' },
201
+ { name: 'usage', description: 'toggle the token-usage panel' },
202
+ { name: 'plan', description: 'demo: plan approval card' },
203
+ { name: 'ask', description: 'demo: question prompt' },
204
+ { name: 'select', description: 'demo: multi-select list' },
205
+ { name: 'progress', description: 'demo: multi-step progress' },
261
206
  ]}
262
207
  onHelpToggle={() => setShowHelp((h) => !h)}
263
208
  onSubmit={handleSubmit}
264
209
  />
265
210
  )}
266
- </Box>
267
211
 
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>
212
+ {/* Claude Code's two-line footer: model (left) · context usage (right) · shortcuts hint (bottom). */}
213
+ <StatusFooter
214
+ left={<Text>{MODEL}</Text>}
215
+ right={
216
+ lastUsage ? (
217
+ <Text>
218
+ {fmtK(lastUsage.inputTokens)}/{fmtK(AGENT.contextWindow)} context
219
+ </Text>
220
+ ) : undefined
221
+ }
222
+ hint="? for shortcuts"
223
+ />
224
+ </Stack>
282
225
  </InkInputProvider>
283
226
  </TheoTUIProvider>
284
227
  )
@@ -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
+ }