create-theokit 1.2.5 → 1.2.6

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.2.5",
3
+ "version": "1.2.6",
4
4
  "type": "module",
5
5
  "description": "Scaffold a new TheoKit project",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  import { Box, Text, useApp, useInput } from 'ink'
2
- import { useMemo, useState, type ReactElement } from 'react'
2
+ import { useEffect, useState, type ReactElement } from 'react'
3
3
  import {
4
4
  AgentStreaming,
5
5
  AppStatusBar,
@@ -38,11 +38,13 @@ const apiKey = (): string =>
38
38
 
39
39
  /**
40
40
  * M46 — the terminal surface, composed from `@theokit/tui` the way a Claude Code / OpenCode / Codex CLI is:
41
- * a `WelcomeBanner` header, a scrolling `<ChatThread>`, a live `<AgentStreaming>` indicator, the Claude-Code
42
- * bordered `<ChatComposer>`, and a persistent `<AppStatusBar>` footer. The agent is driven by the UNIFIED
43
- * `useAgent` hook (M41). `useAgent` reconstructs only the ASSISTANT turns, so — like the web surface — we
44
- * track the user's turns locally and INTERLEAVE them, then project the whole thread onto `<ChatThread>` via
45
- * the `@theokit/tui/ai-sdk` adapter (so both your prompts and the replies show, in order).
41
+ * `WelcomeBanner` header, scrolling `<ChatThread>`, live `<AgentStreaming>`, bordered `<ChatComposer>`, and a
42
+ * persistent `<AppStatusBar>` footer. Driven by the unified `useAgent` hook (M41).
43
+ *
44
+ * `useAgent` opens a FRESH stream per send — its `messages` hold only the CURRENT turn (and the SDK assigns
45
+ * them no stable id), so we OWN the transcript: `history` accumulates every finished turn with our own unique
46
+ * ids (`u-N` / `a-N`), and the in-flight reply is shown live until it commits. This keeps the order correct,
47
+ * the history complete, and every id unique (ChatThread rejects duplicates).
46
48
  */
47
49
  const transport = new InProcessTransport({
48
50
  run: (input) => streamAgentTurnInProcess(chatAgent, apiKey(), input),
@@ -50,24 +52,35 @@ const transport = new InProcessTransport({
50
52
 
51
53
  export function App(): ReactElement {
52
54
  const agent = useAgent<{ message: string }>(transport)
53
- const [userMessages, setUserMessages] = useState<UIMessage[]>([])
54
55
  const streaming = agent.status === 'streaming'
55
56
  const elapsed = useTurnElapsed(streaming)
56
57
  const { exit } = useApp()
57
58
 
58
- // Interleave local user turns with the reconstructed assistant turns (mirrors the web surface), after the
59
- // opening greeting — so the full conversation history renders, your prompt included.
60
- const thread = useMemo<UIMessage[]>(() => {
61
- const out: UIMessage[] = [GREETING]
62
- const turns = Math.max(userMessages.length, agent.messages.length)
63
- for (let i = 0; i < turns; i++) {
64
- const user = userMessages[i]
65
- const assistant = agent.messages[i]
66
- if (user !== undefined) out.push(user)
67
- if (assistant !== undefined) out.push(assistant)
59
+ const [history, setHistory] = useState<UIMessage[]>([GREETING])
60
+
61
+ const users = history.filter((m) => m.role === 'user').length
62
+ const replies = history.filter((m) => m.role === 'assistant' && m.id !== 'greeting').length
63
+ // A sent prompt is still awaiting its committed reply — the current turn is "in flight".
64
+ const pending = users > replies
65
+
66
+ // Merge the in-flight turn's parts into ONE assistant message with our own unique id (the SDK's ids are
67
+ // empty, which would collide in the thread). `suffix` distinguishes the live copy from the committed one.
68
+ const inflightReply = (suffix: string): UIMessage => ({
69
+ id: `a-${String(replies)}${suffix}`,
70
+ role: 'assistant',
71
+ parts: agent.messages.flatMap((m) => m.parts),
72
+ })
73
+
74
+ // Commit the finished reply into history exactly once (the next send resets `agent.messages`).
75
+ useEffect(() => {
76
+ if (!streaming && pending && agent.messages.length > 0) {
77
+ setHistory((h) => [...h, inflightReply('')])
68
78
  }
69
- return out
70
- }, [userMessages, agent.messages])
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [streaming, pending, agent.messages, replies])
81
+
82
+ // Transcript = committed history + the in-flight reply (shown until it commits — no flicker, no double).
83
+ const thread = pending && agent.messages.length > 0 ? [...history, inflightReply('-live')] : history
71
84
 
72
85
  // Esc cancels a running turn, or quits when idle (the Claude Code affordance).
73
86
  useInput((_input, key) => {
@@ -80,13 +93,13 @@ export function App(): ReactElement {
80
93
  const trimmed = text.trim()
81
94
  if (trimmed.length === 0) return
82
95
  if (trimmed === '/clear') {
83
- setUserMessages([])
96
+ setHistory([GREETING])
84
97
  agent.reset()
85
98
  return
86
99
  }
87
- setUserMessages((prev) => [
88
- ...prev,
89
- { id: `u-${String(prev.length)}`, role: 'user', parts: [{ type: 'text', text: trimmed }] },
100
+ setHistory((h) => [
101
+ ...h,
102
+ { id: `u-${String(h.length)}`, role: 'user', parts: [{ type: 'text', text: trimmed }] },
90
103
  ])
91
104
  agent.send({ message: trimmed })
92
105
  }