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 +1 -1
- package/templates/surfaces/tui/README-surface.md.tmpl +45 -0
- package/templates/surfaces/tui/tui/App.tsx.tmpl +31 -263
- package/templates/surfaces/tui/tui/components/Banner.tsx.tmpl +81 -0
- package/templates/surfaces/tui/tui/components/Demos.tsx.tmpl +179 -0
- package/templates/surfaces/tui/tui/components/UsagePanel.tsx.tmpl +37 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
42
|
-
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
|
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'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):
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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<
|
|
204
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
375
|
-
<
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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:
|
|
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'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
|
+
}
|