otherwise-cli 0.1.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.
Files changed (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. package/src/ui/utils/markdown.js +166 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Chat state management hook for Ink UI
3
+ * Manages chat messages, streaming content, and tool executions
4
+ */
5
+
6
+ import { useState, useCallback, useReducer, useRef, useEffect } from "react";
7
+
8
+ /**
9
+ * Message types
10
+ */
11
+ export const MessageRole = {
12
+ USER: "user",
13
+ ASSISTANT: "assistant",
14
+ SYSTEM: "system",
15
+ };
16
+
17
+ /**
18
+ * Generation states
19
+ */
20
+ export const GenerationState = {
21
+ IDLE: "idle",
22
+ THINKING: "thinking",
23
+ GENERATING: "generating",
24
+ TOOL_EXECUTING: "tool_executing",
25
+ STOPPED: "stopped",
26
+ ERROR: "error",
27
+ };
28
+
29
+ /**
30
+ * Tool execution states
31
+ */
32
+ export const ToolState = {
33
+ PREPARING: "preparing",
34
+ RUNNING: "running",
35
+ STREAMING: "streaming",
36
+ COMPLETE: "complete",
37
+ ERROR: "error",
38
+ };
39
+
40
+ /**
41
+ * Tool reducer actions
42
+ */
43
+ const toolActions = {
44
+ START: "tool/start",
45
+ UPDATE: "tool/update",
46
+ STREAMING: "tool/streaming",
47
+ COMPLETE: "tool/complete",
48
+ ERROR: "tool/error",
49
+ CLEAR: "tool/clear",
50
+ };
51
+
52
+ /**
53
+ * Tool state reducer
54
+ */
55
+ function toolReducer(state, action) {
56
+ switch (action.type) {
57
+ case toolActions.START: {
58
+ // Clean up any existing streaming entry for this tool name (streaming → running transition)
59
+ const cleaned = { ...state };
60
+ for (const [id, tool] of Object.entries(cleaned)) {
61
+ if (
62
+ id !== action.id &&
63
+ tool.name === action.name &&
64
+ (tool.status === ToolState.STREAMING || tool.status === ToolState.PREPARING)
65
+ ) {
66
+ delete cleaned[id];
67
+ }
68
+ }
69
+ return {
70
+ ...cleaned,
71
+ [action.id]: {
72
+ id: action.id,
73
+ name: action.name,
74
+ args: action.args,
75
+ status: ToolState.RUNNING,
76
+ startTime: state[action.id]?.startTime || Date.now(),
77
+ streamingContent: state[action.id]?.streamingContent || "",
78
+ result: null,
79
+ error: null,
80
+ },
81
+ };
82
+ }
83
+
84
+ case toolActions.STREAMING: {
85
+ const existing = state[action.id];
86
+ if (existing) {
87
+ return {
88
+ ...state,
89
+ [action.id]: {
90
+ ...existing,
91
+ status: ToolState.STREAMING,
92
+ args: { ...existing.args, ...action.args },
93
+ streamingContent: action.content || "",
94
+ },
95
+ };
96
+ }
97
+ // Create entry when it doesn't exist yet (tool_streaming arrives before tool_start)
98
+ return {
99
+ ...state,
100
+ [action.id]: {
101
+ id: action.id,
102
+ name: action.name,
103
+ args: action.args || {},
104
+ status: ToolState.STREAMING,
105
+ startTime: Date.now(),
106
+ streamingContent: action.content || "",
107
+ result: null,
108
+ error: null,
109
+ },
110
+ };
111
+ }
112
+
113
+ case toolActions.COMPLETE:
114
+ if (!state[action.id]) return state;
115
+ return {
116
+ ...state,
117
+ [action.id]: {
118
+ ...state[action.id],
119
+ status: ToolState.COMPLETE,
120
+ result: action.result,
121
+ endTime: Date.now(),
122
+ },
123
+ };
124
+
125
+ case toolActions.ERROR:
126
+ if (!state[action.id]) return state;
127
+ return {
128
+ ...state,
129
+ [action.id]: {
130
+ ...state[action.id],
131
+ status: ToolState.ERROR,
132
+ error: action.error,
133
+ endTime: Date.now(),
134
+ },
135
+ };
136
+
137
+ case toolActions.CLEAR:
138
+ return {};
139
+
140
+ default:
141
+ return state;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Custom hook for chat state management
147
+ * @param {object} options - Configuration options
148
+ * @returns {object} - Chat state and methods
149
+ */
150
+ export function useChatState(options = {}) {
151
+ const { initialChatId = null, initialModel = "claude-sonnet-4-20250514" } =
152
+ options;
153
+
154
+ // Chat metadata
155
+ const [currentChatId, setCurrentChatId] = useState(initialChatId);
156
+ const currentChatIdRef = useRef(initialChatId);
157
+ const [currentChatTitle, setCurrentChatTitle] = useState(null);
158
+ const [currentModel, setCurrentModel] = useState(initialModel);
159
+
160
+ // Messages (completed)
161
+ const [messages, setMessages] = useState([]);
162
+
163
+ // Generation state
164
+ const [generationState, setGenerationState] = useState(GenerationState.IDLE);
165
+ const [streamingContent, setStreamingContent] = useState("");
166
+ const [thinkingContent, setThinkingContent] = useState("");
167
+ const [thinkingStartTime, setThinkingStartTime] = useState(null);
168
+
169
+ // Ref to track current streaming content (avoids stale closure issues)
170
+ const streamingContentRef = useRef("");
171
+
172
+ // Ref to track current tools for message history
173
+ const toolsRef = useRef({});
174
+
175
+ // Tool executions
176
+ const [tools, dispatchTool] = useReducer(toolReducer, {});
177
+
178
+ // Stats from last generation
179
+ const [lastStats, setLastStats] = useState(null);
180
+
181
+ // Error state
182
+ const [error, setError] = useState(null);
183
+
184
+ // Keep refs in sync (must be after state declarations)
185
+ useEffect(() => {
186
+ streamingContentRef.current = streamingContent;
187
+ }, [streamingContent]);
188
+
189
+ // Throttled flush so CLI re-renders during streaming (fixes "text only on frontend, full message at end on CLI")
190
+ // Without this, many WebSocket 'text' chunks in the same tick batch into one React update
191
+ const isStreaming =
192
+ generationState === GenerationState.THINKING ||
193
+ generationState === GenerationState.GENERATING ||
194
+ generationState === GenerationState.TOOL_EXECUTING;
195
+ useEffect(() => {
196
+ if (!isStreaming) return;
197
+ const interval = setInterval(() => {
198
+ const current = streamingContentRef.current;
199
+ setStreamingContent((prev) => (prev === current ? prev : current));
200
+ }, 50);
201
+ return () => clearInterval(interval);
202
+ }, [isStreaming]);
203
+
204
+ useEffect(() => {
205
+ toolsRef.current = tools;
206
+ }, [tools]);
207
+
208
+ /**
209
+ * Start a new generation
210
+ */
211
+ const startGeneration = useCallback(() => {
212
+ setGenerationState(GenerationState.THINKING);
213
+ setStreamingContent("");
214
+ streamingContentRef.current = "";
215
+ setThinkingContent("");
216
+ setThinkingStartTime(Date.now());
217
+ setError(null);
218
+ dispatchTool({ type: toolActions.CLEAR });
219
+ }, []);
220
+
221
+ /**
222
+ * Handle thinking content
223
+ * @param {string} content - Thinking text chunk
224
+ * @param {number} totalChars - Total character count from server
225
+ */
226
+ const handleThinking = useCallback(
227
+ (content, totalChars) => {
228
+ setThinkingContent((prev) => prev + content);
229
+ if (generationState === GenerationState.IDLE) {
230
+ setGenerationState(GenerationState.THINKING);
231
+ setThinkingStartTime(Date.now());
232
+ }
233
+ },
234
+ [generationState],
235
+ );
236
+
237
+ /**
238
+ * Handle text content
239
+ * @param {string} content - Text chunk
240
+ */
241
+ const handleText = useCallback((content) => {
242
+ if (!content) return;
243
+
244
+ // Skip empty whitespace at start
245
+ if (streamingContentRef.current.length === 0 && !content.trim()) {
246
+ return;
247
+ }
248
+
249
+ setStreamingContent((prev) => {
250
+ const newContent = prev + content;
251
+ streamingContentRef.current = newContent;
252
+ return newContent;
253
+ });
254
+ setGenerationState((prev) =>
255
+ prev === GenerationState.THINKING ? GenerationState.GENERATING : prev,
256
+ );
257
+ }, []);
258
+
259
+ /**
260
+ * Handle tool start
261
+ * Transitions existing streaming entry to running, or creates a new one
262
+ * @param {object} data - Tool start data
263
+ */
264
+ const handleToolStart = useCallback((data) => {
265
+ if (data.name === "set_title") return;
266
+
267
+ setGenerationState(GenerationState.TOOL_EXECUTING);
268
+ // START action in the reducer automatically cleans up streaming entries
269
+ // with the same tool name, so no explicit removal needed here
270
+ dispatchTool({
271
+ type: toolActions.START,
272
+ id: data.callId,
273
+ name: data.name,
274
+ args: data.args,
275
+ });
276
+ }, []);
277
+
278
+ /**
279
+ * Handle tool streaming update
280
+ * @param {object} data - Tool streaming data (has streamingId, not callId)
281
+ */
282
+ const handleToolStreaming = useCallback((data) => {
283
+ if (data.name === "set_title" || data.name === "_preparing") return;
284
+
285
+ setGenerationState(GenerationState.TOOL_EXECUTING);
286
+ dispatchTool({
287
+ type: toolActions.STREAMING,
288
+ id: data.streamingId || data.callId || `streaming-${data.name}`,
289
+ name: data.name,
290
+ args: data.args,
291
+ content: data.streamingContent,
292
+ });
293
+ }, []);
294
+
295
+ /**
296
+ * Handle tool result
297
+ * @param {object} data - Tool result data
298
+ */
299
+ const handleToolResult = useCallback((data) => {
300
+ dispatchTool({
301
+ type: toolActions.COMPLETE,
302
+ id: data.callId,
303
+ result: data.result,
304
+ });
305
+ }, []);
306
+
307
+ /**
308
+ * Handle tool error
309
+ * @param {object} data - Tool error data
310
+ */
311
+ const handleToolError = useCallback((data) => {
312
+ dispatchTool({
313
+ type: toolActions.ERROR,
314
+ id: data.callId,
315
+ error: data.error,
316
+ });
317
+ }, []);
318
+
319
+ /**
320
+ * Handle generation done
321
+ * @param {object} data - Done data with stats
322
+ */
323
+ const handleDone = useCallback(
324
+ (data) => {
325
+ // Use refs to get current values (avoids stale closure)
326
+ const currentContent = streamingContentRef.current;
327
+ const currentTools = toolsRef.current;
328
+
329
+ // Convert tools to array for storage, filtering out hidden tools
330
+ const toolsArray = Object.values(currentTools).filter(
331
+ (t) => t.name !== "set_title" && t.name !== "_preparing",
332
+ );
333
+
334
+ // Add assistant message to history (even if empty, to show tools)
335
+ if (currentContent || toolsArray.length > 0) {
336
+ setMessages((prev) => [
337
+ ...prev,
338
+ {
339
+ id: `assistant-${Date.now()}`,
340
+ role: MessageRole.ASSISTANT,
341
+ content: currentContent || "",
342
+ model: data.message?.model || currentModel,
343
+ timestamp: new Date().toISOString(),
344
+ tools: toolsArray.length > 0 ? toolsArray : undefined,
345
+ },
346
+ ]);
347
+ }
348
+
349
+ setLastStats({
350
+ tps: data.tps,
351
+ numTokens: data.numTokens,
352
+ model: data.message?.model || currentModel,
353
+ finishReason: data.finishReason,
354
+ });
355
+
356
+ setGenerationState(GenerationState.IDLE);
357
+ setStreamingContent("");
358
+ streamingContentRef.current = "";
359
+ setThinkingContent("");
360
+ setThinkingStartTime(null);
361
+ dispatchTool({ type: toolActions.CLEAR });
362
+ toolsRef.current = {};
363
+ },
364
+ [currentModel],
365
+ );
366
+
367
+ /**
368
+ * Handle generation error
369
+ * @param {string} message - Error message
370
+ */
371
+ const handleError = useCallback((message) => {
372
+ setGenerationState(GenerationState.ERROR);
373
+ setError(message);
374
+ }, []);
375
+
376
+ /**
377
+ * Handle generation stopped
378
+ * Preserves any partial content that was streamed
379
+ */
380
+ const handleStopped = useCallback(() => {
381
+ // Get current content before clearing
382
+ const currentContent = streamingContentRef.current;
383
+ const currentTools = toolsRef.current;
384
+
385
+ // Convert tools to array for storage
386
+ const toolsArray = Object.values(currentTools).filter(
387
+ (t) => t.name !== "set_title" && t.name !== "_preparing",
388
+ );
389
+
390
+ // If we have partial content, save it with a [stopped] indicator
391
+ if (currentContent || toolsArray.length > 0) {
392
+ const stoppedContent = currentContent
393
+ ? currentContent + "\n\n*[Generation stopped]*"
394
+ : "*[Generation stopped]*";
395
+
396
+ setMessages((prev) => [
397
+ ...prev,
398
+ {
399
+ id: `assistant-${Date.now()}`,
400
+ role: MessageRole.ASSISTANT,
401
+ content: stoppedContent,
402
+ timestamp: new Date().toISOString(),
403
+ tools: toolsArray.length > 0 ? toolsArray : undefined,
404
+ stopped: true,
405
+ },
406
+ ]);
407
+ }
408
+
409
+ setGenerationState(GenerationState.STOPPED);
410
+ setStreamingContent("");
411
+ streamingContentRef.current = "";
412
+ setThinkingContent("");
413
+ setThinkingStartTime(null);
414
+ dispatchTool({ type: toolActions.CLEAR });
415
+ toolsRef.current = {};
416
+ }, []);
417
+
418
+ /**
419
+ * Handle chat title update
420
+ * @param {string} title - New title
421
+ */
422
+ const handleTitle = useCallback((title) => {
423
+ setCurrentChatTitle(title);
424
+ }, []);
425
+
426
+ /**
427
+ * Handle chat created event
428
+ * @param {number} chatId - New chat ID
429
+ */
430
+ const handleChatCreated = useCallback((chatId) => {
431
+ currentChatIdRef.current = chatId;
432
+ setCurrentChatId(chatId);
433
+ }, []);
434
+
435
+ /**
436
+ * Handle user message from another client (e.g., frontend)
437
+ * @param {object} data - Message data with chatId and content
438
+ */
439
+ const handleUserMessage = useCallback(
440
+ (data) => {
441
+ const { chatId, content } = data;
442
+ const activeChatId = currentChatIdRef.current;
443
+
444
+ // Accept message if it's for the current chat, or if no chat is active yet
445
+ if (!activeChatId || chatId === activeChatId) {
446
+ if (!activeChatId && chatId) {
447
+ currentChatIdRef.current = chatId;
448
+ setCurrentChatId(chatId);
449
+ }
450
+
451
+ setMessages((prev) => [
452
+ ...prev,
453
+ {
454
+ id: `user-${Date.now()}`,
455
+ role: MessageRole.USER,
456
+ content,
457
+ source: "web",
458
+ timestamp: new Date().toISOString(),
459
+ },
460
+ ]);
461
+
462
+ setThinkingContent("");
463
+ setStreamingContent("");
464
+ streamingContentRef.current = "";
465
+ dispatchTool({ type: toolActions.CLEAR });
466
+ toolsRef.current = {};
467
+ setGenerationState(GenerationState.THINKING);
468
+ setThinkingStartTime(Date.now());
469
+ }
470
+ },
471
+ [],
472
+ );
473
+
474
+ /**
475
+ * Handle chat selection from another client
476
+ * @param {number} chatId - Selected chat ID
477
+ */
478
+ const handleChatSelected = useCallback(
479
+ (chatId) => {
480
+ if (chatId !== currentChatIdRef.current) {
481
+ currentChatIdRef.current = chatId;
482
+ setCurrentChatId(chatId);
483
+ setMessages([]);
484
+ setCurrentChatTitle(null);
485
+ setGenerationState(GenerationState.IDLE);
486
+ }
487
+ },
488
+ [],
489
+ );
490
+
491
+ /**
492
+ * Handle model change from another client
493
+ * @param {string} model - New model ID
494
+ */
495
+ const handleModelChanged = useCallback((model) => {
496
+ setCurrentModel(model);
497
+ }, []);
498
+
499
+ /**
500
+ * Add a user message
501
+ * @param {string} content - Message content
502
+ */
503
+ const addUserMessage = useCallback((content) => {
504
+ setMessages((prev) => [
505
+ ...prev,
506
+ {
507
+ id: `user-${Date.now()}`,
508
+ role: MessageRole.USER,
509
+ content,
510
+ timestamp: new Date().toISOString(),
511
+ },
512
+ ]);
513
+ }, []);
514
+
515
+ /**
516
+ * Start a new chat
517
+ */
518
+ const newChat = useCallback(() => {
519
+ currentChatIdRef.current = null;
520
+ setCurrentChatId(null);
521
+ setCurrentChatTitle(null);
522
+ setMessages([]);
523
+ setStreamingContent("");
524
+ setThinkingContent("");
525
+ setThinkingStartTime(null);
526
+ setGenerationState(GenerationState.IDLE);
527
+ setError(null);
528
+ dispatchTool({ type: toolActions.CLEAR });
529
+ }, []);
530
+
531
+ /**
532
+ * Load a chat with messages
533
+ * @param {object} chat - Chat object with messages
534
+ */
535
+ const loadChat = useCallback((chat) => {
536
+ currentChatIdRef.current = chat.id;
537
+ setCurrentChatId(chat.id);
538
+ setCurrentChatTitle(chat.title);
539
+ setMessages(chat.messages || []);
540
+ setGenerationState(GenerationState.IDLE);
541
+ setStreamingContent("");
542
+ setThinkingContent("");
543
+ setError(null);
544
+ dispatchTool({ type: toolActions.CLEAR });
545
+ }, []);
546
+
547
+ /**
548
+ * Process a WebSocket message
549
+ * @param {object} data - Message data
550
+ */
551
+ const processMessage = useCallback(
552
+ (data) => {
553
+ switch (data.type) {
554
+ // Chat lifecycle events
555
+ case "chat_created":
556
+ handleChatCreated(data.chatId);
557
+ break;
558
+ case "chat_selected":
559
+ handleChatSelected(data.chatId);
560
+ break;
561
+
562
+ // User message from another client (frontend)
563
+ case "user_message":
564
+ handleUserMessage(data);
565
+ break;
566
+
567
+ // Model change from another client
568
+ case "model_changed":
569
+ handleModelChanged(data.model);
570
+ break;
571
+
572
+ // Generation streaming events
573
+ case "thinking":
574
+ handleThinking(data.content, data.totalChars);
575
+ break;
576
+ case "text":
577
+ handleText(data.content);
578
+ break;
579
+
580
+ // Tool events
581
+ case "tool_start":
582
+ handleToolStart(data);
583
+ break;
584
+ case "tool_streaming":
585
+ handleToolStreaming(data);
586
+ break;
587
+ case "tool_result":
588
+ handleToolResult(data);
589
+ break;
590
+ case "tool_error":
591
+ handleToolError(data);
592
+ break;
593
+
594
+ // Chat metadata
595
+ case "title":
596
+ handleTitle(data.content);
597
+ break;
598
+
599
+ // Generation completion
600
+ case "done":
601
+ handleDone(data);
602
+ break;
603
+ case "error":
604
+ handleError(data.message);
605
+ break;
606
+ case "stopped":
607
+ handleStopped();
608
+ break;
609
+ }
610
+ },
611
+ [
612
+ handleChatCreated,
613
+ handleChatSelected,
614
+ handleUserMessage,
615
+ handleModelChanged,
616
+ handleThinking,
617
+ handleText,
618
+ handleToolStart,
619
+ handleToolStreaming,
620
+ handleToolResult,
621
+ handleToolError,
622
+ handleTitle,
623
+ handleDone,
624
+ handleError,
625
+ handleStopped,
626
+ ],
627
+ );
628
+
629
+ return {
630
+ // Chat metadata
631
+ currentChatId,
632
+ currentChatTitle,
633
+ currentModel,
634
+ setCurrentModel,
635
+
636
+ // Messages
637
+ messages,
638
+ addUserMessage,
639
+
640
+ // Generation state
641
+ generationState,
642
+ isGenerating:
643
+ generationState !== GenerationState.IDLE &&
644
+ generationState !== GenerationState.STOPPED &&
645
+ generationState !== GenerationState.ERROR,
646
+ isThinking: generationState === GenerationState.THINKING,
647
+ streamingContent,
648
+ thinkingContent,
649
+ thinkingStartTime,
650
+
651
+ // Tools
652
+ tools,
653
+ activeTools: Object.values(tools).filter(
654
+ (t) => t.status === ToolState.RUNNING || t.status === ToolState.STREAMING,
655
+ ),
656
+
657
+ // Stats
658
+ lastStats,
659
+
660
+ // Error
661
+ error,
662
+
663
+ // Actions
664
+ startGeneration,
665
+ newChat,
666
+ loadChat,
667
+ processMessage,
668
+
669
+ // For external state updates
670
+ setCurrentChatId,
671
+ setCurrentChatTitle,
672
+ };
673
+ }
674
+
675
+ export default useChatState;