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.
Files changed (44) hide show
  1. package/dist/cli/cmd-chat.js +69 -2
  2. package/dist/cli/cmd-chat.js.map +1 -1
  3. package/dist/core/version.js +1 -1
  4. package/dist/runtime/orchestrate.js +1 -1
  5. package/dist/telegram/admin-bot.js +1 -1
  6. package/package.json +5 -2
  7. package/tui/package.json +23 -0
  8. package/tui/src/App.tsx +161 -0
  9. package/tui/src/agent/parse.ts +184 -0
  10. package/tui/src/agent/types.ts +75 -0
  11. package/tui/src/commands/registry.ts +301 -0
  12. package/tui/src/components/ActivityIndicator.tsx +148 -0
  13. package/tui/src/components/ApprovalPrompt.tsx +58 -0
  14. package/tui/src/components/BottomBar.tsx +74 -0
  15. package/tui/src/components/Chat.tsx +98 -0
  16. package/tui/src/components/Divider.tsx +12 -0
  17. package/tui/src/components/HorizontalRule.tsx +12 -0
  18. package/tui/src/components/Input.tsx +126 -0
  19. package/tui/src/components/Markdown.tsx +290 -0
  20. package/tui/src/components/Message.tsx +77 -0
  21. package/tui/src/components/PermissionBanner.tsx +30 -0
  22. package/tui/src/components/Picker.tsx +127 -0
  23. package/tui/src/components/SlashMenu.tsx +46 -0
  24. package/tui/src/components/SubagentBlock.tsx +16 -0
  25. package/tui/src/components/ToolBlock.tsx +24 -0
  26. package/tui/src/components/Welcome.tsx +75 -0
  27. package/tui/src/components/tools/BashTool.tsx +27 -0
  28. package/tui/src/components/tools/DefaultTool.tsx +38 -0
  29. package/tui/src/components/tools/DiffView.tsx +91 -0
  30. package/tui/src/components/tools/EditGroup.tsx +97 -0
  31. package/tui/src/components/tools/EditTool.tsx +41 -0
  32. package/tui/src/components/tools/ReadTool.tsx +41 -0
  33. package/tui/src/components/tools/SearchTool.tsx +27 -0
  34. package/tui/src/components/tools/ToolHeader.tsx +48 -0
  35. package/tui/src/components/tools/WriteTool.tsx +54 -0
  36. package/tui/src/config.ts +6 -0
  37. package/tui/src/hooks/useAgent.ts +202 -0
  38. package/tui/src/main.tsx +12 -0
  39. package/tui/src/models.ts +26 -0
  40. package/tui/src/state/reducer.ts +290 -0
  41. package/tui/src/theme.ts +28 -0
  42. package/tui/src/util/nativeNotify.ts +47 -0
  43. package/tui/src/util/spinner.ts +11 -0
  44. 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
+ }
@@ -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
+ }
@@ -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
+ }