camelagi 0.5.49 → 0.5.51
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/dist/cli/cmd-chat.js +69 -2
- package/dist/cli/cmd-chat.js.map +1 -1
- package/dist/core/version.js +1 -1
- package/dist/runtime/orchestrate.js +1 -1
- package/dist/telegram/admin-bot.js +1 -1
- package/package.json +5 -2
- package/tui/package.json +23 -0
- package/tui/src/App.tsx +161 -0
- package/tui/src/agent/parse.ts +184 -0
- package/tui/src/agent/types.ts +75 -0
- package/tui/src/commands/registry.ts +301 -0
- package/tui/src/components/ActivityIndicator.tsx +148 -0
- package/tui/src/components/ApprovalPrompt.tsx +58 -0
- package/tui/src/components/BottomBar.tsx +74 -0
- package/tui/src/components/Chat.tsx +98 -0
- package/tui/src/components/Divider.tsx +12 -0
- package/tui/src/components/HorizontalRule.tsx +12 -0
- package/tui/src/components/Input.tsx +126 -0
- package/tui/src/components/Markdown.tsx +290 -0
- package/tui/src/components/Message.tsx +77 -0
- package/tui/src/components/PermissionBanner.tsx +30 -0
- package/tui/src/components/Picker.tsx +127 -0
- package/tui/src/components/SlashMenu.tsx +46 -0
- package/tui/src/components/SubagentBlock.tsx +16 -0
- package/tui/src/components/ToolBlock.tsx +24 -0
- package/tui/src/components/Welcome.tsx +75 -0
- package/tui/src/components/tools/BashTool.tsx +27 -0
- package/tui/src/components/tools/DefaultTool.tsx +38 -0
- package/tui/src/components/tools/DiffView.tsx +91 -0
- package/tui/src/components/tools/EditGroup.tsx +97 -0
- package/tui/src/components/tools/EditTool.tsx +41 -0
- package/tui/src/components/tools/ReadTool.tsx +41 -0
- package/tui/src/components/tools/SearchTool.tsx +27 -0
- package/tui/src/components/tools/ToolHeader.tsx +48 -0
- package/tui/src/components/tools/WriteTool.tsx +54 -0
- package/tui/src/config.ts +6 -0
- package/tui/src/hooks/useAgent.ts +202 -0
- package/tui/src/main.tsx +12 -0
- package/tui/src/models.ts +26 -0
- package/tui/src/state/reducer.ts +290 -0
- package/tui/src/theme.ts +28 -0
- package/tui/src/util/nativeNotify.ts +47 -0
- package/tui/src/util/spinner.ts +11 -0
- package/tui/tsconfig.json +19 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// WebSocket-based agent hook. Connects to CamelAGI gateway and maps events
|
|
2
|
+
// into the same reducer dispatch calls the original spawn-based hook used.
|
|
3
|
+
|
|
4
|
+
import { useCallback, useEffect, useReducer, useRef } from "react"
|
|
5
|
+
import { parseEvent } from "../agent/parse.js"
|
|
6
|
+
import type { ApprovalBehavior, PermissionMode } from "../agent/types.js"
|
|
7
|
+
import { initialState, reduce } from "../state/reducer.js"
|
|
8
|
+
import { WS_URL } from "../config.js"
|
|
9
|
+
|
|
10
|
+
export interface UseAgentOptions {
|
|
11
|
+
model: string
|
|
12
|
+
effort?: string
|
|
13
|
+
cwd: string
|
|
14
|
+
wsUrl?: string
|
|
15
|
+
token?: string
|
|
16
|
+
sessionId?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useAgent(opts: UseAgentOptions) {
|
|
20
|
+
const [state, dispatch] = useReducer(reduce, initialState)
|
|
21
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
22
|
+
const runningRef = useRef(false)
|
|
23
|
+
const optsRef = useRef(opts)
|
|
24
|
+
optsRef.current = opts
|
|
25
|
+
const sessionIdRef = useRef(opts.sessionId ?? `tui-${Date.now()}`)
|
|
26
|
+
|
|
27
|
+
// Connect WebSocket on mount
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const url = opts.wsUrl ?? WS_URL
|
|
30
|
+
const fullUrl = opts.token ? `${url}?token=${opts.token}` : url
|
|
31
|
+
const ws = new WebSocket(fullUrl)
|
|
32
|
+
wsRef.current = ws
|
|
33
|
+
|
|
34
|
+
ws.addEventListener("open", () => {
|
|
35
|
+
dispatch({ type: "system_note", text: "Connected to gateway" })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
ws.addEventListener("message", (event) => {
|
|
39
|
+
let msg: Record<string, unknown>
|
|
40
|
+
try {
|
|
41
|
+
msg = JSON.parse(String(event.data))
|
|
42
|
+
} catch {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const type = msg.type as string
|
|
47
|
+
|
|
48
|
+
// Gateway-specific messages that aren't standard agent events
|
|
49
|
+
if (type === "done") {
|
|
50
|
+
const usage = msg.usage as Record<string, unknown> | undefined
|
|
51
|
+
dispatch({
|
|
52
|
+
type: "agent_event",
|
|
53
|
+
event: {
|
|
54
|
+
type: "done",
|
|
55
|
+
response: String(msg.response ?? ""),
|
|
56
|
+
usage: usage ? {
|
|
57
|
+
inputTokens: Number(usage.inputTokens ?? 0),
|
|
58
|
+
outputTokens: Number(usage.outputTokens ?? 0),
|
|
59
|
+
cacheReadTokens: Number(usage.cacheReadTokens ?? 0),
|
|
60
|
+
cacheWriteTokens: Number(usage.cacheWriteTokens ?? 0),
|
|
61
|
+
} : undefined,
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
runningRef.current = false
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (type === "error") {
|
|
69
|
+
dispatch({
|
|
70
|
+
type: "agent_event",
|
|
71
|
+
event: { type: "error", message: String(msg.message ?? "Unknown error") },
|
|
72
|
+
})
|
|
73
|
+
runningRef.current = false
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (type === "aborted") {
|
|
78
|
+
dispatch({ type: "abort" })
|
|
79
|
+
runningRef.current = false
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (type === "compacted") {
|
|
84
|
+
dispatch({
|
|
85
|
+
type: "system_note",
|
|
86
|
+
text: `History compacted: ${msg.oldCount} → ${msg.newCount} messages`,
|
|
87
|
+
})
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (type === "retry") {
|
|
92
|
+
dispatch({
|
|
93
|
+
type: "system_note",
|
|
94
|
+
text: `Retrying (attempt ${msg.attempt}, ${msg.kind})...`,
|
|
95
|
+
tone: "warn",
|
|
96
|
+
})
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (type === "queued") {
|
|
101
|
+
dispatch({ type: "system_note", text: "Message queued — waiting for current run to finish" })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (type === "model.switched") {
|
|
106
|
+
dispatch({
|
|
107
|
+
type: "system_note",
|
|
108
|
+
text: `Model: ${msg.model}${msg.thinking ? ` | Thinking: ${msg.thinking}` : ""}${msg.effort ? ` | Effort: ${msg.effort}` : ""}`,
|
|
109
|
+
})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Standard agent events — parse through the same pipeline as spawn mode
|
|
114
|
+
const events = parseEvent(msg)
|
|
115
|
+
for (const ev of events) {
|
|
116
|
+
dispatch({ type: "agent_event", event: ev })
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
ws.addEventListener("close", () => {
|
|
121
|
+
dispatch({ type: "system_note", text: "Disconnected from gateway", tone: "warn" })
|
|
122
|
+
wsRef.current = null
|
|
123
|
+
runningRef.current = false
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
ws.addEventListener("error", () => {
|
|
127
|
+
dispatch({
|
|
128
|
+
type: "agent_event",
|
|
129
|
+
event: { type: "error", message: "WebSocket connection failed. Is the gateway running?" },
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return () => {
|
|
134
|
+
ws.close()
|
|
135
|
+
wsRef.current = null
|
|
136
|
+
}
|
|
137
|
+
}, [opts.wsUrl, opts.token])
|
|
138
|
+
|
|
139
|
+
const wsSend = useCallback((msg: Record<string, unknown>) => {
|
|
140
|
+
const ws = wsRef.current
|
|
141
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
142
|
+
dispatch({
|
|
143
|
+
type: "agent_event",
|
|
144
|
+
event: { type: "error", message: "Not connected to gateway" },
|
|
145
|
+
})
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
ws.send(JSON.stringify(msg))
|
|
149
|
+
}, [])
|
|
150
|
+
|
|
151
|
+
const submit = useCallback((prompt: string) => {
|
|
152
|
+
if (runningRef.current) return
|
|
153
|
+
|
|
154
|
+
dispatch({ type: "user_submit", text: prompt })
|
|
155
|
+
runningRef.current = true
|
|
156
|
+
|
|
157
|
+
wsSend({
|
|
158
|
+
type: "chat",
|
|
159
|
+
message: prompt,
|
|
160
|
+
session: sessionIdRef.current,
|
|
161
|
+
})
|
|
162
|
+
}, [wsSend])
|
|
163
|
+
|
|
164
|
+
const respondToApproval = useCallback((behavior: ApprovalBehavior) => {
|
|
165
|
+
if (!state.pendingApproval) return
|
|
166
|
+
wsSend({
|
|
167
|
+
type: "approval.decide",
|
|
168
|
+
id: state.pendingApproval.id,
|
|
169
|
+
decision: behavior === "allow" ? "allow-once" : "deny",
|
|
170
|
+
})
|
|
171
|
+
dispatch({ type: "approval_resolved" })
|
|
172
|
+
}, [state.pendingApproval, wsSend])
|
|
173
|
+
|
|
174
|
+
const setPermissionMode = useCallback((mode: PermissionMode) => {
|
|
175
|
+
dispatch({ type: "set_permission_mode", mode })
|
|
176
|
+
}, [])
|
|
177
|
+
|
|
178
|
+
const abort = useCallback(() => {
|
|
179
|
+
wsSend({ type: "abort" })
|
|
180
|
+
runningRef.current = false
|
|
181
|
+
dispatch({ type: "abort" })
|
|
182
|
+
}, [wsSend])
|
|
183
|
+
|
|
184
|
+
const clear = useCallback(() => {
|
|
185
|
+
abort()
|
|
186
|
+
sessionIdRef.current = `tui-${Date.now()}`
|
|
187
|
+
dispatch({ type: "clear" })
|
|
188
|
+
}, [abort])
|
|
189
|
+
|
|
190
|
+
const pushSystem = useCallback((text: string, tone?: "info" | "warn" | "error") => {
|
|
191
|
+
dispatch({ type: "system_note", text, tone })
|
|
192
|
+
}, [])
|
|
193
|
+
|
|
194
|
+
const switchModel = useCallback((model: string, thinking?: string, effort?: string) => {
|
|
195
|
+
wsSend({ type: "model.switch", model, thinking, effort })
|
|
196
|
+
}, [wsSend])
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
state, submit, respondToApproval, setPermissionMode, abort, clear,
|
|
200
|
+
pushSystem, switchModel, wsSend, sessionId: sessionIdRef.current,
|
|
201
|
+
}
|
|
202
|
+
}
|
package/tui/src/main.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createCliRenderer } from "@opentui/core"
|
|
2
|
+
import { createRoot } from "@opentui/react"
|
|
3
|
+
import { App } from "./App.js"
|
|
4
|
+
import { DEFAULT_MODEL } from "./config.js"
|
|
5
|
+
|
|
6
|
+
const model = process.env.CAMELAGI_MODEL ?? DEFAULT_MODEL
|
|
7
|
+
const cwd = process.env.CAMELAGI_CWD ?? process.cwd()
|
|
8
|
+
const wsUrl = process.env.CAMELAGI_WS_URL
|
|
9
|
+
const token = process.env.CAMELAGI_TOKEN
|
|
10
|
+
|
|
11
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false, useMouse: false })
|
|
12
|
+
createRoot(renderer).render(<App model={model} cwd={cwd} wsUrl={wsUrl} token={token} />)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Model list matching CamelAGI's supported models
|
|
2
|
+
|
|
3
|
+
export interface ModelOption {
|
|
4
|
+
id: string
|
|
5
|
+
label: string
|
|
6
|
+
vendor: string
|
|
7
|
+
notes?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const MODELS: ModelOption[] = [
|
|
11
|
+
{ id: "claude-sonnet-4-20250514", label: "Claude Sonnet 4", vendor: "Anthropic", notes: "balanced default" },
|
|
12
|
+
{ id: "claude-opus-4-20250514", label: "Claude Opus 4", vendor: "Anthropic", notes: "best reasoning" },
|
|
13
|
+
{ id: "claude-haiku-4-20250506", label: "Claude Haiku 4", vendor: "Anthropic", notes: "fastest" },
|
|
14
|
+
{ id: "gpt-4o", label: "GPT-4o", vendor: "OpenAI" },
|
|
15
|
+
{ id: "gpt-4o-mini", label: "GPT-4o Mini", vendor: "OpenAI", notes: "fast" },
|
|
16
|
+
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro", vendor: "Google" },
|
|
17
|
+
{ id: "deepseek/deepseek-r1", label: "DeepSeek R1", vendor: "DeepSeek" },
|
|
18
|
+
{ id: "deepseek/deepseek-chat", label: "DeepSeek Chat", vendor: "DeepSeek" },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
export const EFFORT_LEVELS = ["low", "medium", "high", "max"] as const
|
|
22
|
+
export type Effort = typeof EFFORT_LEVELS[number]
|
|
23
|
+
|
|
24
|
+
export function findModel(id: string): ModelOption | undefined {
|
|
25
|
+
return MODELS.find(m => m.id === id)
|
|
26
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Pure reducer: (state, event) => state. The component tree is a function
|
|
2
|
+
// of state, not events — so streaming, retries, and approvals are all just
|
|
3
|
+
// state transitions. Keep this file free of any I/O or side effects.
|
|
4
|
+
|
|
5
|
+
import type { AgentEvent, ApprovalRequest, PermissionMode, UsageInfo } from "../agent/types.js"
|
|
6
|
+
|
|
7
|
+
export type ChatStatus = "idle" | "thinking" | "streaming" | "awaiting_approval" | "error"
|
|
8
|
+
|
|
9
|
+
export type ChatEntry =
|
|
10
|
+
| { kind: "user"; id: string; text: string }
|
|
11
|
+
| { kind: "assistant"; id: string; text: string; thinking: string; streaming: boolean }
|
|
12
|
+
| {
|
|
13
|
+
kind: "tool"
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
args: Record<string, unknown>
|
|
17
|
+
status: "running" | "done" | "error" | "denied"
|
|
18
|
+
result?: string
|
|
19
|
+
}
|
|
20
|
+
| { kind: "subagent"; id: string; agentId: string; toolCount?: number; duration?: number; done: boolean }
|
|
21
|
+
| { kind: "system"; id: string; text: string; tone?: "info" | "warn" | "error" }
|
|
22
|
+
| { kind: "divider"; id: string }
|
|
23
|
+
|
|
24
|
+
export interface ChatState {
|
|
25
|
+
entries: ChatEntry[]
|
|
26
|
+
status: ChatStatus
|
|
27
|
+
pendingApproval: ApprovalRequest | null
|
|
28
|
+
permissionMode: PermissionMode
|
|
29
|
+
sessionId: string | null
|
|
30
|
+
usage: UsageInfo | null
|
|
31
|
+
liveTokens: number
|
|
32
|
+
runStartedAt: number | null
|
|
33
|
+
activityLabel: string | null
|
|
34
|
+
error: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const initialState: ChatState = {
|
|
38
|
+
entries: [],
|
|
39
|
+
status: "idle",
|
|
40
|
+
pendingApproval: null,
|
|
41
|
+
permissionMode: "bypassPermissions",
|
|
42
|
+
sessionId: null,
|
|
43
|
+
usage: null,
|
|
44
|
+
liveTokens: 0,
|
|
45
|
+
runStartedAt: null,
|
|
46
|
+
activityLabel: null,
|
|
47
|
+
error: null,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type StateAction =
|
|
51
|
+
| { type: "user_submit"; text: string }
|
|
52
|
+
| { type: "agent_event"; event: AgentEvent }
|
|
53
|
+
| { type: "set_permission_mode"; mode: PermissionMode }
|
|
54
|
+
| { type: "approval_resolved" }
|
|
55
|
+
| { type: "system_note"; text: string; tone?: "info" | "warn" | "error" }
|
|
56
|
+
| { type: "clear" }
|
|
57
|
+
| { type: "abort" }
|
|
58
|
+
|
|
59
|
+
let nextId = 0
|
|
60
|
+
const newId = (prefix: string) => `${prefix}-${++nextId}`
|
|
61
|
+
|
|
62
|
+
export function reduce(state: ChatState, action: StateAction): ChatState {
|
|
63
|
+
switch (action.type) {
|
|
64
|
+
case "user_submit":
|
|
65
|
+
return {
|
|
66
|
+
...state,
|
|
67
|
+
entries: [
|
|
68
|
+
...state.entries,
|
|
69
|
+
{ kind: "user", id: newId("u"), text: action.text },
|
|
70
|
+
],
|
|
71
|
+
status: "thinking",
|
|
72
|
+
liveTokens: 0,
|
|
73
|
+
runStartedAt: Date.now(),
|
|
74
|
+
activityLabel: "Thinking",
|
|
75
|
+
error: null,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case "agent_event":
|
|
79
|
+
return reduceEvent(state, action.event)
|
|
80
|
+
|
|
81
|
+
case "set_permission_mode":
|
|
82
|
+
return { ...state, permissionMode: action.mode }
|
|
83
|
+
|
|
84
|
+
case "approval_resolved":
|
|
85
|
+
return {
|
|
86
|
+
...state,
|
|
87
|
+
pendingApproval: null,
|
|
88
|
+
status: state.status === "awaiting_approval" ? "thinking" : state.status,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case "system_note":
|
|
92
|
+
return {
|
|
93
|
+
...state,
|
|
94
|
+
entries: [
|
|
95
|
+
...state.entries,
|
|
96
|
+
{ kind: "system", id: newId("sys"), text: action.text, tone: action.tone },
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
case "clear":
|
|
101
|
+
return { ...initialState, permissionMode: state.permissionMode }
|
|
102
|
+
|
|
103
|
+
case "abort":
|
|
104
|
+
return {
|
|
105
|
+
...state,
|
|
106
|
+
status: "idle",
|
|
107
|
+
runStartedAt: null,
|
|
108
|
+
activityLabel: null,
|
|
109
|
+
entries: finalizeStreaming(state.entries),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function estimateTokens(s: string): number {
|
|
115
|
+
return Math.ceil(s.length / 4)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function reduceEvent(state: ChatState, ev: AgentEvent): ChatState {
|
|
119
|
+
switch (ev.type) {
|
|
120
|
+
case "init":
|
|
121
|
+
return { ...state, sessionId: ev.sessionId || state.sessionId }
|
|
122
|
+
|
|
123
|
+
case "stream_text": {
|
|
124
|
+
const entries = appendAssistantText(state.entries, ev.text)
|
|
125
|
+
return {
|
|
126
|
+
...state,
|
|
127
|
+
entries,
|
|
128
|
+
status: "streaming",
|
|
129
|
+
activityLabel: "Responding",
|
|
130
|
+
liveTokens: state.liveTokens + estimateTokens(ev.text),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case "thinking_delta": {
|
|
135
|
+
const entries = appendAssistantThinking(state.entries, ev.text)
|
|
136
|
+
return {
|
|
137
|
+
...state,
|
|
138
|
+
entries,
|
|
139
|
+
status: "thinking",
|
|
140
|
+
activityLabel: "Thinking",
|
|
141
|
+
liveTokens: state.liveTokens + estimateTokens(ev.text),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case "thinking":
|
|
146
|
+
return state
|
|
147
|
+
|
|
148
|
+
case "tool_call": {
|
|
149
|
+
const entry: ChatEntry = {
|
|
150
|
+
kind: "tool",
|
|
151
|
+
id: ev.id,
|
|
152
|
+
name: ev.name,
|
|
153
|
+
args: ev.args,
|
|
154
|
+
status: "running",
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
...state,
|
|
158
|
+
entries: [...finalizeStreaming(state.entries), entry],
|
|
159
|
+
status: "thinking",
|
|
160
|
+
activityLabel: `Running ${ev.name}`,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "tool_result": {
|
|
165
|
+
const entries = state.entries.map(e => {
|
|
166
|
+
if (e.kind !== "tool" || e.id !== ev.id) return e
|
|
167
|
+
return {
|
|
168
|
+
...e,
|
|
169
|
+
status: ev.isError ? ("error" as const) : ("done" as const),
|
|
170
|
+
result: ev.preview,
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
return { ...state, entries, activityLabel: "Thinking" }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case "approval_request":
|
|
177
|
+
return {
|
|
178
|
+
...state,
|
|
179
|
+
pendingApproval: ev.request,
|
|
180
|
+
status: "awaiting_approval",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case "permission_denied": {
|
|
184
|
+
const entries = state.entries.map(e =>
|
|
185
|
+
e.kind === "tool" && e.id === ev.id
|
|
186
|
+
? { ...e, status: "denied" as const, result: ev.message }
|
|
187
|
+
: e
|
|
188
|
+
)
|
|
189
|
+
return { ...state, entries }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case "subagent_start":
|
|
193
|
+
return {
|
|
194
|
+
...state,
|
|
195
|
+
entries: [
|
|
196
|
+
...finalizeStreaming(state.entries),
|
|
197
|
+
{ kind: "subagent", id: newId("sa"), agentId: ev.agentId, done: false },
|
|
198
|
+
],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case "subagent_progress": {
|
|
202
|
+
const entries = updateLatestSubagent(state.entries, ev.agentId, sa => ({
|
|
203
|
+
...sa,
|
|
204
|
+
toolCount: ev.toolCount ?? sa.toolCount,
|
|
205
|
+
duration: ev.duration ?? sa.duration,
|
|
206
|
+
}))
|
|
207
|
+
return { ...state, entries }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case "subagent_done": {
|
|
211
|
+
const entries = updateLatestSubagent(state.entries, ev.agentId, sa => ({ ...sa, done: true }))
|
|
212
|
+
return { ...state, entries }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case "usage":
|
|
216
|
+
return { ...state, usage: ev.usage }
|
|
217
|
+
|
|
218
|
+
case "done":
|
|
219
|
+
return {
|
|
220
|
+
...state,
|
|
221
|
+
entries: finalizeStreaming(state.entries),
|
|
222
|
+
usage: ev.usage ?? state.usage,
|
|
223
|
+
status: "idle",
|
|
224
|
+
runStartedAt: null,
|
|
225
|
+
activityLabel: null,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case "error":
|
|
229
|
+
return {
|
|
230
|
+
...state,
|
|
231
|
+
status: "error",
|
|
232
|
+
error: ev.message,
|
|
233
|
+
entries: [
|
|
234
|
+
...finalizeStreaming(state.entries),
|
|
235
|
+
{ kind: "system", id: newId("err"), text: ev.message, tone: "error" },
|
|
236
|
+
],
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function appendAssistantText(entries: ChatEntry[], text: string): ChatEntry[] {
|
|
242
|
+
const last = entries[entries.length - 1]
|
|
243
|
+
if (last && last.kind === "assistant" && last.streaming) {
|
|
244
|
+
return entries.map((e, i) =>
|
|
245
|
+
i === entries.length - 1 && e.kind === "assistant"
|
|
246
|
+
? { ...e, text: e.text + text }
|
|
247
|
+
: e
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
return [
|
|
251
|
+
...entries,
|
|
252
|
+
{ kind: "assistant", id: newId("a"), text, thinking: "", streaming: true },
|
|
253
|
+
]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function appendAssistantThinking(entries: ChatEntry[], text: string): ChatEntry[] {
|
|
257
|
+
const last = entries[entries.length - 1]
|
|
258
|
+
if (last && last.kind === "assistant" && last.streaming) {
|
|
259
|
+
return entries.map((e, i) =>
|
|
260
|
+
i === entries.length - 1 && e.kind === "assistant"
|
|
261
|
+
? { ...e, thinking: e.thinking + text }
|
|
262
|
+
: e
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
return [
|
|
266
|
+
...entries,
|
|
267
|
+
{ kind: "assistant", id: newId("a"), text: "", thinking: text, streaming: true },
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function finalizeStreaming(entries: ChatEntry[]): ChatEntry[] {
|
|
272
|
+
return entries.map(e =>
|
|
273
|
+
e.kind === "assistant" && e.streaming ? { ...e, streaming: false } : e
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function updateLatestSubagent(
|
|
278
|
+
entries: ChatEntry[],
|
|
279
|
+
agentId: string,
|
|
280
|
+
fn: (sa: Extract<ChatEntry, { kind: "subagent" }>) => Extract<ChatEntry, { kind: "subagent" }>,
|
|
281
|
+
): ChatEntry[] {
|
|
282
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
283
|
+
const e = entries[i]
|
|
284
|
+
if (e.kind === "subagent" && e.agentId === agentId && !e.done) {
|
|
285
|
+
const updated = fn(e)
|
|
286
|
+
return [...entries.slice(0, i), updated, ...entries.slice(i + 1)]
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return entries
|
|
290
|
+
}
|
package/tui/src/theme.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const theme = {
|
|
2
|
+
// Codex-inspired neutral palette: minimal color, lots of dim, sparing accent.
|
|
3
|
+
user: "#e5e7eb",
|
|
4
|
+
userBg: "#373737", // subtle highlight strip behind user messages
|
|
5
|
+
codeBg: "#0f172a", // darker bg for fenced code blocks
|
|
6
|
+
assistant: "#e5e7eb",
|
|
7
|
+
thinking: "#a78bfa",
|
|
8
|
+
toolRunning: "#fbbf24",
|
|
9
|
+
toolDone: "#34d399",
|
|
10
|
+
toolError: "#f87171",
|
|
11
|
+
toolDenied: "#f59e0b",
|
|
12
|
+
system: "#9ca3af",
|
|
13
|
+
dim: "#6b7280",
|
|
14
|
+
border: "#334155",
|
|
15
|
+
borderActive: "#64748b",
|
|
16
|
+
accent: "#22d3ee", // teal — used for links, /commands, list numbers
|
|
17
|
+
divider: "#334155",
|
|
18
|
+
diffAdd: "#022900",
|
|
19
|
+
diffRemove: "#3D0200",
|
|
20
|
+
diffAddFg: "#86efac",
|
|
21
|
+
diffRemoveFg: "#fca5a5",
|
|
22
|
+
modeAcceptEdits: "#34d399",
|
|
23
|
+
modeBypass: "#f87171",
|
|
24
|
+
modePlan: "#60a5fa",
|
|
25
|
+
bullet: "#9ca3af", // • marker color
|
|
26
|
+
branch: "#64748b", // └ tree branch
|
|
27
|
+
number: "#22d3ee", // colored list numbers
|
|
28
|
+
} as const
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Cross-platform desktop notification, no native deps. Each branch shells
|
|
2
|
+
// out to the OS's bundled tool:
|
|
3
|
+
// • macOS → osascript display notification
|
|
4
|
+
// • Linux → notify-send (libnotify, present on most distros)
|
|
5
|
+
// • Win32 → PowerShell + BurntToast-free toast via Windows.UI.Notifications
|
|
6
|
+
//
|
|
7
|
+
// Failures are swallowed — a missing notify-send shouldn't crash a flow run.
|
|
8
|
+
// Strings are escaped before being interpolated into shell strings to keep
|
|
9
|
+
// quotes / backslashes safe.
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process"
|
|
12
|
+
|
|
13
|
+
function escapeAppleScript(s: string): string {
|
|
14
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function nativeNotify(title: string, body?: string, sound = true): void {
|
|
18
|
+
try {
|
|
19
|
+
if (process.platform === "darwin") {
|
|
20
|
+
const t = escapeAppleScript(title)
|
|
21
|
+
const b = escapeAppleScript(body ?? "")
|
|
22
|
+
const soundClause = sound ? ' sound name "default"' : ""
|
|
23
|
+
const script = `display notification "${b}" with title "${t}"${soundClause}`
|
|
24
|
+
spawn("osascript", ["-e", script], { stdio: "ignore", detached: true }).unref()
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
if (process.platform === "linux") {
|
|
28
|
+
const args = [title]
|
|
29
|
+
if (body) args.push(body)
|
|
30
|
+
spawn("notify-send", args, { stdio: "ignore", detached: true }).unref()
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
if (process.platform === "win32") {
|
|
34
|
+
const t = title.replace(/"/g, '`"')
|
|
35
|
+
const b = (body ?? "").replace(/"/g, '`"')
|
|
36
|
+
const ps = `[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]>$null;` +
|
|
37
|
+
`$x=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(1);` +
|
|
38
|
+
`$x.GetElementsByTagName('text').Item(0).AppendChild($x.CreateTextNode("${t}"))>$null;` +
|
|
39
|
+
`$x.GetElementsByTagName('text').Item(1).AppendChild($x.CreateTextNode("${b}"))>$null;` +
|
|
40
|
+
`[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('LiquidAgente').Show([Windows.UI.Notifications.ToastNotification]::new($x));`
|
|
41
|
+
spawn("powershell", ["-NoProfile", "-Command", ps], { stdio: "ignore", detached: true }).unref()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// No notifier available — silently skip rather than failing the run.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Braille-pattern spinner frames — the de-facto standard for terminal
|
|
2
|
+
// spinners. OpenTUI doesn't ship one, so we share this single source
|
|
3
|
+
// instead of duplicating the array across components.
|
|
4
|
+
|
|
5
|
+
export const SPINNER_FRAMES = [
|
|
6
|
+
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
|
|
7
|
+
] as const
|
|
8
|
+
|
|
9
|
+
export function spinnerFrame(tick: number): string {
|
|
10
|
+
return SPINNER_FRAMES[tick % SPINNER_FRAMES.length]!
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext", "DOM"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"jsxImportSource": "@opentui/react",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"allowImportingTsExtensions": false,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"types": ["node"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*", "scripts/**/*", "../../liquidagente-flow-core/src/**/*", "../../../liquidagente-flow-core/src/**/*"]
|
|
19
|
+
}
|