@townco/ui 0.1.83 → 0.1.93

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 (63) hide show
  1. package/dist/core/hooks/use-chat-input.js +13 -6
  2. package/dist/core/hooks/use-chat-messages.d.ts +17 -0
  3. package/dist/core/hooks/use-chat-messages.js +294 -10
  4. package/dist/core/schemas/chat.d.ts +20 -0
  5. package/dist/core/schemas/chat.js +4 -0
  6. package/dist/core/schemas/index.d.ts +1 -0
  7. package/dist/core/schemas/index.js +1 -0
  8. package/dist/core/schemas/source.d.ts +22 -0
  9. package/dist/core/schemas/source.js +45 -0
  10. package/dist/core/store/chat-store.d.ts +4 -0
  11. package/dist/core/store/chat-store.js +54 -0
  12. package/dist/gui/components/Actions.d.ts +15 -0
  13. package/dist/gui/components/Actions.js +22 -0
  14. package/dist/gui/components/ChatInput.d.ts +9 -1
  15. package/dist/gui/components/ChatInput.js +24 -6
  16. package/dist/gui/components/ChatInputCommandMenu.d.ts +1 -0
  17. package/dist/gui/components/ChatInputCommandMenu.js +22 -5
  18. package/dist/gui/components/ChatInputParameters.d.ts +13 -0
  19. package/dist/gui/components/ChatInputParameters.js +67 -0
  20. package/dist/gui/components/ChatLayout.d.ts +2 -0
  21. package/dist/gui/components/ChatLayout.js +183 -61
  22. package/dist/gui/components/ChatPanelTabContent.d.ts +7 -0
  23. package/dist/gui/components/ChatPanelTabContent.js +17 -7
  24. package/dist/gui/components/ChatView.js +105 -15
  25. package/dist/gui/components/CitationChip.d.ts +15 -0
  26. package/dist/gui/components/CitationChip.js +72 -0
  27. package/dist/gui/components/EditableUserMessage.d.ts +18 -0
  28. package/dist/gui/components/EditableUserMessage.js +109 -0
  29. package/dist/gui/components/MessageActions.d.ts +16 -0
  30. package/dist/gui/components/MessageActions.js +97 -0
  31. package/dist/gui/components/MessageContent.js +22 -7
  32. package/dist/gui/components/Response.d.ts +3 -0
  33. package/dist/gui/components/Response.js +30 -3
  34. package/dist/gui/components/Sidebar.js +1 -1
  35. package/dist/gui/components/TodoSubline.js +1 -1
  36. package/dist/gui/components/WorkProgress.js +7 -0
  37. package/dist/gui/components/index.d.ts +6 -1
  38. package/dist/gui/components/index.js +6 -1
  39. package/dist/gui/hooks/index.d.ts +1 -0
  40. package/dist/gui/hooks/index.js +1 -0
  41. package/dist/gui/hooks/use-favicon.d.ts +6 -0
  42. package/dist/gui/hooks/use-favicon.js +47 -0
  43. package/dist/gui/hooks/use-scroll-to-bottom.d.ts +14 -0
  44. package/dist/gui/hooks/use-scroll-to-bottom.js +317 -1
  45. package/dist/gui/index.d.ts +1 -1
  46. package/dist/gui/index.js +1 -1
  47. package/dist/gui/lib/motion.js +6 -6
  48. package/dist/gui/lib/remark-citations.d.ts +28 -0
  49. package/dist/gui/lib/remark-citations.js +70 -0
  50. package/dist/sdk/client/acp-client.d.ts +38 -1
  51. package/dist/sdk/client/acp-client.js +67 -3
  52. package/dist/sdk/schemas/message.d.ts +40 -0
  53. package/dist/sdk/schemas/message.js +20 -0
  54. package/dist/sdk/transports/http.d.ts +24 -1
  55. package/dist/sdk/transports/http.js +189 -1
  56. package/dist/sdk/transports/stdio.d.ts +1 -0
  57. package/dist/sdk/transports/stdio.js +39 -0
  58. package/dist/sdk/transports/types.d.ts +46 -1
  59. package/dist/sdk/transports/websocket.d.ts +1 -0
  60. package/dist/sdk/transports/websocket.js +4 -0
  61. package/dist/tui/components/ChatView.js +3 -4
  62. package/package.json +5 -3
  63. package/src/styles/global.css +71 -0
@@ -23,22 +23,29 @@ export function useChatInput(client, startSession) {
23
23
  * Handle input submission
24
24
  */
25
25
  const handleSubmit = useCallback(async () => {
26
- if (!input.value.trim() || input.isSubmitting) {
26
+ // Read fresh state directly from store to avoid stale closure issues
27
+ const currentInput = useChatStore.getState().input;
28
+ if (!currentInput.value.trim() || currentInput.isSubmitting) {
27
29
  return;
28
30
  }
29
- const message = input.value;
30
- const attachments = input.attachedFiles;
31
+ const message = currentInput.value;
32
+ const attachments = currentInput.attachedFiles;
33
+ const promptParameters = currentInput.selectedPromptParameters;
31
34
  logger.debug("Submitting message with attachments", {
32
35
  messageLength: message.length,
33
36
  attachmentCount: attachments.length,
34
37
  hasAttachments: attachments.length > 0,
38
+ hasPromptParameters: !!promptParameters && Object.keys(promptParameters).length > 0,
39
+ promptParameters,
35
40
  });
36
41
  // Clear input immediately for better UX
37
42
  setInputValue("");
38
43
  setInputSubmitting(true);
39
44
  try {
40
- await sendMessage(message, attachments.length > 0 ? attachments : undefined);
41
- // Clear attachments after successful send
45
+ await sendMessage(message, attachments.length > 0 ? attachments : undefined, promptParameters && Object.keys(promptParameters).length > 0
46
+ ? promptParameters
47
+ : undefined);
48
+ // Clear attachments after successful send (keep promptParameters for next message)
42
49
  useChatStore.setState((state) => ({
43
50
  input: { ...state.input, attachedFiles: [] },
44
51
  }));
@@ -52,7 +59,7 @@ export function useChatInput(client, startSession) {
52
59
  finally {
53
60
  setInputSubmitting(false);
54
61
  }
55
- }, [input, setInputValue, setInputSubmitting, sendMessage]);
62
+ }, [setInputValue, setInputSubmitting, sendMessage]);
56
63
  /**
57
64
  * Handle file attachment
58
65
  */
@@ -194,6 +194,15 @@ export declare function useChatMessages(client: AcpClient | null, startSession:
194
194
  mimeType: string;
195
195
  data: string;
196
196
  }[] | undefined;
197
+ sources?: {
198
+ id: string;
199
+ url: string;
200
+ title: string;
201
+ snippet?: string | undefined;
202
+ favicon?: string | undefined;
203
+ toolCallId: string;
204
+ sourceName?: string | undefined;
205
+ }[] | undefined;
197
206
  }[];
198
207
  isStreaming: boolean;
199
208
  sendMessage: (content: string, attachments?: {
@@ -202,5 +211,13 @@ export declare function useChatMessages(client: AcpClient | null, startSession:
202
211
  size: number;
203
212
  mimeType: string;
204
213
  data: string;
214
+ }[] | undefined, promptParameters?: Record<string, string> | undefined) => Promise<void>;
215
+ editAndResend: (userMessageIndex: number, newContent: string, attachments?: {
216
+ name: string;
217
+ path: string;
218
+ size: number;
219
+ mimeType: string;
220
+ data: string;
205
221
  }[] | undefined) => Promise<void>;
222
+ cancel: () => Promise<void>;
206
223
  };
@@ -1,5 +1,5 @@
1
1
  import { createLogger } from "@townco/core";
2
- import { useCallback } from "react";
2
+ import { useCallback, useRef } from "react";
3
3
  import { useChatStore } from "../store/chat-store.js";
4
4
  const logger = createLogger("use-chat-messages", "debug");
5
5
  /**
@@ -20,14 +20,25 @@ export function useChatMessages(client, startSession) {
20
20
  const addToolCallToCurrentMessage = useChatStore((state) => state.addToolCallToCurrentMessage);
21
21
  const updateToolCallInCurrentMessage = useChatStore((state) => state.updateToolCallInCurrentMessage);
22
22
  const addHookNotificationToCurrentMessage = useChatStore((state) => state.addHookNotificationToCurrentMessage);
23
+ const addSourcesToCurrentMessage = useChatStore((state) => state.addSourcesToCurrentMessage);
24
+ const truncateMessagesFrom = useChatStore((state) => state.truncateMessagesFrom);
25
+ // Track the current assistant message ID for cancellation
26
+ const currentAssistantMessageIdRef = useRef(null);
27
+ // Track if current turn was cancelled
28
+ const wasCancelledRef = useRef(false);
23
29
  /**
24
30
  * Send a message to the agent
31
+ * @param content - The message text content
32
+ * @param attachments - Optional image attachments
33
+ * @param promptParameters - Optional per-message parameters to influence agent behavior
25
34
  */
26
- const sendMessage = useCallback(async (content, attachments) => {
35
+ const sendMessage = useCallback(async (content, attachments, promptParameters) => {
27
36
  logger.debug("[sendMessage] Called with", {
28
37
  contentLength: content.length,
29
38
  attachmentsCount: attachments?.length || 0,
30
39
  hasAttachments: !!attachments && attachments.length > 0,
40
+ hasPromptParameters: !!promptParameters,
41
+ promptParameters: promptParameters,
31
42
  });
32
43
  if (!client) {
33
44
  logger.error("No client available");
@@ -83,12 +94,16 @@ export function useChatMessages(client, startSession) {
83
94
  streamingStartTime: startTime, // Use the same start time from when user sent message
84
95
  };
85
96
  addMessage(assistantMessage);
97
+ // Store the assistant message ID for potential cancellation
98
+ currentAssistantMessageIdRef.current = assistantMessageId;
99
+ // Reset cancellation flag for new message
100
+ wasCancelledRef.current = false;
86
101
  // Start receiving chunks (async iterator)
87
102
  const messageStream = client.receiveMessages();
88
103
  // Send ONLY the new message (not full history)
89
104
  // The agent backend now manages conversation context
90
105
  client
91
- .sendMessage(content, activeSessionId, attachments)
106
+ .sendMessage(content, activeSessionId, attachments, promptParameters)
92
107
  .catch((error) => {
93
108
  const message = error instanceof Error ? error.message : String(error);
94
109
  setError(message);
@@ -99,6 +114,11 @@ export function useChatMessages(client, startSession) {
99
114
  let accumulatedContent = "";
100
115
  let streamCompleted = false;
101
116
  for await (const chunk of messageStream) {
117
+ // Check if cancelled before processing each chunk
118
+ if (wasCancelledRef.current) {
119
+ logger.info("Stream cancelled, exiting loop");
120
+ break;
121
+ }
102
122
  // Handle different chunk types using discriminated union
103
123
  if (chunk.type === "content") {
104
124
  // Content chunk - text streaming
@@ -139,6 +159,7 @@ export function useChatMessages(client, startSession) {
139
159
  });
140
160
  setIsStreaming(false);
141
161
  setStreamingStartTime(null); // Clear global streaming start time
162
+ currentAssistantMessageIdRef.current = null; // Clear cancellation ref
142
163
  streamCompleted = true;
143
164
  break;
144
165
  }
@@ -150,8 +171,11 @@ export function useChatMessages(client, startSession) {
150
171
  content: accumulatedContent,
151
172
  ...(chunk.tokenUsage ? { tokenUsage: chunk.tokenUsage } : {}),
152
173
  });
153
- // Small delay to allow Ink to render between chunks (~60fps)
154
- await new Promise((resolve) => setTimeout(resolve, 16));
174
+ // Small delay to allow rendering between chunks (~60fps)
175
+ // Skip delay when tab is hidden to prevent browser throttling from blocking the stream
176
+ if (!document.hidden) {
177
+ await new Promise((resolve) => setTimeout(resolve, 16));
178
+ }
155
179
  }
156
180
  }
157
181
  }
@@ -177,16 +201,34 @@ export function useChatMessages(client, startSession) {
177
201
  // Add/update hook notification in current assistant message
178
202
  addHookNotificationToCurrentMessage(chunk.notification);
179
203
  }
204
+ else if (chunk.type === "sources") {
205
+ // Sources chunk - citation sources from tool calls
206
+ logger.debug("Received sources chunk", { chunk });
207
+ // Add sources to current assistant message for citation rendering
208
+ addSourcesToCurrentMessage(chunk.sources);
209
+ }
180
210
  }
181
211
  // Ensure streaming state is cleared even if no explicit isComplete was received
182
212
  if (!streamCompleted) {
183
- logger.warn("Stream ended without isComplete flag");
184
- updateMessage(assistantMessageId, {
185
- isStreaming: false,
186
- streamingStartTime: undefined,
187
- });
213
+ if (wasCancelledRef.current) {
214
+ // User cancelled - append "[Cancelled]" indicator
215
+ logger.info("Stream cancelled by user");
216
+ updateMessage(assistantMessageId, {
217
+ content: `${accumulatedContent}\n\n[Cancelled]`,
218
+ isStreaming: false,
219
+ streamingStartTime: undefined,
220
+ });
221
+ }
222
+ else {
223
+ logger.warn("Stream ended without isComplete flag");
224
+ updateMessage(assistantMessageId, {
225
+ isStreaming: false,
226
+ streamingStartTime: undefined,
227
+ });
228
+ }
188
229
  setIsStreaming(false);
189
230
  setStreamingStartTime(null);
231
+ currentAssistantMessageIdRef.current = null;
190
232
  }
191
233
  }
192
234
  catch (error) {
@@ -194,6 +236,7 @@ export function useChatMessages(client, startSession) {
194
236
  setError(message);
195
237
  setIsStreaming(false);
196
238
  setStreamingStartTime(null); // Clear streaming start time on error
239
+ currentAssistantMessageIdRef.current = null;
197
240
  // Ensure the assistant message isStreaming is set to false
198
241
  updateMessage(assistantMessageId, {
199
242
  isStreaming: false,
@@ -215,10 +258,251 @@ export function useChatMessages(client, startSession) {
215
258
  addToolCallToCurrentMessage,
216
259
  updateToolCallInCurrentMessage,
217
260
  addHookNotificationToCurrentMessage,
261
+ addSourcesToCurrentMessage,
262
+ ]);
263
+ /**
264
+ * Cancel the current agent turn
265
+ * Stops streaming and appends "[Cancelled]" to the message
266
+ */
267
+ const cancel = useCallback(async () => {
268
+ if (!client || !isStreaming) {
269
+ logger.debug("Cannot cancel: not streaming or no client");
270
+ return;
271
+ }
272
+ logger.info("Cancelling current turn");
273
+ // Set cancelled flag FIRST - the stream loop will check this
274
+ wasCancelledRef.current = true;
275
+ try {
276
+ // Call client cancel to stop the agent
277
+ await client.cancel(sessionId || undefined);
278
+ }
279
+ catch (error) {
280
+ logger.error("Error cancelling turn", { error });
281
+ }
282
+ // Note: The stream loop will handle updating the message with "[Cancelled]"
283
+ // and clearing the streaming state when it exits
284
+ }, [client, isStreaming, sessionId]);
285
+ /**
286
+ * Edit and resend a message from a specific point in the conversation.
287
+ * Truncates the conversation to the specified message and sends the new content.
288
+ *
289
+ * @param messageIndex - The index of the user message to edit (in UI messages array)
290
+ * @param newContent - The new text content
291
+ * @param attachments - Optional image attachments
292
+ */
293
+ const editAndResend = useCallback(async (userMessageIndex, newContent, attachments) => {
294
+ logger.debug("[editAndResend] Called with", {
295
+ userMessageIndex,
296
+ contentLength: newContent.length,
297
+ attachmentsCount: attachments?.length || 0,
298
+ });
299
+ if (!client) {
300
+ logger.error("No client available");
301
+ setError("No client available");
302
+ return;
303
+ }
304
+ if (!sessionId) {
305
+ logger.error("No session available");
306
+ setError("No session available");
307
+ return;
308
+ }
309
+ // Find the Nth user message (0-indexed) in the UI messages array
310
+ let userMessageCount = 0;
311
+ let targetArrayIndex = -1;
312
+ for (let i = 0; i < messages.length; i++) {
313
+ if (messages[i]?.role === "user") {
314
+ if (userMessageCount === userMessageIndex) {
315
+ targetArrayIndex = i;
316
+ break;
317
+ }
318
+ userMessageCount++;
319
+ }
320
+ }
321
+ if (targetArrayIndex === -1) {
322
+ logger.error("User message not found", {
323
+ userMessageIndex,
324
+ totalUserMessages: userMessageCount,
325
+ });
326
+ setError("User message not found");
327
+ return;
328
+ }
329
+ const targetMessage = messages[targetArrayIndex];
330
+ logger.debug("[editAndResend] Found target message", {
331
+ userMessageIndex,
332
+ targetArrayIndex,
333
+ messageRole: targetMessage?.role,
334
+ });
335
+ // Create assistant message ID outside try block so it's accessible in catch
336
+ const assistantMessageId = `msg_${Date.now()}_assistant`;
337
+ try {
338
+ // Start streaming and track time
339
+ const startTime = Date.now();
340
+ setIsStreaming(true);
341
+ setStreamingStartTime(startTime);
342
+ // Truncate UI messages - keep everything BEFORE the target message
343
+ truncateMessagesFrom(targetArrayIndex);
344
+ // Add the new user message to UI
345
+ const userMessage = {
346
+ id: `msg_${Date.now()}_user`,
347
+ role: "user",
348
+ content: newContent,
349
+ timestamp: new Date().toISOString(),
350
+ isStreaming: false,
351
+ ...(attachments && attachments.length > 0
352
+ ? {
353
+ images: attachments
354
+ .filter((a) => a.mimeType.startsWith("image/"))
355
+ .map((a) => ({ mimeType: a.mimeType, data: a.data })),
356
+ }
357
+ : {}),
358
+ };
359
+ addMessage(userMessage);
360
+ // Create placeholder for assistant message
361
+ const assistantMessage = {
362
+ id: assistantMessageId,
363
+ role: "assistant",
364
+ content: "",
365
+ timestamp: new Date().toISOString(),
366
+ isStreaming: true,
367
+ streamingStartTime: startTime,
368
+ };
369
+ addMessage(assistantMessage);
370
+ // Store the assistant message ID for potential cancellation
371
+ currentAssistantMessageIdRef.current = assistantMessageId;
372
+ wasCancelledRef.current = false;
373
+ // Start receiving chunks (async iterator)
374
+ const messageStream = client.receiveMessages();
375
+ // Call editAndResend on the backend
376
+ client
377
+ .editAndResend(userMessageIndex, newContent, sessionId, attachments)
378
+ .catch((error) => {
379
+ const message = error instanceof Error ? error.message : String(error);
380
+ setError(message);
381
+ setIsStreaming(false);
382
+ setStreamingStartTime(null);
383
+ });
384
+ // Listen for streaming chunks (same as sendMessage)
385
+ let accumulatedContent = "";
386
+ let streamCompleted = false;
387
+ for await (const chunk of messageStream) {
388
+ if (wasCancelledRef.current) {
389
+ logger.info("Stream cancelled, exiting loop");
390
+ break;
391
+ }
392
+ if (chunk.type === "content") {
393
+ // biome-ignore lint/suspicious/noExplicitAny: Accessing dynamic properties from streaming chunks
394
+ const chunkMeta = chunk._meta;
395
+ const contextSizeData =
396
+ // biome-ignore lint/suspicious/noExplicitAny: Accessing dynamic properties from streaming chunks
397
+ chunkMeta?.context_size || chunk.context_size;
398
+ if (contextSizeData != null) {
399
+ const contextSize = contextSizeData;
400
+ logger.info("✅ Received context_size from backend", {
401
+ context_size: contextSize,
402
+ totalEstimated: contextSize.totalEstimated,
403
+ });
404
+ setLatestContextSize(contextSize);
405
+ }
406
+ if (chunk.isComplete) {
407
+ updateMessage(assistantMessageId, {
408
+ content: accumulatedContent,
409
+ isStreaming: false,
410
+ streamingStartTime: undefined,
411
+ ...(chunk.tokenUsage ? { tokenUsage: chunk.tokenUsage } : {}),
412
+ });
413
+ setIsStreaming(false);
414
+ setStreamingStartTime(null);
415
+ currentAssistantMessageIdRef.current = null;
416
+ streamCompleted = true;
417
+ break;
418
+ }
419
+ else {
420
+ if (chunk.contentDelta.type === "text") {
421
+ accumulatedContent += chunk.contentDelta.text;
422
+ updateMessage(assistantMessageId, {
423
+ content: accumulatedContent,
424
+ ...(chunk.tokenUsage ? { tokenUsage: chunk.tokenUsage } : {}),
425
+ });
426
+ if (!document.hidden) {
427
+ await new Promise((resolve) => setTimeout(resolve, 16));
428
+ }
429
+ }
430
+ }
431
+ }
432
+ else if (chunk.type === "tool_call") {
433
+ logger.debug("Received tool_call chunk", { chunk });
434
+ addToolCall(sessionId, chunk.toolCall);
435
+ addToolCallToCurrentMessage(chunk.toolCall);
436
+ }
437
+ else if (chunk.type === "tool_call_update") {
438
+ logger.debug("Received tool_call_update chunk", { chunk });
439
+ updateToolCall(sessionId, chunk.toolCallUpdate);
440
+ updateToolCallInCurrentMessage(chunk.toolCallUpdate);
441
+ }
442
+ else if (chunk.type === "hook_notification") {
443
+ logger.debug("Received hook_notification chunk", { chunk });
444
+ addHookNotificationToCurrentMessage(chunk.notification);
445
+ }
446
+ else if (chunk.type === "sources") {
447
+ logger.debug("Received sources chunk", { chunk });
448
+ addSourcesToCurrentMessage(chunk.sources);
449
+ }
450
+ }
451
+ if (!streamCompleted) {
452
+ if (wasCancelledRef.current) {
453
+ logger.info("Stream cancelled by user");
454
+ updateMessage(assistantMessageId, {
455
+ content: `${accumulatedContent}\n\n[Cancelled]`,
456
+ isStreaming: false,
457
+ streamingStartTime: undefined,
458
+ });
459
+ }
460
+ else {
461
+ logger.warn("Stream ended without isComplete flag");
462
+ updateMessage(assistantMessageId, {
463
+ isStreaming: false,
464
+ streamingStartTime: undefined,
465
+ });
466
+ }
467
+ setIsStreaming(false);
468
+ setStreamingStartTime(null);
469
+ currentAssistantMessageIdRef.current = null;
470
+ }
471
+ }
472
+ catch (error) {
473
+ const message = error instanceof Error ? error.message : String(error);
474
+ setError(message);
475
+ setIsStreaming(false);
476
+ setStreamingStartTime(null);
477
+ currentAssistantMessageIdRef.current = null;
478
+ updateMessage(assistantMessageId, {
479
+ isStreaming: false,
480
+ streamingStartTime: undefined,
481
+ });
482
+ }
483
+ }, [
484
+ client,
485
+ sessionId,
486
+ messages,
487
+ truncateMessagesFrom,
488
+ addMessage,
489
+ updateMessage,
490
+ setIsStreaming,
491
+ setStreamingStartTime,
492
+ setError,
493
+ setLatestContextSize,
494
+ addToolCall,
495
+ updateToolCall,
496
+ addToolCallToCurrentMessage,
497
+ updateToolCallInCurrentMessage,
498
+ addHookNotificationToCurrentMessage,
499
+ addSourcesToCurrentMessage,
218
500
  ]);
219
501
  return {
220
502
  messages,
221
503
  isStreaming,
222
504
  sendMessage,
505
+ editAndResend,
506
+ cancel,
223
507
  };
224
508
  }
@@ -279,6 +279,15 @@ export declare const DisplayMessage: z.ZodObject<{
279
279
  mimeType: z.ZodString;
280
280
  data: z.ZodString;
281
281
  }, z.core.$strip>>>;
282
+ sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
283
+ id: z.ZodString;
284
+ url: z.ZodString;
285
+ title: z.ZodString;
286
+ snippet: z.ZodOptional<z.ZodString>;
287
+ favicon: z.ZodOptional<z.ZodString>;
288
+ toolCallId: z.ZodString;
289
+ sourceName: z.ZodOptional<z.ZodString>;
290
+ }, z.core.$strip>>>;
282
291
  }, z.core.$strip>;
283
292
  export type DisplayMessage = z.infer<typeof DisplayMessage>;
284
293
  /**
@@ -294,6 +303,7 @@ export declare const InputState: z.ZodObject<{
294
303
  mimeType: z.ZodString;
295
304
  data: z.ZodString;
296
305
  }, z.core.$strip>>;
306
+ selectedPromptParameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
297
307
  }, z.core.$strip>;
298
308
  export type InputState = z.infer<typeof InputState>;
299
309
  /**
@@ -533,6 +543,15 @@ export declare const ChatSessionState: z.ZodObject<{
533
543
  mimeType: z.ZodString;
534
544
  data: z.ZodString;
535
545
  }, z.core.$strip>>>;
546
+ sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
547
+ id: z.ZodString;
548
+ url: z.ZodString;
549
+ title: z.ZodString;
550
+ snippet: z.ZodOptional<z.ZodString>;
551
+ favicon: z.ZodOptional<z.ZodString>;
552
+ toolCallId: z.ZodString;
553
+ sourceName: z.ZodOptional<z.ZodString>;
554
+ }, z.core.$strip>>>;
536
555
  }, z.core.$strip>>;
537
556
  input: z.ZodObject<{
538
557
  value: z.ZodString;
@@ -544,6 +563,7 @@ export declare const ChatSessionState: z.ZodObject<{
544
563
  mimeType: z.ZodString;
545
564
  data: z.ZodString;
546
565
  }, z.core.$strip>>;
566
+ selectedPromptParameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
547
567
  }, z.core.$strip>;
548
568
  error: z.ZodNullable<z.ZodString>;
549
569
  }, z.core.$strip>;
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { HookNotification, HookType } from "../../sdk/schemas/message.js";
3
+ import { SourceSchema } from "./source.js";
3
4
  import { TokenUsageSchema, ToolCallSchema } from "./tool-call.js";
4
5
  /**
5
6
  * Chat UI state schemas
@@ -64,6 +65,7 @@ export const DisplayMessage = z.object({
64
65
  hookNotifications: z.array(HookNotificationDisplay).optional(), // Hook notifications for this message
65
66
  tokenUsage: TokenUsageSchema.optional(), // Token usage for this message
66
67
  images: z.array(DisplayImageAttachment).optional(), // Image attachments for user messages
68
+ sources: z.array(SourceSchema).optional(), // Citation sources from tool calls
67
69
  });
68
70
  /**
69
71
  * Input state schema
@@ -78,6 +80,8 @@ export const InputState = z.object({
78
80
  mimeType: z.string(),
79
81
  data: z.string(), // base64 encoded file data
80
82
  })),
83
+ /** Selected prompt parameters for per-message configuration. Maps parameter ID to selected option ID. */
84
+ selectedPromptParameters: z.record(z.string(), z.string()).optional(),
81
85
  });
82
86
  /**
83
87
  * Chat session UI state
@@ -2,4 +2,5 @@
2
2
  * Export all core schemas
3
3
  */
4
4
  export * from "./chat.js";
5
+ export * from "./source.js";
5
6
  export * from "./tool-call.js";
@@ -2,4 +2,5 @@
2
2
  * Export all core schemas
3
3
  */
4
4
  export * from "./chat.js";
5
+ export * from "./source.js";
5
6
  export * from "./tool-call.js";
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Citation source extracted from tool calls (WebSearch, WebFetch, MCP tools)
4
+ */
5
+ export declare const SourceSchema: z.ZodObject<{
6
+ id: z.ZodString;
7
+ url: z.ZodString;
8
+ title: z.ZodString;
9
+ snippet: z.ZodOptional<z.ZodString>;
10
+ favicon: z.ZodOptional<z.ZodString>;
11
+ toolCallId: z.ZodString;
12
+ sourceName: z.ZodOptional<z.ZodString>;
13
+ }, z.core.$strip>;
14
+ export type Source = z.infer<typeof SourceSchema>;
15
+ /**
16
+ * Helper to derive favicon URL from a domain
17
+ */
18
+ export declare function getFaviconUrl(url: string): string;
19
+ /**
20
+ * Helper to extract domain name from URL
21
+ */
22
+ export declare function getSourceName(url: string): string;
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Citation source extracted from tool calls (WebSearch, WebFetch, MCP tools)
4
+ */
5
+ export const SourceSchema = z.object({
6
+ /** Unique identifier for LLM reference (e.g., "1", "2") */
7
+ id: z.string(),
8
+ /** The URL of the source */
9
+ url: z.string().url(),
10
+ /** Title of the source page/article */
11
+ title: z.string(),
12
+ /** Optional snippet/excerpt from the source */
13
+ snippet: z.string().optional(),
14
+ /** Favicon URL (derived from domain) */
15
+ favicon: z.string().optional(),
16
+ /** The tool call ID that produced this source */
17
+ toolCallId: z.string(),
18
+ /** Source name/domain (e.g., "Reuters", "GitHub") */
19
+ sourceName: z.string().optional(),
20
+ });
21
+ /**
22
+ * Helper to derive favicon URL from a domain
23
+ */
24
+ export function getFaviconUrl(url) {
25
+ try {
26
+ const domain = new URL(url).hostname;
27
+ return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
28
+ }
29
+ catch {
30
+ return "";
31
+ }
32
+ }
33
+ /**
34
+ * Helper to extract domain name from URL
35
+ */
36
+ export function getSourceName(url) {
37
+ try {
38
+ const hostname = new URL(url).hostname;
39
+ // Remove www. prefix and get the main domain
40
+ return hostname.replace(/^www\./, "").split(".")[0] || hostname;
41
+ }
42
+ catch {
43
+ return "Unknown";
44
+ }
45
+ }
@@ -2,6 +2,7 @@ import { type LogEntry } from "@townco/core";
2
2
  import type { TodoItem } from "../../gui/components/TodoListItem.js";
3
3
  import type { HookNotification } from "../../sdk/schemas/message.js";
4
4
  import type { ConnectionStatus, DisplayMessage, InputState } from "../schemas/index.js";
5
+ import type { Source } from "../schemas/source.js";
5
6
  import type { ToolCall, ToolCallUpdate } from "../schemas/tool-call.js";
6
7
  /**
7
8
  * Selector to get todos for the current session (memoized to prevent infinite loops)
@@ -62,10 +63,12 @@ export interface ChatStore {
62
63
  addToolCallToCurrentMessage: (toolCall: ToolCall) => void;
63
64
  updateToolCallInCurrentMessage: (update: ToolCallUpdate) => void;
64
65
  addHookNotificationToCurrentMessage: (notification: HookNotification) => void;
66
+ addSourcesToCurrentMessage: (sources: Source[]) => void;
65
67
  setInputValue: (value: string) => void;
66
68
  setInputSubmitting: (submitting: boolean) => void;
67
69
  addFileAttachment: (file: InputState["attachedFiles"][number]) => void;
68
70
  removeFileAttachment: (index: number) => void;
71
+ setSelectedPromptParameters: (params: Record<string, string>) => void;
69
72
  clearInput: () => void;
70
73
  addTokenUsage: (tokenUsage: {
71
74
  inputTokens?: number;
@@ -79,6 +82,7 @@ export interface ChatStore {
79
82
  addLog: (log: LogEntry) => void;
80
83
  clearLogs: () => void;
81
84
  setActiveTab: (tab: "chat" | "logs") => void;
85
+ truncateMessagesFrom: (messageIndex: number) => void;
82
86
  }
83
87
  /**
84
88
  * Create chat store