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 +1 -1
- package/templates/surfaces/tui/README-surface.md.tmpl +45 -0
- package/templates/surfaces/tui/tui/App.tsx.tmpl +109 -166
- 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,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
28
|
-
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
/**
|
|
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
|
|
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'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):
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
//
|
|
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 (
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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'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
|
+
}
|