@townco/ui 0.1.82 → 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
@@ -219,11 +219,11 @@ export function getDuration(shouldReduceMotion, duration = motionDuration.normal
219
219
  // Sidebar Animations (AppSidebar)
220
220
  // ============================================================================
221
221
  export const sidebarTransition = {
222
- duration: 0.5,
222
+ duration: 0.25,
223
223
  ease: motionEasing.smooth,
224
224
  };
225
225
  export const sidebarMobileTransition = {
226
- duration: 0.3,
226
+ duration: 0.15,
227
227
  ease: motionEasing.smooth,
228
228
  };
229
229
  // Desktop: Slide animation (fixed width, no reflow)
@@ -244,9 +244,9 @@ export const sidebarContentVariants = {
244
244
  animate: { opacity: 1, x: 0 },
245
245
  };
246
246
  export const sidebarContentTransition = {
247
- duration: 0.5,
247
+ duration: 0.25,
248
248
  ease: motionEasing.smooth,
249
- delay: 0.25,
249
+ delay: 0.1,
250
250
  };
251
251
  // Backdrop animation for mobile overlay
252
252
  export const backdropVariants = {
@@ -275,12 +275,12 @@ export const asideContentVariants = {
275
275
  animate: { opacity: 1, x: 0 },
276
276
  };
277
277
  export const asideContentTransition = {
278
- duration: 0.35,
278
+ duration: 0.25,
279
279
  ease: motionEasing.smooth,
280
280
  delay: 0.1,
281
281
  };
282
282
  // Mobile transition (faster, matches sidebar)
283
283
  export const asideMobileTransition = {
284
- duration: 0.3,
284
+ duration: 0.15,
285
285
  ease: motionEasing.smooth,
286
286
  };
@@ -0,0 +1,28 @@
1
+ import type { Root } from "mdast";
2
+ /**
3
+ * Citation node type for the MDAST
4
+ * Uses 'span' as hName since Streamdown only supports standard HTML elements
5
+ */
6
+ export interface CitationNode {
7
+ type: "citation";
8
+ data: {
9
+ hName: "span";
10
+ hProperties: {
11
+ "data-citation-id": string;
12
+ className: "citation-marker";
13
+ };
14
+ };
15
+ sourceId: string;
16
+ }
17
+ /**
18
+ * Remark plugin that transforms citation syntax into citation nodes.
19
+ *
20
+ * Finds patterns like [[1]], [[2]], [[12]] (double brackets) or
21
+ * [1], [2], [12] (single brackets) in text and converts them
22
+ * to custom citation elements that can be rendered with a custom component.
23
+ *
24
+ * Single-bracket citations are only matched when NOT followed by:
25
+ * - `(` - would be a markdown link [text](url)
26
+ * - `:` - would be a markdown reference definition [1]: url
27
+ */
28
+ export declare function remarkCitations(): (tree: Root) => void;
@@ -0,0 +1,70 @@
1
+ import { visit } from "unist-util-visit";
2
+ /**
3
+ * Remark plugin that transforms citation syntax into citation nodes.
4
+ *
5
+ * Finds patterns like [[1]], [[2]], [[12]] (double brackets) or
6
+ * [1], [2], [12] (single brackets) in text and converts them
7
+ * to custom citation elements that can be rendered with a custom component.
8
+ *
9
+ * Single-bracket citations are only matched when NOT followed by:
10
+ * - `(` - would be a markdown link [text](url)
11
+ * - `:` - would be a markdown reference definition [1]: url
12
+ */
13
+ export function remarkCitations() {
14
+ return (tree) => {
15
+ visit(tree, "text", (node, index, parent) => {
16
+ if (!parent || index === undefined)
17
+ return;
18
+ // Match both [[N]] (double brackets) and [N] (single brackets)
19
+ // Single brackets use negative lookahead to avoid matching markdown links/refs
20
+ const citationRegex = /\[\[(\d+)\]\]|\[(\d+)\](?![:(])/g;
21
+ const text = node.value;
22
+ // Check if there are any citations in this text
23
+ if (!citationRegex.test(text))
24
+ return;
25
+ // Reset regex state
26
+ citationRegex.lastIndex = 0;
27
+ // Split text by citations and create mixed content
28
+ const parts = [];
29
+ let lastIndex = 0;
30
+ let match;
31
+ // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex exec loop pattern
32
+ while ((match = citationRegex.exec(text)) !== null) {
33
+ // Add text before the citation
34
+ if (match.index > lastIndex) {
35
+ parts.push({
36
+ type: "text",
37
+ value: text.slice(lastIndex, match.index),
38
+ });
39
+ }
40
+ // Add the citation node
41
+ // sourceId is in match[1] for [[N]] format, or match[2] for [N] format
42
+ const sourceId = match[1] ?? match[2] ?? "";
43
+ parts.push({
44
+ type: "citation",
45
+ data: {
46
+ hName: "span",
47
+ hProperties: {
48
+ "data-citation-id": sourceId,
49
+ className: "citation-marker",
50
+ },
51
+ },
52
+ sourceId,
53
+ });
54
+ lastIndex = match.index + match[0].length;
55
+ }
56
+ // Add remaining text after last citation
57
+ if (lastIndex < text.length) {
58
+ parts.push({
59
+ type: "text",
60
+ value: text.slice(lastIndex),
61
+ });
62
+ }
63
+ // Replace the original text node with the new nodes
64
+ if (parts.length > 0) {
65
+ // biome-ignore lint/suspicious/noExplicitAny: Parent children array needs dynamic assignment
66
+ parent.children.splice(index, 1, ...parts);
67
+ }
68
+ });
69
+ };
70
+ }
@@ -54,6 +54,10 @@ export declare class AcpClient {
54
54
  listSessions(): Promise<SessionSummary[]>;
55
55
  /**
56
56
  * Send a message in the current session
57
+ * @param content - The message text content
58
+ * @param sessionId - Optional session ID (defaults to current session)
59
+ * @param attachments - Optional image attachments
60
+ * @param promptParameters - Optional per-message parameters to influence agent behavior
57
61
  */
58
62
  sendMessage(content: string, sessionId?: string, attachments?: Array<{
59
63
  name: string;
@@ -61,11 +65,33 @@ export declare class AcpClient {
61
65
  size: number;
62
66
  mimeType: string;
63
67
  data: string;
64
- }>): Promise<void>;
68
+ }>, promptParameters?: Record<string, string>): Promise<void>;
65
69
  /**
66
70
  * Receive messages from the agent (streaming)
67
71
  */
68
72
  receiveMessages(): AsyncIterableIterator<MessageChunk>;
73
+ /**
74
+ * Cancel the current agent turn
75
+ * Stops streaming, tool execution, and sub-agent processes
76
+ */
77
+ cancel(sessionId?: string): Promise<void>;
78
+ /**
79
+ * Edit and resend a message from a specific point in the conversation.
80
+ * This truncates the session history to the specified message index
81
+ * and sends the new content.
82
+ *
83
+ * @param messageIndex - The index of the user message to replace (0-based)
84
+ * @param content - The new text content
85
+ * @param sessionId - Optional session ID (defaults to current session)
86
+ * @param attachments - Optional image attachments
87
+ */
88
+ editAndResend(messageIndex: number, content: string, sessionId?: string, attachments?: Array<{
89
+ name: string;
90
+ path: string;
91
+ size: number;
92
+ mimeType: string;
93
+ data: string;
94
+ }>): Promise<void>;
69
95
  /**
70
96
  * Get a session by ID
71
97
  */
@@ -94,6 +120,7 @@ export declare class AcpClient {
94
120
  * - mcps: List of MCP servers connected to the agent
95
121
  * - subagents: List of subagents available via Task tool
96
122
  * - uiConfig: UI configuration for interface appearance
123
+ * - promptParameters: Configurable parameters users can select per-message
97
124
  */
98
125
  getAgentInfo(): {
99
126
  name?: string;
@@ -118,6 +145,16 @@ export declare class AcpClient {
118
145
  name: string;
119
146
  description: string;
120
147
  }>;
148
+ promptParameters?: Array<{
149
+ id: string;
150
+ label: string;
151
+ description?: string;
152
+ options: Array<{
153
+ id: string;
154
+ label: string;
155
+ }>;
156
+ defaultOptionId?: string;
157
+ }>;
121
158
  };
122
159
  /**
123
160
  * Create transport based on explicit configuration
@@ -142,8 +142,12 @@ export class AcpClient {
142
142
  }
143
143
  /**
144
144
  * Send a message in the current session
145
+ * @param content - The message text content
146
+ * @param sessionId - Optional session ID (defaults to current session)
147
+ * @param attachments - Optional image attachments
148
+ * @param promptParameters - Optional per-message parameters to influence agent behavior
145
149
  */
146
- async sendMessage(content, sessionId, attachments) {
150
+ async sendMessage(content, sessionId, attachments, promptParameters) {
147
151
  const targetSessionId = sessionId || this.currentSessionId;
148
152
  if (!targetSessionId) {
149
153
  throw new Error("No active session. Start a session first.");
@@ -188,8 +192,8 @@ export class AcpClient {
188
192
  // Add to session
189
193
  session.messages.push(message);
190
194
  this.updateSessionStatus(targetSessionId, "active");
191
- // Send through transport
192
- await this.transport.send(message);
195
+ // Send through transport with optional promptParameters
196
+ await this.transport.send(message, promptParameters ? { promptParameters } : undefined);
193
197
  }
194
198
  /**
195
199
  * Receive messages from the agent (streaming)
@@ -200,6 +204,65 @@ export class AcpClient {
200
204
  }
201
205
  yield* this.transport.receive();
202
206
  }
207
+ /**
208
+ * Cancel the current agent turn
209
+ * Stops streaming, tool execution, and sub-agent processes
210
+ */
211
+ async cancel(sessionId) {
212
+ const targetSessionId = sessionId || this.currentSessionId;
213
+ if (!targetSessionId) {
214
+ logger.warn("Cannot cancel: no session ID");
215
+ return;
216
+ }
217
+ logger.info("Cancelling session", { sessionId: targetSessionId });
218
+ await this.transport.cancel(targetSessionId);
219
+ }
220
+ /**
221
+ * Edit and resend a message from a specific point in the conversation.
222
+ * This truncates the session history to the specified message index
223
+ * and sends the new content.
224
+ *
225
+ * @param messageIndex - The index of the user message to replace (0-based)
226
+ * @param content - The new text content
227
+ * @param sessionId - Optional session ID (defaults to current session)
228
+ * @param attachments - Optional image attachments
229
+ */
230
+ async editAndResend(messageIndex, content, sessionId, attachments) {
231
+ const targetSessionId = sessionId || this.currentSessionId;
232
+ if (!targetSessionId) {
233
+ throw new Error("No active session. Start a session first.");
234
+ }
235
+ if (!this.transport.isConnected()) {
236
+ throw new Error("Transport not connected");
237
+ }
238
+ if (!this.transport.editAndResend) {
239
+ throw new Error("Transport does not support edit and resend");
240
+ }
241
+ // Build prompt content blocks (images first, then text)
242
+ const prompt = [];
243
+ // Add image attachments
244
+ if (attachments && attachments.length > 0) {
245
+ for (const attachment of attachments) {
246
+ if (attachment.mimeType.startsWith("image/")) {
247
+ prompt.push({
248
+ type: "image",
249
+ data: attachment.data,
250
+ mimeType: attachment.mimeType,
251
+ });
252
+ }
253
+ }
254
+ }
255
+ // Add text content
256
+ prompt.push({
257
+ type: "text",
258
+ text: content,
259
+ });
260
+ logger.info("Edit and resend", {
261
+ sessionId: targetSessionId,
262
+ messageIndex,
263
+ });
264
+ await this.transport.editAndResend(targetSessionId, messageIndex, prompt);
265
+ }
203
266
  /**
204
267
  * Get a session by ID
205
268
  */
@@ -246,6 +309,7 @@ export class AcpClient {
246
309
  * - mcps: List of MCP servers connected to the agent
247
310
  * - subagents: List of subagents available via Task tool
248
311
  * - uiConfig: UI configuration for interface appearance
312
+ * - promptParameters: Configurable parameters users can select per-message
249
313
  */
250
314
  getAgentInfo() {
251
315
  return this.transport.getAgentInfo?.() || {};
@@ -406,6 +406,35 @@ export declare const HookNotificationChunk: z.ZodObject<{
406
406
  messageId: z.ZodOptional<z.ZodString>;
407
407
  }, z.core.$strip>;
408
408
  export type HookNotificationChunk = z.infer<typeof HookNotificationChunk>;
409
+ /**
410
+ * Citation source schema (for inline citations from tool calls)
411
+ */
412
+ export declare const CitationSource: z.ZodObject<{
413
+ id: z.ZodString;
414
+ url: z.ZodString;
415
+ title: z.ZodString;
416
+ snippet: z.ZodOptional<z.ZodString>;
417
+ favicon: z.ZodOptional<z.ZodString>;
418
+ toolCallId: z.ZodString;
419
+ sourceName: z.ZodOptional<z.ZodString>;
420
+ }, z.core.$strip>;
421
+ export type CitationSource = z.infer<typeof CitationSource>;
422
+ /**
423
+ * Sources chunk - for streaming citation sources from tool calls
424
+ */
425
+ export declare const SourcesChunk: z.ZodObject<{
426
+ type: z.ZodLiteral<"sources">;
427
+ sources: z.ZodArray<z.ZodObject<{
428
+ id: z.ZodString;
429
+ url: z.ZodString;
430
+ title: z.ZodString;
431
+ snippet: z.ZodOptional<z.ZodString>;
432
+ favicon: z.ZodOptional<z.ZodString>;
433
+ toolCallId: z.ZodString;
434
+ sourceName: z.ZodOptional<z.ZodString>;
435
+ }, z.core.$strip>>;
436
+ }, z.core.$strip>;
437
+ export type SourcesChunk = z.infer<typeof SourcesChunk>;
409
438
  /**
410
439
  * Message chunk - discriminated union of all chunk types
411
440
  */
@@ -520,5 +549,16 @@ export declare const MessageChunk: z.ZodDiscriminatedUnion<[z.ZodObject<{
520
549
  toolCallId: z.ZodOptional<z.ZodString>;
521
550
  }, z.core.$strip>], "type">;
522
551
  messageId: z.ZodOptional<z.ZodString>;
552
+ }, z.core.$strip>, z.ZodObject<{
553
+ type: z.ZodLiteral<"sources">;
554
+ sources: z.ZodArray<z.ZodObject<{
555
+ id: z.ZodString;
556
+ url: z.ZodString;
557
+ title: z.ZodString;
558
+ snippet: z.ZodOptional<z.ZodString>;
559
+ favicon: z.ZodOptional<z.ZodString>;
560
+ toolCallId: z.ZodString;
561
+ sourceName: z.ZodOptional<z.ZodString>;
562
+ }, z.core.$strip>>;
523
563
  }, z.core.$strip>], "type">;
524
564
  export type MessageChunk = z.infer<typeof MessageChunk>;
@@ -209,6 +209,25 @@ export const HookNotificationChunk = z.object({
209
209
  notification: HookNotification,
210
210
  messageId: z.string().optional(),
211
211
  });
212
+ /**
213
+ * Citation source schema (for inline citations from tool calls)
214
+ */
215
+ export const CitationSource = z.object({
216
+ id: z.string(),
217
+ url: z.string().url(),
218
+ title: z.string(),
219
+ snippet: z.string().optional(),
220
+ favicon: z.string().optional(),
221
+ toolCallId: z.string(),
222
+ sourceName: z.string().optional(),
223
+ });
224
+ /**
225
+ * Sources chunk - for streaming citation sources from tool calls
226
+ */
227
+ export const SourcesChunk = z.object({
228
+ type: z.literal("sources"),
229
+ sources: z.array(CitationSource),
230
+ });
212
231
  /**
213
232
  * Message chunk - discriminated union of all chunk types
214
233
  */
@@ -217,4 +236,5 @@ export const MessageChunk = z.discriminatedUnion("type", [
217
236
  ToolCallChunk,
218
237
  ToolCallUpdateChunk,
219
238
  HookNotificationChunk,
239
+ SourcesChunk,
220
240
  ]);
@@ -34,7 +34,19 @@ export declare class HttpTransport implements Transport {
34
34
  */
35
35
  listSessions(): Promise<SessionSummary[]>;
36
36
  disconnect(): Promise<void>;
37
- send(message: Message): Promise<void>;
37
+ send(message: Message, options?: {
38
+ promptParameters?: Record<string, string>;
39
+ }): Promise<void>;
40
+ cancel(sessionId: string): Promise<void>;
41
+ /**
42
+ * Edit and resend a message from a specific point in the conversation.
43
+ * This truncates the session history and sends the new prompt.
44
+ */
45
+ editAndResend(sessionId: string, messageIndex: number, prompt: Array<{
46
+ type: string;
47
+ text?: string;
48
+ [key: string]: unknown;
49
+ }>): Promise<void>;
38
50
  receive(): AsyncIterableIterator<MessageChunk>;
39
51
  isConnected(): boolean;
40
52
  onSessionUpdate(callback: (update: SessionUpdate) => void): () => void;
@@ -66,7 +78,18 @@ export declare class HttpTransport implements Transport {
66
78
  name: string;
67
79
  description: string;
68
80
  }>;
81
+ promptParameters?: Array<{
82
+ id: string;
83
+ label: string;
84
+ description?: string;
85
+ options: Array<{
86
+ id: string;
87
+ label: string;
88
+ }>;
89
+ defaultOptionId?: string;
90
+ }>;
69
91
  };
92
+ private sendNotification;
70
93
  private sendRpcRequest;
71
94
  private connectSSE;
72
95
  private handleSSEDisconnect;
@@ -86,6 +86,11 @@ export class HttpTransport {
86
86
  typeof meta.uiConfig === "object"
87
87
  ? meta.uiConfig
88
88
  : undefined;
89
+ const promptParameters = metaIsObject &&
90
+ "promptParameters" in meta &&
91
+ Array.isArray(meta.promptParameters)
92
+ ? meta.promptParameters
93
+ : undefined;
89
94
  this.agentInfo = {
90
95
  name: initResponse.agentInfo.name,
91
96
  // title is the ACP field for human-readable display name
@@ -100,6 +105,7 @@ export class HttpTransport {
100
105
  ...(tools ? { tools } : {}),
101
106
  ...(mcps ? { mcps } : {}),
102
107
  ...(subagents ? { subagents } : {}),
108
+ ...(promptParameters ? { promptParameters } : {}),
103
109
  };
104
110
  }
105
111
  logger.info("ACP connection initialized", { initResponse });
@@ -181,6 +187,11 @@ export class HttpTransport {
181
187
  typeof meta.uiConfig === "object"
182
188
  ? meta.uiConfig
183
189
  : undefined;
190
+ const promptParameters = metaIsObject &&
191
+ "promptParameters" in meta &&
192
+ Array.isArray(meta.promptParameters)
193
+ ? meta.promptParameters
194
+ : undefined;
184
195
  this.agentInfo = {
185
196
  name: initResponse.agentInfo.name,
186
197
  // title is the ACP field for human-readable display name
@@ -195,6 +206,7 @@ export class HttpTransport {
195
206
  ...(tools ? { tools } : {}),
196
207
  ...(mcps ? { mcps } : {}),
197
208
  ...(subagents ? { subagents } : {}),
209
+ ...(promptParameters ? { promptParameters } : {}),
198
210
  };
199
211
  }
200
212
  // Check if loadSession is supported
@@ -294,7 +306,7 @@ export class HttpTransport {
294
306
  throw err;
295
307
  }
296
308
  }
297
- async send(message) {
309
+ async send(message, options) {
298
310
  if (!this.connected || !this.currentSessionId) {
299
311
  throw new Error("Transport not connected");
300
312
  }
@@ -346,6 +358,12 @@ export class HttpTransport {
346
358
  sessionId: this.currentSessionId,
347
359
  prompt: promptBlocks,
348
360
  };
361
+ // Add promptParameters to _meta if provided
362
+ if (options?.promptParameters) {
363
+ promptRequest._meta = {
364
+ promptParameters: options.promptParameters,
365
+ };
366
+ }
349
367
  // Send the prompt - this will trigger streaming responses via SSE
350
368
  const promptResponse = await this.sendRpcRequest("session/prompt", promptRequest);
351
369
  logger.debug("Prompt sent", { promptResponse });
@@ -379,6 +397,116 @@ export class HttpTransport {
379
397
  throw err;
380
398
  }
381
399
  }
400
+ async cancel(sessionId) {
401
+ if (!this.connected) {
402
+ logger.warn("Cannot cancel: transport not connected");
403
+ return;
404
+ }
405
+ const targetSessionId = sessionId || this.currentSessionId;
406
+ if (!targetSessionId) {
407
+ logger.warn("Cannot cancel: no session ID");
408
+ return;
409
+ }
410
+ logger.info("Cancelling session", { sessionId: targetSessionId });
411
+ // Mark stream as complete FIRST to stop processing new messages
412
+ this.streamComplete = true;
413
+ // Clear any queued messages to prevent them from being processed
414
+ this.messageQueue.length = 0;
415
+ // Send cancel notification to the agent (notification = no id, no response expected)
416
+ await this.sendNotification("session/cancel", {
417
+ sessionId: targetSessionId,
418
+ });
419
+ // Resolve any pending chunk resolvers with a completion marker
420
+ const completionChunk = {
421
+ type: "content",
422
+ id: targetSessionId,
423
+ role: "assistant",
424
+ contentDelta: { type: "text", text: "" },
425
+ isComplete: true,
426
+ };
427
+ // Drain all pending resolvers
428
+ while (this.chunkResolvers.length > 0) {
429
+ const resolver = this.chunkResolvers.shift();
430
+ if (resolver) {
431
+ resolver(completionChunk);
432
+ }
433
+ }
434
+ }
435
+ /**
436
+ * Edit and resend a message from a specific point in the conversation.
437
+ * This truncates the session history and sends the new prompt.
438
+ */
439
+ async editAndResend(sessionId, messageIndex, prompt) {
440
+ if (!this.connected) {
441
+ throw new Error("Transport not connected");
442
+ }
443
+ const targetSessionId = sessionId || this.currentSessionId;
444
+ if (!targetSessionId) {
445
+ throw new Error("No session ID available");
446
+ }
447
+ // Exit replay mode when user sends their first message
448
+ if (this.isInReplayMode) {
449
+ logger.info("Exiting replay mode - user edited a message");
450
+ this.isInReplayMode = false;
451
+ }
452
+ // Reset stream state for new message
453
+ this.streamComplete = false;
454
+ this.messageQueue = [];
455
+ logger.info("Edit and resend", {
456
+ sessionId: targetSessionId,
457
+ messageIndex,
458
+ });
459
+ const headers = {
460
+ "Content-Type": "application/json",
461
+ ...this.options.headers,
462
+ };
463
+ const timeoutMs = this.options.timeout ?? 10 * 60 * 1000;
464
+ const controller = new AbortController();
465
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
466
+ try {
467
+ const response = await fetch(`${this.options.baseUrl}/sessions/${targetSessionId}/edit-and-resend`, {
468
+ method: "POST",
469
+ headers,
470
+ body: JSON.stringify({ messageIndex, prompt }),
471
+ signal: controller.signal,
472
+ });
473
+ clearTimeout(timeoutId);
474
+ if (!response.ok) {
475
+ const errorText = await response.text();
476
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
477
+ }
478
+ // Mark stream as complete after edit-and-resend finishes
479
+ this.streamComplete = true;
480
+ // Send completion chunk
481
+ const resolver = this.chunkResolvers.shift();
482
+ if (resolver) {
483
+ resolver({
484
+ type: "content",
485
+ id: targetSessionId,
486
+ role: "assistant",
487
+ contentDelta: { type: "text", text: "" },
488
+ isComplete: true,
489
+ });
490
+ }
491
+ else {
492
+ this.messageQueue.push({
493
+ type: "content",
494
+ id: targetSessionId,
495
+ role: "assistant",
496
+ contentDelta: { type: "text", text: "" },
497
+ isComplete: true,
498
+ });
499
+ }
500
+ }
501
+ catch (error) {
502
+ clearTimeout(timeoutId);
503
+ this.streamComplete = true;
504
+ if (error instanceof Error && error.name === "AbortError") {
505
+ throw new Error(`Request timeout after ${timeoutMs}ms`);
506
+ }
507
+ throw error;
508
+ }
509
+ }
382
510
  async *receive() {
383
511
  // Mark that we're actively receiving messages (prevents duplicate session updates)
384
512
  this.isReceivingMessages = true;
@@ -448,6 +576,39 @@ export class HttpTransport {
448
576
  getAgentInfo() {
449
577
  return this.agentInfo || {};
450
578
  }
579
+ /**
580
+ * Send an ACP notification to the server (no response expected)
581
+ */
582
+ async sendNotification(method, params) {
583
+ // Construct ACP notification message (no id = notification)
584
+ const notification = {
585
+ jsonrpc: "2.0",
586
+ method,
587
+ params,
588
+ };
589
+ logger.debug("Sending notification", { method, params });
590
+ const headers = {
591
+ "Content-Type": "application/json",
592
+ ...this.options.headers,
593
+ };
594
+ try {
595
+ const response = await fetch(`${this.options.baseUrl}/rpc`, {
596
+ method: "POST",
597
+ headers,
598
+ body: JSON.stringify(notification),
599
+ });
600
+ if (!response.ok) {
601
+ const errorText = await response.text();
602
+ logger.error("Notification failed", {
603
+ status: response.status,
604
+ errorText,
605
+ });
606
+ }
607
+ }
608
+ catch (error) {
609
+ logger.error("Error sending notification", { error });
610
+ }
611
+ }
451
612
  /**
452
613
  * Send an ACP RPC request to the server
453
614
  */
@@ -661,6 +822,11 @@ export class HttpTransport {
661
822
  * Handle a session notification from the agent
662
823
  */
663
824
  handleSessionNotification(params) {
825
+ // Skip processing if stream has been cancelled/completed
826
+ if (this.streamComplete) {
827
+ logger.debug("Skipping session notification - stream complete/cancelled");
828
+ return;
829
+ }
664
830
  logger.debug("handleSessionNotification called", { params });
665
831
  // Extract content from the update
666
832
  const paramsExtended = params;
@@ -1207,6 +1373,28 @@ export class HttpTransport {
1207
1373
  sessionUpdate,
1208
1374
  });
1209
1375
  }
1376
+ else if (update &&
1377
+ "sessionUpdate" in update &&
1378
+ update.sessionUpdate === "sources") {
1379
+ // Sources notification - citation sources from tool calls
1380
+ const sourcesUpdate = update;
1381
+ logger.debug("Received sources notification", {
1382
+ sourcesCount: sourcesUpdate.sources.length,
1383
+ });
1384
+ // Create a sources chunk for the message queue
1385
+ const sourcesChunk = {
1386
+ type: "sources",
1387
+ sources: sourcesUpdate.sources,
1388
+ };
1389
+ // Queue for ordered processing
1390
+ const resolver = this.chunkResolvers.shift();
1391
+ if (resolver) {
1392
+ resolver(sourcesChunk);
1393
+ }
1394
+ else {
1395
+ this.messageQueue.push(sourcesChunk);
1396
+ }
1397
+ }
1210
1398
  else if (update?.sessionUpdate === "agent_message_chunk") {
1211
1399
  // Check if this is a replay (not live streaming)
1212
1400
  const isReplay = update._meta &&