create-theokit 1.10.0 → 1.11.1
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 -2
- package/templates/default/agents/chat.ts +1 -4
- package/templates/default/app/hooks/use-transcript.test.ts +36 -10
- package/templates/default/app/hooks/use-transcript.ts +11 -60
- package/templates/default/package.json.tmpl +2 -2
- package/templates/surfaces/desktop/frontend/src/App.tsx.tmpl +5 -32
- package/templates/surfaces/tui/tui/App.tsx.tmpl +6 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-theokit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold a new TheoKit project",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"cross-spawn": "^7.0.6"
|
|
16
16
|
},
|
|
17
17
|
"publishConfig": {
|
|
18
|
-
"provenance": true,
|
|
19
18
|
"access": "public"
|
|
20
19
|
},
|
|
21
20
|
"engines": {
|
|
@@ -24,9 +24,6 @@ export default agent()
|
|
|
24
24
|
.system(BASE_INSTRUCTIONS)
|
|
25
25
|
.tool(weatherTool)
|
|
26
26
|
.tool(currentTimeTool)
|
|
27
|
-
//
|
|
28
|
-
// `<skills>` block every turn (cheap, so the model KNOWS it exists) AND auto-provisions a `skill_read`
|
|
29
|
-
// tool the model calls to load the full body on demand (so a long procedure only enters the prompt
|
|
30
|
-
// when needed). Pass a filesystem skill NAME (a string) here too; mix strings + createSkill freely.
|
|
27
|
+
// Skills the agent can consult on demand (hover `.skills` for how it works).
|
|
31
28
|
.skills([dailyBriefingSkill])
|
|
32
29
|
.build()
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { act, renderHook } from '@testing-library/react'
|
|
3
3
|
import { describe, it, expect, vi } from 'vitest'
|
|
4
|
+
import type { UIMessage } from 'ai'
|
|
4
5
|
|
|
5
|
-
// Mock the agent client
|
|
6
|
+
// Mock the agent client. Since M46 the store owns the conversation `thread`; the hook is a thin
|
|
7
|
+
// projection, so we drive the mock's `thread`/`status` and assert the hook maps + delegates correctly.
|
|
6
8
|
const send = vi.fn()
|
|
7
9
|
const resetAgent = vi.fn()
|
|
10
|
+
let agentThread: UIMessage[] = []
|
|
11
|
+
let agentStatus = 'idle'
|
|
8
12
|
vi.mock('theokit/client', () => ({
|
|
9
|
-
useAgent: () => ({
|
|
13
|
+
useAgent: () => ({
|
|
14
|
+
thread: agentThread,
|
|
15
|
+
send,
|
|
16
|
+
status: agentStatus,
|
|
17
|
+
reset: resetAgent,
|
|
18
|
+
error: undefined,
|
|
19
|
+
}),
|
|
10
20
|
}))
|
|
11
21
|
|
|
12
22
|
const { useChatTranscript } = await import('./use-transcript')
|
|
@@ -17,29 +27,45 @@ function textOf(part: unknown): string {
|
|
|
17
27
|
|
|
18
28
|
describe('useChatTranscript', () => {
|
|
19
29
|
it('starts warm — the thread is just the greeting, and onlyGreeting is true', () => {
|
|
30
|
+
agentThread = []
|
|
31
|
+
agentStatus = 'idle'
|
|
20
32
|
const { result } = renderHook(() => useChatTranscript())
|
|
21
33
|
expect(result.current.thread).toHaveLength(1)
|
|
22
34
|
expect(result.current.thread[0].id).toBe('greeting')
|
|
23
35
|
expect(result.current.onlyGreeting).toBe(true)
|
|
24
36
|
})
|
|
25
37
|
|
|
26
|
-
it('sendMessage
|
|
38
|
+
it('sendMessage delegates to the store (which owns appending the user turn)', () => {
|
|
39
|
+
agentThread = []
|
|
40
|
+
agentStatus = 'idle'
|
|
27
41
|
const { result } = renderHook(() => useChatTranscript())
|
|
28
42
|
act(() => result.current.sendMessage('hello'))
|
|
29
|
-
const last = result.current.thread[result.current.thread.length - 1]
|
|
30
|
-
expect(last.role).toBe('user')
|
|
31
|
-
expect(textOf(last.parts[0])).toBe('hello')
|
|
32
43
|
expect(send).toHaveBeenCalledWith({ message: 'hello' })
|
|
33
|
-
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('projects the store thread after the greeting; onlyGreeting is false once a turn exists', () => {
|
|
47
|
+
agentThread = [
|
|
48
|
+
{ id: 'u-0', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
|
49
|
+
{ id: 'a-0', role: 'assistant', parts: [{ type: 'text', text: 'hello there' }] },
|
|
50
|
+
]
|
|
51
|
+
agentStatus = 'done'
|
|
52
|
+
const { result } = renderHook(() => useChatTranscript())
|
|
53
|
+
expect(result.current.thread).toHaveLength(3) // greeting + user + assistant
|
|
54
|
+
expect(result.current.thread[0].id).toBe('greeting')
|
|
55
|
+
const last = result.current.thread[2]
|
|
56
|
+
expect(last.role).toBe('assistant')
|
|
57
|
+
expect(textOf(last.parts[0])).toBe('hello there')
|
|
34
58
|
expect(result.current.onlyGreeting).toBe(false)
|
|
35
59
|
})
|
|
36
60
|
|
|
37
|
-
it('reset
|
|
61
|
+
it('reset delegates to the store reset and the transcript returns to the greeting', () => {
|
|
62
|
+
// After reset the store yields an empty thread; the hook must project back to just the greeting.
|
|
63
|
+
agentThread = []
|
|
64
|
+
agentStatus = 'idle'
|
|
38
65
|
const { result } = renderHook(() => useChatTranscript())
|
|
39
|
-
act(() => result.current.sendMessage('hi'))
|
|
40
66
|
act(() => result.current.reset())
|
|
67
|
+
expect(resetAgent).toHaveBeenCalled()
|
|
41
68
|
expect(result.current.thread).toHaveLength(1)
|
|
42
69
|
expect(result.current.thread[0].id).toBe('greeting')
|
|
43
|
-
expect(resetAgent).toHaveBeenCalled()
|
|
44
70
|
})
|
|
45
71
|
})
|
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { type UIMessage } from '@theokit/ui'
|
|
4
|
-
import { useEffect, useState } from 'react'
|
|
5
4
|
import { useAgent } from 'theokit/client'
|
|
6
5
|
|
|
7
6
|
import { GREETING } from '../lib/constants'
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
|
-
* Owns the chat transcript
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* Why a hook is needed: `useAgent` opens a FRESH stream per send, so its `messages` hold only the CURRENT
|
|
15
|
-
* turn (with no stable id). We OWN the full transcript here — `history` accumulates each finished turn with
|
|
16
|
-
* our own unique ids, and the in-flight reply is shown live until it commits: correct order, complete
|
|
17
|
-
* history, unique keys.
|
|
9
|
+
* Owns the chat transcript for the view. Since M46, the framework's client store accumulates the full
|
|
10
|
+
* conversation `thread` (committed turns + the in-flight streaming reply, with stable ids), so this hook
|
|
11
|
+
* is a thin projection: prepend the warm greeting and map status to the flags the UI reads. The 88-line
|
|
12
|
+
* hand-rolled transcript (local history + commit-once effect + inflight-merge) is gone — the store owns it.
|
|
18
13
|
*/
|
|
19
14
|
export interface ChatTranscript {
|
|
20
|
-
/** The full transcript to render (committed history + the in-flight reply while streaming). */
|
|
15
|
+
/** The full transcript to render (greeting + committed history + the in-flight reply while streaming). */
|
|
21
16
|
thread: UIMessage[]
|
|
22
17
|
isStreaming: boolean
|
|
23
18
|
hasError: boolean
|
|
@@ -31,58 +26,14 @@ export interface ChatTranscript {
|
|
|
31
26
|
}
|
|
32
27
|
|
|
33
28
|
export function useChatTranscript(): ChatTranscript {
|
|
34
|
-
const
|
|
35
|
-
const {
|
|
36
|
-
messages,
|
|
37
|
-
send,
|
|
38
|
-
status,
|
|
39
|
-
reset: resetAgent,
|
|
40
|
-
error,
|
|
41
|
-
} = useAgent<{ message: string }>('/api/agents/chat')
|
|
42
|
-
|
|
43
|
-
const isStreaming = status === 'streaming'
|
|
44
|
-
const users = history.filter((m) => m.role === 'user').length
|
|
45
|
-
const replies = history.filter((m) => m.role === 'assistant' && m.id !== 'greeting').length
|
|
46
|
-
// A sent prompt is still awaiting its committed reply — the current turn is "in flight".
|
|
47
|
-
const pending = users > replies
|
|
48
|
-
|
|
49
|
-
// Merge the in-flight turn's parts into ONE assistant message with our own unique id (the SDK's ids are
|
|
50
|
-
// empty, which would collide). `suffix` distinguishes the live copy from the committed one.
|
|
51
|
-
const inflightReply = (suffix: string): UIMessage => ({
|
|
52
|
-
id: `a-${String(replies)}${suffix}`,
|
|
53
|
-
role: 'assistant',
|
|
54
|
-
parts: messages.flatMap((m) => m.parts),
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
// Commit the finished reply into history exactly once (the next send resets `messages`). Deps are the
|
|
58
|
-
// stream-transition inputs; `inflightReply` is deliberately NOT a dep — it's a fresh closure each render
|
|
59
|
-
// that would re-fire the effect, and it only reads `messages`/`replies`, which ARE deps. (If you enable
|
|
60
|
-
// the `react-hooks/exhaustive-deps` lint rule, add an eslint-disable for this line.)
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
if (!isStreaming && pending && messages.length > 0) {
|
|
63
|
-
setHistory((h) => [...h, inflightReply('')])
|
|
64
|
-
}
|
|
65
|
-
}, [isStreaming, pending, messages, replies])
|
|
66
|
-
|
|
67
|
-
// Transcript = committed history + the in-flight reply (shown until it commits — no flicker, no double).
|
|
68
|
-
const thread = pending && messages.length > 0 ? [...history, inflightReply('-live')] : history
|
|
69
|
-
|
|
29
|
+
const { thread, send, status, reset, error } = useAgent<{ message: string }>('/api/agents/chat')
|
|
70
30
|
return {
|
|
71
|
-
thread,
|
|
72
|
-
isStreaming,
|
|
31
|
+
thread: [GREETING, ...thread],
|
|
32
|
+
isStreaming: status === 'streaming',
|
|
73
33
|
hasError: status === 'error',
|
|
74
34
|
error,
|
|
75
|
-
onlyGreeting:
|
|
76
|
-
sendMessage(text) {
|
|
77
|
-
|
|
78
|
-
...h,
|
|
79
|
-
{ id: `u-${String(h.length)}`, role: 'user', parts: [{ type: 'text', text }] },
|
|
80
|
-
])
|
|
81
|
-
send({ message: text })
|
|
82
|
-
},
|
|
83
|
-
reset() {
|
|
84
|
-
setHistory([GREETING])
|
|
85
|
-
resetAgent()
|
|
86
|
-
},
|
|
35
|
+
onlyGreeting: thread.length === 0 && status !== 'streaming',
|
|
36
|
+
sendMessage: (text) => send({ message: text }),
|
|
37
|
+
reset,
|
|
87
38
|
}
|
|
88
39
|
}
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"typecheck": "tsc --noEmit"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"theokit": "^0.
|
|
19
|
-
"@theokit/agents": "^0.38.
|
|
18
|
+
"theokit": "^0.39.1",
|
|
19
|
+
"@theokit/agents": "^0.38.1",
|
|
20
20
|
"@theokit/sdk": "^2.25.0",
|
|
21
21
|
"@theokit/ui": "^1.0.0",
|
|
22
22
|
"@usetheo/ui": "^0.14.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState, type CSSProperties, type ReactElement } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
ChatThread,
|
|
4
4
|
ChatMessage,
|
|
@@ -92,50 +92,23 @@ const styles = {
|
|
|
92
92
|
|
|
93
93
|
export function App(): ReactElement {
|
|
94
94
|
const [composerValue, setComposerValue] = useState('')
|
|
95
|
-
const
|
|
96
|
-
const { messages, send, status, reset, error } = useAgent<{ message: string }>(transport)
|
|
95
|
+
const { thread: agentThread, send, status, reset, error } = useAgent<{ message: string }>(transport)
|
|
97
96
|
|
|
98
97
|
const isStreaming = status === 'streaming'
|
|
99
98
|
const hasError = status === 'error'
|
|
100
99
|
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
const pending = users > replies
|
|
105
|
-
|
|
106
|
-
// Merge the in-flight turn's parts into ONE assistant message with our own unique id (the SDK's ids are
|
|
107
|
-
// empty, which would collide). `suffix` distinguishes the live copy from the committed one.
|
|
108
|
-
const inflightReply = (suffix: string): UIMessage => ({
|
|
109
|
-
id: `a-${String(replies)}${suffix}`,
|
|
110
|
-
role: 'assistant',
|
|
111
|
-
parts: messages.flatMap((m) => m.parts),
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
// Commit the finished reply into history exactly once (the next send resets `messages`).
|
|
115
|
-
useEffect(() => {
|
|
116
|
-
if (!isStreaming && pending && messages.length > 0) {
|
|
117
|
-
setHistory((h) => [...h, inflightReply('')])
|
|
118
|
-
}
|
|
119
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
-
}, [isStreaming, pending, messages, replies])
|
|
121
|
-
|
|
122
|
-
// Transcript = committed history + the in-flight reply (shown until it commits — no flicker, no double).
|
|
123
|
-
const thread = pending && messages.length > 0 ? [...history, inflightReply('-live')] : history
|
|
124
|
-
const onlyGreeting = history.length === 1 && !isStreaming
|
|
100
|
+
// The store owns the conversation (M46) — prepend the warm greeting and render.
|
|
101
|
+
const thread = [GREETING, ...agentThread]
|
|
102
|
+
const onlyGreeting = agentThread.length === 0 && !isStreaming
|
|
125
103
|
|
|
126
104
|
function handleSubmit(value: string): void {
|
|
127
105
|
const trimmed = value.trim()
|
|
128
106
|
if (trimmed.length === 0) return
|
|
129
|
-
setHistory((h) => [
|
|
130
|
-
...h,
|
|
131
|
-
{ id: `u-${String(h.length)}`, role: 'user', parts: [{ type: 'text', text: trimmed }] },
|
|
132
|
-
])
|
|
133
107
|
send({ message: trimmed })
|
|
134
108
|
setComposerValue('')
|
|
135
109
|
}
|
|
136
110
|
|
|
137
111
|
function newChat(): void {
|
|
138
|
-
setHistory([GREETING])
|
|
139
112
|
reset()
|
|
140
113
|
setComposerValue('')
|
|
141
114
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Box, Text, useApp, useInput } from 'ink'
|
|
2
|
-
import {
|
|
2
|
+
import { type ReactElement } from 'react'
|
|
3
3
|
import {
|
|
4
4
|
AgentStreaming,
|
|
5
5
|
AppStatusBar,
|
|
@@ -42,10 +42,9 @@ const apiKey = (): string =>
|
|
|
42
42
|
* `WelcomeBanner` header, scrolling `<ChatThread>`, live `<AgentStreaming>`, bordered `<ChatComposer>`, and a
|
|
43
43
|
* persistent `<AppStatusBar>` footer. Driven by the unified `useAgent` hook (M41).
|
|
44
44
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* the history complete, and every id unique (ChatThread rejects duplicates).
|
|
45
|
+
* The full conversation comes straight from `useAgent().thread` (M46): the client store accumulates every
|
|
46
|
+
* finished turn plus the in-flight streaming reply, with stable ids — so no hand-rolled history/commit-once
|
|
47
|
+
* here. Prepend the warm `GREETING` and render.
|
|
49
48
|
*/
|
|
50
49
|
const transport = new InProcessTransport({
|
|
51
50
|
run: (input) => streamAgentTurnInProcess(chatAgent, apiKey(), input),
|
|
@@ -57,31 +56,8 @@ export function App(): ReactElement {
|
|
|
57
56
|
const elapsed = useTurnElapsed(streaming)
|
|
58
57
|
const { exit } = useApp()
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const users = history.filter((m) => m.role === 'user').length
|
|
63
|
-
const replies = history.filter((m) => m.role === 'assistant' && m.id !== 'greeting').length
|
|
64
|
-
// A sent prompt is still awaiting its committed reply — the current turn is "in flight".
|
|
65
|
-
const pending = users > replies
|
|
66
|
-
|
|
67
|
-
// Merge the in-flight turn's parts into ONE assistant message with our own unique id (the SDK's ids are
|
|
68
|
-
// empty, which would collide in the thread). `suffix` distinguishes the live copy from the committed one.
|
|
69
|
-
const inflightReply = (suffix: string): UIMessage => ({
|
|
70
|
-
id: `a-${String(replies)}${suffix}`,
|
|
71
|
-
role: 'assistant',
|
|
72
|
-
parts: agent.messages.flatMap((m) => m.parts),
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
// Commit the finished reply into history exactly once (the next send resets `agent.messages`).
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
if (!streaming && pending && agent.messages.length > 0) {
|
|
78
|
-
setHistory((h) => [...h, inflightReply('')])
|
|
79
|
-
}
|
|
80
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
|
-
}, [streaming, pending, agent.messages, replies])
|
|
82
|
-
|
|
83
|
-
// Transcript = committed history + the in-flight reply (shown until it commits — no flicker, no double).
|
|
84
|
-
const thread = pending && agent.messages.length > 0 ? [...history, inflightReply('-live')] : history
|
|
59
|
+
// The store owns the conversation (M46) — prepend the warm greeting and render.
|
|
60
|
+
const thread = [GREETING, ...agent.thread]
|
|
85
61
|
|
|
86
62
|
// Esc cancels a running turn, or quits when idle (the Claude Code affordance).
|
|
87
63
|
useInput((_input, key) => {
|
|
@@ -94,14 +70,9 @@ export function App(): ReactElement {
|
|
|
94
70
|
const trimmed = text.trim()
|
|
95
71
|
if (trimmed.length === 0) return
|
|
96
72
|
if (trimmed === '/clear') {
|
|
97
|
-
setHistory([GREETING])
|
|
98
73
|
agent.reset()
|
|
99
74
|
return
|
|
100
75
|
}
|
|
101
|
-
setHistory((h) => [
|
|
102
|
-
...h,
|
|
103
|
-
{ id: `u-${String(h.length)}`, role: 'user', parts: [{ type: 'text', text: trimmed }] },
|
|
104
|
-
])
|
|
105
76
|
agent.send({ message: trimmed })
|
|
106
77
|
}
|
|
107
78
|
|