@meowlynxsea/koi 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 (109) hide show
  1. package/LICENSE +34 -0
  2. package/NOTICE +35 -0
  3. package/README.md +15 -0
  4. package/bin/koi +12 -0
  5. package/dist/highlights-eq9cgrbb.scm +604 -0
  6. package/dist/highlights-ghv9g403.scm +205 -0
  7. package/dist/highlights-hk7bwhj4.scm +284 -0
  8. package/dist/highlights-r812a2qc.scm +150 -0
  9. package/dist/highlights-x6tmsnaa.scm +115 -0
  10. package/dist/injections-73j83es3.scm +27 -0
  11. package/dist/main.js +489918 -0
  12. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  13. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  14. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  15. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  16. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  17. package/package.json +51 -0
  18. package/src/agent/check-permissions.ts +239 -0
  19. package/src/agent/hooks/message-utils.ts +305 -0
  20. package/src/agent/hooks/types.ts +32 -0
  21. package/src/agent/hooks.ts +1560 -0
  22. package/src/agent/mode.ts +163 -0
  23. package/src/agent/monitor-registry.ts +308 -0
  24. package/src/agent/permission-ui.ts +71 -0
  25. package/src/agent/plan-ui.ts +74 -0
  26. package/src/agent/question-ui.ts +58 -0
  27. package/src/agent/session-fork.ts +299 -0
  28. package/src/agent/session-snapshots.ts +216 -0
  29. package/src/agent/session-store.ts +649 -0
  30. package/src/agent/session-tasks.ts +305 -0
  31. package/src/agent/session.ts +27 -0
  32. package/src/agent/subagent-registry.ts +176 -0
  33. package/src/agent/subagent.ts +194 -0
  34. package/src/agent/tool-orchestration.ts +55 -0
  35. package/src/agent/tools.ts +8 -0
  36. package/src/cli/args.ts +6 -0
  37. package/src/cli/commands.ts +5 -0
  38. package/src/commands/skills/index.ts +23 -0
  39. package/src/config/models.ts +6 -0
  40. package/src/config/settings.ts +392 -0
  41. package/src/main.tsx +64 -0
  42. package/src/services/mcp/client.ts +194 -0
  43. package/src/services/mcp/config.ts +232 -0
  44. package/src/services/mcp/connection-manager.ts +258 -0
  45. package/src/services/mcp/index.ts +80 -0
  46. package/src/services/mcp/mcp-commands.ts +114 -0
  47. package/src/services/mcp/stdio-transport.ts +246 -0
  48. package/src/services/mcp/types.ts +155 -0
  49. package/src/skills/SkillsMenu.tsx +370 -0
  50. package/src/skills/bundled/batch.ts +106 -0
  51. package/src/skills/bundled/debug.ts +86 -0
  52. package/src/skills/bundled/loremIpsum.ts +101 -0
  53. package/src/skills/bundled/remember.ts +97 -0
  54. package/src/skills/bundled/simplify.ts +100 -0
  55. package/src/skills/bundled/skillify.ts +123 -0
  56. package/src/skills/bundled/stuck.ts +101 -0
  57. package/src/skills/bundled/updateConfig.ts +228 -0
  58. package/src/skills/bundled.ts +46 -0
  59. package/src/skills/frontmatter.ts +179 -0
  60. package/src/skills/index.ts +87 -0
  61. package/src/skills/invoke.ts +231 -0
  62. package/src/skills/loader.ts +710 -0
  63. package/src/skills/substitution.ts +169 -0
  64. package/src/skills/types.ts +201 -0
  65. package/src/tools/agent.ts +143 -0
  66. package/src/tools/ask-user-question.ts +46 -0
  67. package/src/tools/bash.ts +148 -0
  68. package/src/tools/edit.ts +164 -0
  69. package/src/tools/glob.ts +102 -0
  70. package/src/tools/grep.ts +248 -0
  71. package/src/tools/index.ts +73 -0
  72. package/src/tools/list-mcp-resources.ts +74 -0
  73. package/src/tools/ls.ts +85 -0
  74. package/src/tools/mcp.ts +76 -0
  75. package/src/tools/monitor.ts +159 -0
  76. package/src/tools/plan-mode.ts +134 -0
  77. package/src/tools/read-mcp-resource.ts +79 -0
  78. package/src/tools/read.ts +137 -0
  79. package/src/tools/skill.ts +176 -0
  80. package/src/tools/task.ts +349 -0
  81. package/src/tools/types.ts +52 -0
  82. package/src/tools/webfetch-domains.ts +239 -0
  83. package/src/tools/webfetch.ts +533 -0
  84. package/src/tools/write.ts +101 -0
  85. package/src/tui/app.tsx +1178 -0
  86. package/src/tui/components/chat-panel.tsx +1071 -0
  87. package/src/tui/components/command-panel.tsx +261 -0
  88. package/src/tui/components/confirm-modal.tsx +135 -0
  89. package/src/tui/components/connect-modal.tsx +435 -0
  90. package/src/tui/components/connecting-modal.tsx +167 -0
  91. package/src/tui/components/edit-pending-modal.tsx +103 -0
  92. package/src/tui/components/exit-modal.tsx +131 -0
  93. package/src/tui/components/fork-modal.tsx +377 -0
  94. package/src/tui/components/image-preview-modal.tsx +141 -0
  95. package/src/tui/components/image-utils.ts +128 -0
  96. package/src/tui/components/info-bar.tsx +103 -0
  97. package/src/tui/components/input-box.tsx +352 -0
  98. package/src/tui/components/mcp/MCPSettings.tsx +386 -0
  99. package/src/tui/components/mcp/index.ts +7 -0
  100. package/src/tui/components/model-modal.tsx +310 -0
  101. package/src/tui/components/pending-area.tsx +88 -0
  102. package/src/tui/components/rename-modal.tsx +119 -0
  103. package/src/tui/components/session-modal.tsx +233 -0
  104. package/src/tui/components/side-bar.tsx +349 -0
  105. package/src/tui/components/tool-output.ts +6 -0
  106. package/src/tui/hooks/user-prompt-history.ts +114 -0
  107. package/src/tui/theme.ts +63 -0
  108. package/src/types/commands.ts +80 -0
  109. package/src/types/cross-spawn.d.ts +24 -0
@@ -0,0 +1,1560 @@
1
+ /**
2
+ * Agent Lifecycle Hooks
3
+ *
4
+ * React hooks that bridge Pi AgentSession events to the TUI state layer.
5
+ * Supports multi-session: create, load, switch, fork.
6
+ */
7
+
8
+ import { useState, useEffect, useRef, useCallback } from "react";
9
+ import type {
10
+ AgentSession,
11
+ AgentSessionEvent,
12
+ } from "@mariozechner/pi-coding-agent";
13
+
14
+ type SessionManagerType = AgentSession["sessionManager"];
15
+ type SessionTreeNode = ReturnType<SessionManagerType["getTree"]>[number];
16
+ import type { AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
17
+ import type { AgentMessage } from "@mariozechner/pi-agent-core";
18
+ import type { UIMessage } from "../tui/components/chat-panel.js";
19
+ import { isToolExpandable, isToolForceExpanded, getToolDefaultCollapsed } from "../tui/components/chat-panel.js";
20
+ import type { ModelRef } from "../config/settings.js";
21
+ import { setSessionTitle, getSessionTitle, getCurrentModel, getAuxiliaryModel, callAuxiliaryModel } from "../config/settings.js";
22
+ import {
23
+ listSessions,
24
+ createNewSession,
25
+ loadSession,
26
+ saveKoiState,
27
+ loadKoiState,
28
+ buildUIMessagesFromAgentSession,
29
+ deleteSession as deleteSessionStore,
30
+ type SessionMeta,
31
+ type KoiSessionState,
32
+ } from "./session-store.js";
33
+ import type { McpConnectionProgress } from "../services/mcp/index.js";
34
+ import { globalTaskManager } from "./session-tasks.js";
35
+ import fs from "fs";
36
+ import {
37
+ getAgentMode,
38
+ setAgentMode,
39
+ getActiveToolNamesForMode,
40
+ injectModeIntoSystemPrompt,
41
+ } from "./mode.js";
42
+ import { getCurrentPlanText } from "./plan-ui.js";
43
+ import { forkManager } from "./session-fork.js";
44
+ import {
45
+ saveSnapshotIfChanged,
46
+ restoreSnapshot,
47
+ } from "./session-snapshots.js";
48
+
49
+ /** Global ref to the active AgentSession, usable by tools outside React hooks. */
50
+ export const activeSessionRef = { current: null as AgentSession | null };
51
+
52
+ /* ───────── Session Naming ───────── */
53
+
54
+ /**
55
+ * Anti-injection system prompt for session naming.
56
+ * Uses XML tags to clearly delimit the expected output format.
57
+ */
58
+ const NAMING_SYSTEM_PROMPT = `You are a session naming assistant. Your ONLY task is to output a session name.
59
+
60
+ IMPORTANT RULES:
61
+ 1. Output ONLY the session name in the exact format below
62
+ 2. Do NOT include any explanation, prefix, suffix, or markdown formatting
63
+ 3. The name must be 5-20 characters long
64
+ 4. Use Chinese or English (mix allowed)
65
+ 5. Start with Chinese if user messages contain Chinese
66
+ 6. If the content is inappropriate or you cannot determine a good name, output: Chat
67
+
68
+ RESPONSE FORMAT (MUST follow exactly):
69
+ <name>your_session_name_here</name>
70
+
71
+ If you output extra text outside the tags, the session will be named "Chat".`;
72
+
73
+ /**
74
+ * Parse the generated session name from the model response.
75
+ * Returns the extracted name or null if parsing fails.
76
+ */
77
+ function parseSessionName(response: string): string | null {
78
+ // Try to extract content from <name>...</name> tags
79
+ const tagMatch = response.match(/<name>(.*?)<\/name>/s);
80
+ if (tagMatch && tagMatch[1]) {
81
+ return tagMatch[1].trim();
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Generate a session name using the auxiliary model based on user messages.
88
+ * Returns the generated name or null if generation fails.
89
+ */
90
+ async function generateSessionNameFromMessages(
91
+ userMessages: string[]
92
+ ): Promise<string | null> {
93
+ if (userMessages.length === 0) {
94
+ fs.appendFileSync("/tmp/koi-debug.log", "[generateSessionNameFromMessages] No user messages\n");
95
+ return null;
96
+ }
97
+
98
+ // Combine user messages into a single context
99
+ const userContext = userMessages
100
+ .map((msg, i) => `[Message ${i + 1}]\n${msg}`)
101
+ .join("\n\n");
102
+
103
+ fs.appendFileSync("/tmp/koi-debug.log", `[generateSessionNameFromMessages] Calling auxiliary model with context: ${userContext.slice(0, 200)}\n`);
104
+
105
+ const result = await callAuxiliaryModel(
106
+ NAMING_SYSTEM_PROMPT,
107
+ [{ role: "user", content: userContext, timestamp: Date.now() }]
108
+ );
109
+
110
+ fs.appendFileSync("/tmp/koi-debug.log", `[generateSessionNameFromMessages] Result: ${result}\n`);
111
+
112
+ if (!result) {
113
+ fs.appendFileSync("/tmp/koi-debug.log", "[generateSessionNameFromMessages] No result from auxiliary model\n");
114
+ return null;
115
+ }
116
+
117
+ const parsed = parseSessionName(result);
118
+ fs.appendFileSync("/tmp/koi-debug.log", `[generateSessionNameFromMessages] Parsed name: ${parsed}\n`);
119
+ return parsed;
120
+ }
121
+
122
+ export interface KoiAgentState {
123
+ session: AgentSession | null;
124
+ messages: UIMessage[];
125
+ isStreaming: boolean;
126
+ isReady: boolean;
127
+ error: string | null;
128
+ sessionTitle: string;
129
+ steeringMessages: readonly string[];
130
+ followUpMessages: readonly string[];
131
+ // MCP connection progress state
132
+ isConnectingMcp: boolean;
133
+ mcpConnectionProgress: McpConnectionProgress | null;
134
+ prompt: (text: string) => Promise<void>;
135
+ steer: (text: string) => Promise<void>;
136
+ followUp: (text: string) => Promise<void>;
137
+ abort: () => Promise<void>;
138
+ toggleCollapse: (id: string) => void;
139
+ expandAll: () => void;
140
+ collapseAll: () => void;
141
+ clearMessages: () => void;
142
+ removePendingMessage: (type: "sheer" | "queued", index: number) => string | null;
143
+ retractMessage: (id: string) => string | null;
144
+ switchSession: (sessionFile: string) => Promise<void>;
145
+ newSession: () => Promise<void>;
146
+ forkSession: (entryId: string) => Promise<void>;
147
+ setSessionTitle: (title: string) => void;
148
+ sessionList: SessionMeta[];
149
+ refreshSessionList: () => Promise<void>;
150
+ currentSessionId: string | null;
151
+ saveCurrentState: () => void;
152
+ deleteSession: (sessionId: string) => Promise<void>;
153
+ addPlanMessage: (content: string) => Promise<void>;
154
+ /** Sync agent mode changes to session state (called when mode changes externally) */
155
+ syncAgentMode: (mode: "build" | "ask" | "plan") => void;
156
+ }
157
+
158
+ /**
159
+ * ID & Type Guards
160
+ *
161
+ * generateId: collision-resistant enough for UI message keys within a single session.
162
+ * isAssistantMessage / isThinkingBlock: narrow union types from the generic AgentMessage content blocks.
163
+ */
164
+
165
+ function generateId(prefix: string): string {
166
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
167
+ }
168
+
169
+ function isAssistantMessage(msg: unknown): msg is AssistantMessage {
170
+ return (
171
+ typeof msg === "object" &&
172
+ msg !== null &&
173
+ "role" in msg &&
174
+ (msg as Record<string, unknown>)["role"] === "assistant"
175
+ );
176
+ }
177
+
178
+ function isUserMessage(msg: unknown): msg is UserMessage {
179
+ return (
180
+ typeof msg === "object" &&
181
+ msg !== null &&
182
+ "role" in msg &&
183
+ (msg as Record<string, unknown>)["role"] === "user"
184
+ );
185
+ }
186
+
187
+ function getUserMessageContent(msg: UserMessage): string {
188
+ if (typeof msg.content === "string") {
189
+ return msg.content;
190
+ }
191
+ return msg.content
192
+ .filter((b): b is Extract<typeof b, { type: "text" }> => b.type === "text")
193
+ .map((b) => b.text)
194
+ .join("");
195
+ }
196
+
197
+ interface ThinkingBlock {
198
+ type: "thinking";
199
+ thinking: string;
200
+ }
201
+
202
+ function isThinkingBlock(block: { type: string }): block is ThinkingBlock {
203
+ return block.type === "thinking" && "thinking" in block;
204
+ }
205
+
206
+ function isCustomPlanMessage(msg: unknown): msg is { role: "custom"; customType: "plan"; content: string | unknown[]; display: boolean; timestamp: number } {
207
+ return (
208
+ typeof msg === "object" &&
209
+ msg !== null &&
210
+ "role" in msg &&
211
+ (msg as unknown as Record<string, unknown>)["role"] === "custom" &&
212
+ "customType" in msg &&
213
+ (msg as unknown as Record<string, unknown>)["customType"] === "plan"
214
+ );
215
+ }
216
+
217
+ function extractCustomPlanContent(msg: { content: string | unknown[] }): string {
218
+ if (typeof msg.content === "string") return msg.content;
219
+ if (Array.isArray(msg.content)) {
220
+ return msg.content
221
+ .filter((c): c is { type: "text"; text: string } =>
222
+ typeof c === "object" && c !== null && "type" in c && (c as unknown as Record<string, unknown>)["type"] === "text"
223
+ )
224
+ .map((c) => c.text)
225
+ .join("");
226
+ }
227
+ return "";
228
+ }
229
+
230
+ function extractTextAndThinking(msg: AssistantMessage): {
231
+ text: string;
232
+ thinking: string;
233
+ } {
234
+ let text = "";
235
+ let thinking = "";
236
+ for (const block of msg.content) {
237
+ if (block.type === "text") {
238
+ text += block.text;
239
+ } else if (isThinkingBlock(block)) {
240
+ thinking += block.thinking || "";
241
+ }
242
+ }
243
+ return { text, thinking };
244
+ }
245
+
246
+ /**
247
+ * Event Handlers
248
+ *
249
+ * Each Pi AgentSession event is mapped to a dedicated handler below.
250
+ * Handlers receive an EventHandlerContext (setters + refs) so they stay pure-ish and testable.
251
+ * The handleEvent() switch at the bottom of this section dispatches by event type.
252
+ */
253
+
254
+ interface EventHandlerContext {
255
+ setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
256
+ setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>;
257
+ streamingMsgIdRef: React.MutableRefObject<string | null>;
258
+ pendingToolsRef: React.MutableRefObject<Map<string, string>>;
259
+ setSessionTitleState: React.Dispatch<React.SetStateAction<string>>;
260
+ setSessionTitle: (title: string) => void;
261
+ allExpandedRef: React.MutableRefObject<boolean>;
262
+ setSteeringMessages: React.Dispatch<React.SetStateAction<readonly string[]>>;
263
+ setFollowUpMessages: React.Dispatch<React.SetStateAction<readonly string[]>>;
264
+ localSteerQueueRef: React.MutableRefObject<string[]>;
265
+ localFollowUpQueueRef: React.MutableRefObject<string[]>;
266
+ hasToolCallsRef: React.MutableRefObject<boolean>;
267
+ sessionRef: React.MutableRefObject<AgentSession | null>;
268
+ }
269
+
270
+ /**
271
+ * Computes the next agent message state during a streaming message_update event.
272
+ * Tracks thinking start/end timestamps so the UI can show a "Thinking..." spinner
273
+ * and collapse/expand the reasoning block after generation finishes.
274
+ */
275
+ function buildAgentMessageUpdate(
276
+ prevMsg: UIMessage & { type: "agent" },
277
+ text: string,
278
+ thinking: string,
279
+ assistantEvent?: { type: string }
280
+ ): UIMessage {
281
+ const thinkingStarted = thinking.length > 0 && !prevMsg.thinkingStartTime;
282
+ const thinkingJustEnded =
283
+ prevMsg.thinkingStartTime &&
284
+ !prevMsg.thinkingEndTime &&
285
+ (assistantEvent?.type === "thinking_end" ||
286
+ assistantEvent?.type === "text_start" ||
287
+ assistantEvent?.type === "text_delta" ||
288
+ assistantEvent?.type === "toolcall_start" ||
289
+ assistantEvent?.type === "toolcall_delta");
290
+
291
+ return {
292
+ ...prevMsg,
293
+ content: text,
294
+ thinking: thinking.length > 0 ? thinking : undefined,
295
+ thinkingStartTime: thinkingStarted ? Date.now() : prevMsg.thinkingStartTime,
296
+ thinkingEndTime: thinkingJustEnded ? Date.now() : prevMsg.thinkingEndTime,
297
+ };
298
+ }
299
+
300
+ function updateAgentMessage(
301
+ messages: UIMessage[],
302
+ msgId: string,
303
+ updater: (msg: UIMessage & { type: "agent" }) => UIMessage
304
+ ): UIMessage[] {
305
+ const next = [...messages];
306
+ const idx = next.findIndex((m) => m.id === msgId && m.type === "agent");
307
+ if (idx >= 0) {
308
+ next[idx] = updater(next[idx] as UIMessage & { type: "agent" });
309
+ }
310
+ return next;
311
+ }
312
+
313
+ function removeAgentMessageIfEmpty(
314
+ messages: UIMessage[],
315
+ msgId: string,
316
+ text: string,
317
+ thinking: string
318
+ ): UIMessage[] {
319
+ const next = [...messages];
320
+ const idx = next.findIndex((m) => m.id === msgId && m.type === "agent");
321
+ if (idx >= 0) {
322
+ if (text.length === 0 && thinking.length === 0) {
323
+ next.splice(idx, 1);
324
+ } else {
325
+ const prevMsg = next[idx] as UIMessage & { type: "agent" };
326
+ next[idx] = {
327
+ ...prevMsg,
328
+ content: text,
329
+ thinking: thinking.length > 0 ? thinking : undefined,
330
+ thinkingEndTime:
331
+ thinking.length > 0 && !prevMsg.thinkingEndTime
332
+ ? Date.now()
333
+ : prevMsg.thinkingEndTime,
334
+ };
335
+ }
336
+ }
337
+ return next;
338
+ }
339
+
340
+ /**
341
+ * Rebuilds the UI message list from Pi's session history (`event.messages`),
342
+ * preserving existing UI state (thinkingCollapsed, expanded, etc.) for matched messages.
343
+ * Unmatched messages from the current UI (e.g. tool_call, tool_result) are appended at the end.
344
+ */
345
+ export function isInternalNotification(text: string): boolean {
346
+ const t = text.trimStart();
347
+ return t.startsWith("<task-notification>") || t.startsWith("<monitor-notification>");
348
+ }
349
+
350
+ function rebuildMessagesFromHistory(
351
+ currentMessages: UIMessage[],
352
+ historyMessages: AgentMessage[],
353
+ pendingMsgId?: string | null
354
+ ): UIMessage[] {
355
+ const reordered: UIMessage[] = [];
356
+ const usedIndices = new Set<number>();
357
+
358
+ for (const histMsg of historyMessages) {
359
+ if (isUserMessage(histMsg)) {
360
+ const content = getUserMessageContent(histMsg);
361
+ const idx = currentMessages.findIndex(
362
+ (m, i) => !usedIndices.has(i) && m.type === "user" && m.content === content
363
+ );
364
+ if (idx >= 0) {
365
+ usedIndices.add(idx);
366
+ reordered.push(currentMessages[idx]!);
367
+ } else {
368
+ reordered.push({ id: generateId("user"), type: "user", content });
369
+ }
370
+ } else if (isAssistantMessage(histMsg)) {
371
+ const { text, thinking } = extractTextAndThinking(histMsg);
372
+
373
+ // If the pending streaming agent message ended up empty, skip it
374
+ // so we don't resurrect a removed placeholder.
375
+ const pendingIdx = currentMessages.findIndex(
376
+ (m, i) => !usedIndices.has(i) && m.type === "agent" && m.id === pendingMsgId
377
+ );
378
+ const isEmptyPending = pendingIdx >= 0 && text.length === 0 && thinking.length === 0;
379
+ if (isEmptyPending) {
380
+ usedIndices.add(pendingIdx);
381
+ continue;
382
+ }
383
+
384
+ const idx = currentMessages.findIndex(
385
+ (m, i) => !usedIndices.has(i) && m.type === "agent" && m.content === text
386
+ );
387
+ if (idx >= 0) {
388
+ usedIndices.add(idx);
389
+ reordered.push(currentMessages[idx]!);
390
+
391
+ // Pull any trailing tool_call / tool_result messages that immediately
392
+ // followed this agent message in the old UI order so they stay together.
393
+ for (let i = idx + 1; i < currentMessages.length; i++) {
394
+ if (usedIndices.has(i)) break;
395
+ const m = currentMessages[i];
396
+ if (!m) break;
397
+ if (m.type === "tool_call") {
398
+ usedIndices.add(i);
399
+ reordered.push(m);
400
+ } else {
401
+ break;
402
+ }
403
+ }
404
+ } else {
405
+ reordered.push({
406
+ id: generateId("agent"),
407
+ type: "agent",
408
+ content: text,
409
+ thinking: thinking || undefined,
410
+ thinkingCollapsed: true,
411
+ });
412
+ }
413
+ } else if (isCustomPlanMessage(histMsg)) {
414
+ const content = extractCustomPlanContent(histMsg);
415
+ const idx = currentMessages.findIndex(
416
+ (m, i) => !usedIndices.has(i) && m.type === "plan"
417
+ );
418
+ if (idx >= 0) {
419
+ usedIndices.add(idx);
420
+ reordered.push(currentMessages[idx]!);
421
+ } else {
422
+ reordered.push({
423
+ id: generateId("plan"),
424
+ type: "plan",
425
+ content,
426
+ });
427
+ }
428
+ }
429
+ }
430
+
431
+ // Append any unmatched current messages (tool_call, etc.)
432
+ for (let i = 0; i < currentMessages.length; i++) {
433
+ if (!usedIndices.has(i)) {
434
+ reordered.push(currentMessages[i]!);
435
+ }
436
+ }
437
+
438
+ // Deduplicate plan messages: only the latest plan is kept.
439
+ const planIndices: number[] = [];
440
+ for (let i = 0; i < reordered.length; i++) {
441
+ if (reordered[i]!.type === "plan") {
442
+ planIndices.push(i);
443
+ }
444
+ }
445
+ if (planIndices.length > 1) {
446
+ for (let i = planIndices.length - 2; i >= 0; i--) {
447
+ reordered.splice(planIndices[i]!, 1);
448
+ }
449
+ }
450
+
451
+ return reordered;
452
+ }
453
+
454
+ /** Fired when the LLM begins generating a response. */
455
+ function handleAgentStart(ctx: EventHandlerContext) {
456
+ ctx.setIsStreaming(true);
457
+ }
458
+
459
+ /**
460
+ * Fired when the LLM finishes a full turn.
461
+ * Replaces the streaming placeholder with the final assistant text (or removes it if empty).
462
+ * Also inserts any pending followUp messages and remaining steer messages at turn end.
463
+ */
464
+ function handleAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>, ctx: EventHandlerContext) {
465
+ ctx.setIsStreaming(false);
466
+
467
+ // Deliver any remaining steer messages (turn had no tool calls) and all followUp messages
468
+ const steerToInsert = ctx.localSteerQueueRef.current;
469
+ ctx.localSteerQueueRef.current = [];
470
+ const followUpToInsert = ctx.localFollowUpQueueRef.current;
471
+ ctx.localFollowUpQueueRef.current = [];
472
+
473
+ const pendingMsgId = ctx.streamingMsgIdRef.current;
474
+
475
+ ctx.setMessages((prev) => {
476
+ let next: UIMessage[] = prev.filter((m) => m.type !== "status");
477
+
478
+ // AgentSessionEvent.agent_end.messages only contains messages from the
479
+ // CURRENT run (newMessages), not the full session history. We must use
480
+ // sessionRef.current.messages for any history-reconstruction logic.
481
+ const fullHistory = ctx.sessionRef.current?.messages ?? event.messages;
482
+
483
+ // Check whether Pi's session history already contains user messages
484
+ // that are not yet in our UI (e.g. queued/followUp messages delivered
485
+ // by Pi before agent_end fired). If so, rebuild from full history
486
+ // to get the correct order instead of blindly appending to the end.
487
+ const historyUserTexts = fullHistory
488
+ .filter(isUserMessage)
489
+ .map(getUserMessageContent);
490
+ const uiUserTexts = new Set(next.filter((m) => m.type === "user").map((m) => m.content));
491
+ const hasNewUserMessages = historyUserTexts.some((text) => !uiUserTexts.has(text));
492
+
493
+ if (hasNewUserMessages && fullHistory.length > 0) {
494
+ // Finalise the pending streaming placeholder first
495
+ if (pendingMsgId) {
496
+ const lastAssistant = [...fullHistory].reverse().find(isAssistantMessage);
497
+ if (lastAssistant) {
498
+ const { text, thinking } = extractTextAndThinking(lastAssistant);
499
+ next = removeAgentMessageIfEmpty(next, pendingMsgId, text, thinking);
500
+ }
501
+ }
502
+ return rebuildMessagesFromHistory(next, fullHistory, pendingMsgId);
503
+ }
504
+
505
+ // Fallback: old append logic for cases where Pi hasn't yet added
506
+ // the queued messages to its history snapshot.
507
+ if (pendingMsgId && fullHistory.length > 0) {
508
+ const lastAssistant = [...fullHistory].reverse().find(isAssistantMessage);
509
+ if (lastAssistant) {
510
+ const { text, thinking } = extractTextAndThinking(lastAssistant);
511
+ next = removeAgentMessageIfEmpty(next, pendingMsgId, text, thinking);
512
+ }
513
+ }
514
+
515
+ const inserts = [
516
+ ...steerToInsert.map((text) => ({ id: generateId("user"), type: "user" as const, content: text })),
517
+ ...followUpToInsert.map((text) => ({ id: generateId("user"), type: "user" as const, content: text })),
518
+ ];
519
+ if (inserts.length > 0) {
520
+ return next.concat(inserts);
521
+ }
522
+ return next;
523
+ });
524
+
525
+ ctx.streamingMsgIdRef.current = null;
526
+ ctx.pendingToolsRef.current.clear();
527
+ ctx.hasToolCallsRef.current = false;
528
+
529
+ // Save snapshot after each completed turn so forks can restore exact state.
530
+ const currentSession = ctx.sessionRef.current;
531
+ if (currentSession) {
532
+ saveSnapshotIfChanged(currentSession, {
533
+ tasks: globalTaskManager.listTasks(),
534
+ planText: getCurrentPlanText(),
535
+ agentMode: getAgentMode(),
536
+ activeTools: getActiveToolNamesForMode(getAgentMode()),
537
+ });
538
+ }
539
+ }
540
+
541
+ /** Creates a blank streaming placeholder for the incoming assistant message.
542
+ * If there were tool calls in this turn, any pending steer messages are delivered
543
+ * right before the new assistant message (after tools finish, before next LLM call).
544
+ */
545
+ function handleMessageStart(event: Extract<AgentSessionEvent, { type: "message_start" }>, ctx: EventHandlerContext) {
546
+ if (!isAssistantMessage(event.message)) return;
547
+ const steerToInsert = ctx.hasToolCallsRef.current ? ctx.localSteerQueueRef.current : [];
548
+ if (ctx.hasToolCallsRef.current) {
549
+ ctx.localSteerQueueRef.current = [];
550
+ }
551
+ const msgId = generateId("agent");
552
+ ctx.streamingMsgIdRef.current = msgId;
553
+ ctx.setMessages((prev) => [
554
+ ...prev.filter((m) => m.type !== "status"),
555
+ ...(steerToInsert.length > 0
556
+ ? steerToInsert.map((text) => ({ id: generateId("user"), type: "user" as const, content: text }))
557
+ : []),
558
+ { id: msgId, type: "agent", content: "", thinkingCollapsed: true },
559
+ ]);
560
+ }
561
+
562
+ /**
563
+ * Fired on every token / block delta during streaming.
564
+ * Updates content, thinking text, and thinking start/end timestamps in a single immutable swap.
565
+ */
566
+ function handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>, ctx: EventHandlerContext) {
567
+ if (!isAssistantMessage(event.message)) return;
568
+ const msgId = ctx.streamingMsgIdRef.current;
569
+ if (!msgId) return;
570
+ const { text, thinking } = extractTextAndThinking(event.message);
571
+ const assistantEvent = event.assistantMessageEvent;
572
+ ctx.setMessages((prev) =>
573
+ updateAgentMessage(prev, msgId, (prevMsg) =>
574
+ buildAgentMessageUpdate(prevMsg, text, thinking, assistantEvent)
575
+ )
576
+ );
577
+ }
578
+
579
+ /**
580
+ * Finalizes the streaming message. Unlike agent_end, this fires per-message
581
+ * (a turn may contain multiple messages when tools are involved).
582
+ */
583
+ function handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>, ctx: EventHandlerContext) {
584
+ if (!isAssistantMessage(event.message)) return;
585
+ const msgId = ctx.streamingMsgIdRef.current;
586
+ if (msgId) {
587
+ const { text, thinking } = extractTextAndThinking(event.message);
588
+ ctx.setMessages((prev) => removeAgentMessageIfEmpty(prev, msgId, text, thinking));
589
+ }
590
+ ctx.streamingMsgIdRef.current = null;
591
+ }
592
+
593
+ /** Adds a pending tool_call message to the UI so the user sees live execution. */
594
+ function handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>, ctx: EventHandlerContext) {
595
+ ctx.hasToolCallsRef.current = true;
596
+ const toolMsgId = generateId("tool");
597
+ ctx.pendingToolsRef.current.set(event.toolCallId, toolMsgId);
598
+ ctx.setMessages((prev) =>
599
+ prev.concat({
600
+ id: toolMsgId,
601
+ type: "tool_call",
602
+ toolCallId: event.toolCallId,
603
+ toolName: event.toolName,
604
+ args: event.args as Record<string, unknown>,
605
+ collapsed: getToolDefaultCollapsed(event.toolName, ctx.allExpandedRef.current),
606
+ })
607
+ );
608
+ }
609
+
610
+ /** Streams partial tool results (e.g. long-running bash output chunks). */
611
+ function handleToolExecutionUpdate(event: Extract<AgentSessionEvent, { type: "tool_execution_update" }>, ctx: EventHandlerContext) {
612
+ const toolMsgId = ctx.pendingToolsRef.current.get(event.toolCallId);
613
+ if (!toolMsgId) return;
614
+ ctx.setMessages((prev) =>
615
+ prev.map((m) =>
616
+ m.id === toolMsgId && m.type === "tool_call"
617
+ ? { ...m, result: event.partialResult }
618
+ : m
619
+ )
620
+ );
621
+ }
622
+
623
+ /** Marks the tool call as complete and stores the final result (or error). */
624
+ function handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>, ctx: EventHandlerContext) {
625
+ const toolMsgId = ctx.pendingToolsRef.current.get(event.toolCallId);
626
+ if (!toolMsgId) return;
627
+ ctx.setMessages((prev) =>
628
+ prev.map((m) =>
629
+ m.id === toolMsgId && m.type === "tool_call"
630
+ ? { ...m, result: event.result, isError: event.isError }
631
+ : m
632
+ )
633
+ );
634
+ }
635
+
636
+ /** Notifies the user that the session is being compacted to reduce context usage. */
637
+ function handleCompactionStart(event: Extract<AgentSessionEvent, { type: "compaction_start" }>, ctx: EventHandlerContext) {
638
+ ctx.setMessages((prev) =>
639
+ prev.concat({
640
+ id: generateId("compact"),
641
+ type: "compaction",
642
+ content: `Compacting session (${event.reason})...`,
643
+ })
644
+ );
645
+ }
646
+
647
+ function handleCompactionEnd(event: Extract<AgentSessionEvent, { type: "compaction_end" }>, ctx: EventHandlerContext) {
648
+ ctx.setMessages((prev) =>
649
+ prev.map((m) =>
650
+ m.type === "compaction" && m.content.includes("Compacting")
651
+ ? {
652
+ ...m,
653
+ content: event.aborted ? "Compaction aborted." : "Session compacted.",
654
+ }
655
+ : m
656
+ )
657
+ );
658
+
659
+ // Re-apply mode-specific tool restrictions and system prompt after compaction,
660
+ // in case the compaction process reset any session state.
661
+ const session = ctx.sessionRef.current;
662
+ if (session && !event.aborted) {
663
+ const mode = getAgentMode();
664
+ session.setActiveToolsByName(getActiveToolNamesForMode(mode));
665
+ injectModeIntoSystemPrompt(session, mode);
666
+ }
667
+ }
668
+
669
+ /** Shows a retry banner when the agent encounters a transient error and retries automatically. */
670
+ function handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>, ctx: EventHandlerContext) {
671
+ ctx.setMessages((prev) =>
672
+ prev
673
+ .filter((m) => m.type !== "status")
674
+ .concat({
675
+ id: generateId("retry"),
676
+ type: "retry",
677
+ attempt: event.attempt,
678
+ maxAttempts: event.maxAttempts,
679
+ content: `Retrying... (${event.attempt}/${event.maxAttempts}): ${event.errorMessage}`,
680
+ })
681
+ );
682
+ }
683
+
684
+ /** Clears the retry banner once the retry cycle finishes (success or final failure). */
685
+ function handleAutoRetryEnd(_event: Extract<AgentSessionEvent, { type: "auto_retry_end" }>, ctx: EventHandlerContext) {
686
+ ctx.setMessages((prev) => prev.filter((m) => m.type !== "retry"));
687
+ }
688
+
689
+ /** Syncs the session name when the agent or user renames it. */
690
+ function handleSessionInfoChanged(event: Extract<AgentSessionEvent, { type: "session_info_changed" }>, ctx: EventHandlerContext) {
691
+ if (event.name) {
692
+ ctx.setSessionTitleState(event.name);
693
+ ctx.setSessionTitle(event.name);
694
+ }
695
+ }
696
+
697
+ /** Syncs the pending steer/followUp queues from the agent session to React state.
698
+ * Delivery detection is handled manually via local queues and event boundaries
699
+ * (steer after tool calls, followUp at agent_end), so this only updates the UI state.
700
+ */
701
+ function handleQueueUpdate(event: Extract<AgentSessionEvent, { type: "queue_update" }>, ctx: EventHandlerContext) {
702
+ ctx.setSteeringMessages(event.steering);
703
+ ctx.setFollowUpMessages(event.followUp);
704
+ }
705
+
706
+ /**
707
+ * Central dispatcher for all AgentSession events.
708
+ * Uses a switch so TypeScript can narrow the event type for each handler.
709
+ */
710
+ function handleEvent(event: AgentSessionEvent, ctx: EventHandlerContext) {
711
+ switch (event.type) {
712
+ case "agent_start": handleAgentStart(ctx); break;
713
+ case "agent_end": handleAgentEnd(event, ctx); break;
714
+ case "message_start": handleMessageStart(event, ctx); break;
715
+ case "message_update": handleMessageUpdate(event, ctx); break;
716
+ case "message_end": handleMessageEnd(event, ctx); break;
717
+ case "tool_execution_start": handleToolExecutionStart(event, ctx); break;
718
+ case "tool_execution_update": handleToolExecutionUpdate(event, ctx); break;
719
+ case "tool_execution_end": handleToolExecutionEnd(event, ctx); break;
720
+ case "compaction_start": handleCompactionStart(event, ctx); break;
721
+ case "compaction_end": handleCompactionEnd(event, ctx); break;
722
+ case "auto_retry_start": handleAutoRetryStart(event, ctx); break;
723
+ case "auto_retry_end": handleAutoRetryEnd(event, ctx); break;
724
+ case "session_info_changed": handleSessionInfoChanged(event, ctx); break;
725
+ case "queue_update": handleQueueUpdate(event, ctx); break;
726
+ default: break;
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Tree Navigation
732
+ *
733
+ * Session entries form a tree because of forking / branching.
734
+ * findNodeInTree walks the entire tree to locate an entry by its id.
735
+ */
736
+
737
+ function findNodeInTree(
738
+ nodes: SessionTreeNode[],
739
+ id: string
740
+ ): SessionTreeNode | null {
741
+ for (const node of nodes) {
742
+ if (node.entry.id === id) return node;
743
+ const found = findNodeInTree(node.children, id);
744
+ if (found) return found;
745
+ }
746
+ return null;
747
+ }
748
+
749
+ /**
750
+ * useKoiAgent — Core React hook for the Koi TUI.
751
+ *
752
+ * Bridges Pi's AgentSession lifecycle to React state:
753
+ * • Event subscription & message streaming
754
+ * • Session CRUD (create, switch, fork, delete)
755
+ * • Auto-save of UI state to ~/.config/koi/sessions/<id>/koi-state.json
756
+ * • Collapse / expand helpers for tool_calls and thinking blocks
757
+ *
758
+ * Refs are kept in sync with state so cleanup handlers (unmount, switch, delete)
759
+ * always see the latest values without adding them to dependency arrays.
760
+ */
761
+
762
+ export function useKoiAgent(): KoiAgentState {
763
+ const [session, setSession] = useState<AgentSession | null>(null);
764
+ const [messages, setMessages] = useState<UIMessage[]>([]);
765
+ const [isStreaming, setIsStreaming] = useState(false);
766
+ const [isReady, setIsReady] = useState(false);
767
+ const [error, setError] = useState<string | null>(null);
768
+ const [sessionList, setSessionList] = useState<SessionMeta[]>([]);
769
+ const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
770
+ const [sessionTitle, setSessionTitleState] = useState<string>(getSessionTitle());
771
+ const [steeringMessages, setSteeringMessages] = useState<readonly string[]>([]);
772
+ const [followUpMessages, setFollowUpMessages] = useState<readonly string[]>([]);
773
+ // MCP connection progress state
774
+ const [isConnectingMcp, setIsConnectingMcp] = useState(false);
775
+ const [mcpConnectionProgress, setMcpConnectionProgress] = useState<McpConnectionProgress | null>(null);
776
+
777
+ const streamingMsgIdRef = useRef<string | null>(null);
778
+ const pendingToolsRef = useRef<Map<string, string>>(new Map());
779
+ const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
780
+ const currentModelRef = useRef<ModelRef | null>(getCurrentModel());
781
+ const auxiliaryModelRef = useRef<ModelRef | null>(getAuxiliaryModel());
782
+ const sessionRef = useRef<AgentSession | null>(null);
783
+ const messagesRef = useRef<UIMessage[]>([]);
784
+ const currentSessionIdRef = useRef<string | null>(null);
785
+ const allExpandedRef = useRef<boolean>(false);
786
+ const localSteerQueueRef = useRef<string[]>([]);
787
+ const localFollowUpQueueRef = useRef<string[]>([]);
788
+ const hasToolCallsRef = useRef(false);
789
+ // Track whether this session has been named by the auxiliary model
790
+ const sessionNamedRef = useRef(false);
791
+
792
+ // Refs for session state that needs to be persisted with KoiSessionState
793
+ const sessionStateRef = useRef<{
794
+ forkedFrom: string | null;
795
+ forkBranchId: string | null;
796
+ forkedAt: number | null;
797
+ agentMode: "build" | "ask" | "plan";
798
+ activeTools: string[];
799
+ }>({
800
+ forkedFrom: null,
801
+ forkBranchId: null,
802
+ forkedAt: null,
803
+ agentMode: "build",
804
+ activeTools: getActiveToolNamesForMode("build"),
805
+ });
806
+
807
+ // Keep refs in sync with latest state for cleanup handlers (unmount, switch, delete).
808
+ // These refs avoid stale closures without adding every state to dependency arrays.
809
+ useEffect(() => {
810
+ sessionRef.current = session;
811
+ activeSessionRef.current = session;
812
+ }, [session]);
813
+ useEffect(() => { messagesRef.current = messages; }, [messages]);
814
+ useEffect(() => { currentSessionIdRef.current = currentSessionId; }, [currentSessionId]);
815
+
816
+ // Debounce writes to disk: avoids hammering the filesystem on every token during streaming.
817
+ // Also batches rapid message updates into a single save.
818
+ const scheduleSave = useCallback(
819
+ (sessionId: string, msgs: UIMessage[], title: string) => {
820
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
821
+ saveTimerRef.current = setTimeout(() => {
822
+ const state: KoiSessionState = {
823
+ sessionId,
824
+ title,
825
+ currentModel: currentModelRef.current,
826
+ auxiliaryModel: auxiliaryModelRef.current,
827
+ messages: msgs,
828
+ createdAt: Date.now(),
829
+ updatedAt: Date.now(),
830
+ // Fork and agent mode state
831
+ forkedFrom: sessionStateRef.current.forkedFrom,
832
+ forkBranchId: sessionStateRef.current.forkBranchId,
833
+ forkedAt: sessionStateRef.current.forkedAt,
834
+ agentMode: sessionStateRef.current.agentMode,
835
+ activeTools: sessionStateRef.current.activeTools,
836
+ // UI state (empty, will be populated by actual UI interactions)
837
+ expandedMessages: [],
838
+ collapsedMessages: [],
839
+ };
840
+ saveKoiState(sessionId, state);
841
+ globalTaskManager.save(sessionId);
842
+ }, 500);
843
+ },
844
+ []
845
+ );
846
+
847
+ useEffect(() => {
848
+ if (currentSessionId && session) {
849
+ scheduleSave(currentSessionId, messages, session.sessionName || getSessionTitle());
850
+ }
851
+ }, [messages, currentSessionId, session, scheduleSave]);
852
+
853
+ // Wire Pi AgentSession events into React setters via the central handleEvent dispatcher.
854
+ const subscribeToSession = useCallback((s: AgentSession) => {
855
+ // Set refs immediately so event handlers (which may fire before the next
856
+ // React render cycle / useEffect) see the correct session.
857
+ sessionRef.current = s;
858
+ activeSessionRef.current = s;
859
+ const ctx: EventHandlerContext = {
860
+ setMessages,
861
+ setIsStreaming,
862
+ streamingMsgIdRef,
863
+ pendingToolsRef,
864
+ setSessionTitleState,
865
+ setSessionTitle,
866
+ allExpandedRef,
867
+ setSteeringMessages,
868
+ setFollowUpMessages,
869
+ localSteerQueueRef,
870
+ localFollowUpQueueRef,
871
+ hasToolCallsRef,
872
+ sessionRef,
873
+ };
874
+ return s.subscribe((event: AgentSessionEvent) => handleEvent(event, ctx));
875
+ }, []);
876
+
877
+ // On session load: prefer persisted koi-state.json; fall back to rebuilding from AgentSession.messages.
878
+ const restoreSessionState = useCallback((s: AgentSession) => {
879
+ const koiState = loadKoiState(s.sessionId);
880
+ let restoredMessages = koiState?.messages.length ? koiState.messages : buildUIMessagesFromAgentSession(s);
881
+
882
+ // Strip internal subagent notifications from restored messages — they are
883
+ // meant for the LLM context only and should not clutter the UI.
884
+ restoredMessages = restoredMessages.filter(
885
+ (m) => !(m.type === "user" && isInternalNotification(m.content))
886
+ );
887
+
888
+ // Deduplicate plan messages: only the latest plan is kept.
889
+ const planIndices: number[] = [];
890
+ for (let i = 0; i < restoredMessages.length; i++) {
891
+ if (restoredMessages[i]!.type === "plan") {
892
+ planIndices.push(i);
893
+ }
894
+ }
895
+ if (planIndices.length > 1) {
896
+ const filtered = restoredMessages.filter((_, i) => !planIndices.slice(0, -1).includes(i));
897
+ restoredMessages = filtered;
898
+ }
899
+
900
+ setMessages(restoredMessages);
901
+
902
+ const title = koiState?.title ?? s.sessionName;
903
+ if (title) {
904
+ setSessionTitleState(title);
905
+ setSessionTitle(title);
906
+ }
907
+ if (koiState?.currentModel) currentModelRef.current = koiState.currentModel;
908
+ if (koiState?.auxiliaryModel) auxiliaryModelRef.current = koiState.auxiliaryModel;
909
+
910
+ // Restore fork-related and agent mode state
911
+ if (koiState) {
912
+ sessionStateRef.current = {
913
+ forkedFrom: koiState.forkedFrom ?? null,
914
+ forkBranchId: koiState.forkBranchId ?? null,
915
+ forkedAt: koiState.forkedAt ?? null,
916
+ agentMode: koiState.agentMode ?? "build",
917
+ activeTools: koiState.activeTools ?? getActiveToolNamesForMode(koiState.agentMode ?? "build"),
918
+ };
919
+
920
+ // Restore agent mode for the session
921
+ setAgentMode(sessionStateRef.current.agentMode);
922
+ s.setActiveToolsByName(sessionStateRef.current.activeTools);
923
+ injectModeIntoSystemPrompt(s, sessionStateRef.current.agentMode);
924
+ }
925
+
926
+ // Restore snapshot (tasks + plan + mode) for current leaf, overriding koiState if present.
927
+ const leafId = s.sessionManager.getLeafId();
928
+ if (leafId) {
929
+ const snapshotData = restoreSnapshot(s, leafId, globalTaskManager);
930
+ if (snapshotData) {
931
+ sessionStateRef.current = {
932
+ ...sessionStateRef.current,
933
+ agentMode: snapshotData.agentMode,
934
+ activeTools: snapshotData.activeTools,
935
+ };
936
+ setAgentMode(snapshotData.agentMode);
937
+ s.setActiveToolsByName(snapshotData.activeTools);
938
+ injectModeIntoSystemPrompt(s, snapshotData.agentMode);
939
+ }
940
+ }
941
+ }, []);
942
+
943
+ // Orchestrates the full session boot sequence (subscribe → restore state → refresh list).
944
+ const setupSession = useCallback(
945
+ async (result: { session: AgentSession }) => {
946
+ const s = result.session;
947
+ setSession(s);
948
+ setCurrentSessionId(s.sessionId);
949
+ globalTaskManager.setActiveSession(s.sessionId);
950
+ subscribeToSession(s);
951
+ restoreSessionState(s);
952
+ setIsReady(true);
953
+ setSessionList(await listSessions());
954
+ },
955
+ [subscribeToSession, restoreSessionState]
956
+ );
957
+
958
+ // Shared state shape used by saveCurrentState, scheduleSave, and the unmount cleanup effect.
959
+ const buildKoiState = useCallback(
960
+ (sid: string, msgs: UIMessage[], title: string): KoiSessionState => ({
961
+ sessionId: sid,
962
+ title,
963
+ currentModel: currentModelRef.current,
964
+ auxiliaryModel: auxiliaryModelRef.current,
965
+ messages: msgs,
966
+ createdAt: Date.now(),
967
+ updatedAt: Date.now(),
968
+ // Fork and agent mode state
969
+ forkedFrom: sessionStateRef.current.forkedFrom,
970
+ forkBranchId: sessionStateRef.current.forkBranchId,
971
+ forkedAt: sessionStateRef.current.forkedAt,
972
+ agentMode: sessionStateRef.current.agentMode,
973
+ activeTools: sessionStateRef.current.activeTools,
974
+ // UI state
975
+ expandedMessages: [],
976
+ collapsedMessages: [],
977
+ }),
978
+ []
979
+ );
980
+
981
+ // On mount: create a new session instead of continuing the most recent one.
982
+ useEffect(() => {
983
+ let mounted = true;
984
+ void createNewSession(globalTaskManager)
985
+ .then((result) => {
986
+ if (!mounted) {
987
+ result.session.dispose();
988
+ return;
989
+ }
990
+ void setupSession(result);
991
+ })
992
+ .catch((err: unknown) => {
993
+ if (!mounted) return;
994
+ setError(err instanceof Error ? err.message : String(err));
995
+ setIsReady(true);
996
+ });
997
+ return () => { mounted = false; };
998
+ }, [setupSession]);
999
+
1000
+ // On unmount: persist final state before disposing the AgentSession to prevent data loss.
1001
+ useEffect(() => {
1002
+ return () => {
1003
+ const s = sessionRef.current;
1004
+ const sid = currentSessionIdRef.current;
1005
+ const msgs = messagesRef.current;
1006
+ if (s) {
1007
+ if (sid) {
1008
+ saveKoiState(sid, buildKoiState(sid, msgs, s.sessionName || getSessionTitle()));
1009
+ globalTaskManager.save(sid);
1010
+ }
1011
+ s.dispose();
1012
+ }
1013
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
1014
+ };
1015
+ }, [buildKoiState]);
1016
+
1017
+ const saveCurrentState = useCallback(() => {
1018
+ if (currentSessionId && session) {
1019
+ saveKoiState(currentSessionId, buildKoiState(currentSessionId, messages, session.sessionName || getSessionTitle()));
1020
+ globalTaskManager.save(currentSessionId);
1021
+ }
1022
+ }, [currentSessionId, session, messages, buildKoiState]);
1023
+
1024
+ // Clears streaming artifacts (msg id, pending tools) when switching or creating a new session.
1025
+ const resetSessionUI = useCallback(() => {
1026
+ setError(null);
1027
+ streamingMsgIdRef.current = null;
1028
+ pendingToolsRef.current.clear();
1029
+ setSteeringMessages([]);
1030
+ setFollowUpMessages([]);
1031
+ localSteerQueueRef.current = [];
1032
+ localFollowUpQueueRef.current = [];
1033
+ hasToolCallsRef.current = false;
1034
+ sessionNamedRef.current = false;
1035
+ }, []);
1036
+
1037
+ // -- Session Actions --
1038
+ const switchSession = useCallback(
1039
+ async (sessionFile: string) => {
1040
+ if (!session) return;
1041
+ setIsReady(false);
1042
+ saveCurrentState();
1043
+ await session.abort();
1044
+ session.dispose();
1045
+
1046
+ // Start MCP connection progress tracking
1047
+ setIsConnectingMcp(true);
1048
+ setMcpConnectionProgress({
1049
+ total: 0,
1050
+ completed: 0,
1051
+ currentServer: "Initializing...",
1052
+ status: "connecting",
1053
+ });
1054
+
1055
+ try {
1056
+ const result = await loadSession(sessionFile, globalTaskManager, (progress) => {
1057
+ setMcpConnectionProgress(progress);
1058
+ });
1059
+
1060
+ // Clear MCP connection progress
1061
+ setIsConnectingMcp(false);
1062
+ setMcpConnectionProgress(null);
1063
+
1064
+ resetSessionUI();
1065
+ await setupSession(result);
1066
+ } catch (err: unknown) {
1067
+ setError(err instanceof Error ? err.message : String(err));
1068
+ setIsReady(true);
1069
+ setIsConnectingMcp(false);
1070
+ setMcpConnectionProgress(null);
1071
+ }
1072
+ },
1073
+ [session, saveCurrentState, setupSession, resetSessionUI]
1074
+ );
1075
+
1076
+ const newSession = useCallback(async () => {
1077
+ if (!session) return;
1078
+ setIsReady(false);
1079
+ saveCurrentState();
1080
+ await session.abort();
1081
+ session.dispose();
1082
+
1083
+ // Start MCP connection progress tracking
1084
+ setIsConnectingMcp(true);
1085
+ setMcpConnectionProgress({
1086
+ total: 0,
1087
+ completed: 0,
1088
+ currentServer: "Initializing...",
1089
+ status: "connecting",
1090
+ });
1091
+
1092
+ try {
1093
+ const result = await createNewSession(globalTaskManager, (progress) => {
1094
+ setMcpConnectionProgress(progress);
1095
+ });
1096
+
1097
+ // Clear MCP connection progress
1098
+ setIsConnectingMcp(false);
1099
+ setMcpConnectionProgress(null);
1100
+
1101
+ resetSessionUI();
1102
+ setMessages([]);
1103
+ setSessionTitleState("New Session");
1104
+ setSessionTitle("New Session");
1105
+ currentModelRef.current = getCurrentModel();
1106
+ auxiliaryModelRef.current = getAuxiliaryModel();
1107
+ await setupSession(result);
1108
+ } catch (err: unknown) {
1109
+ setError(err instanceof Error ? err.message : String(err));
1110
+ setIsReady(true);
1111
+ setIsConnectingMcp(false);
1112
+ setMcpConnectionProgress(null);
1113
+ }
1114
+ }, [session, saveCurrentState, setupSession, resetSessionUI]);
1115
+
1116
+ /**
1117
+ * Fork Logic
1118
+ *
1119
+ * Forking creates a new branch in the conversation tree.
1120
+ * computeForwardPath builds the path from the selected entry to the leaf.
1121
+ * findBranchPoint walks forward to locate the next user message; we branch
1122
+ * from the entry *before* it so the entire assistant/tool turn is preserved.
1123
+ */
1124
+ const computeForwardPath = useCallback(
1125
+ (session: AgentSession, entryId: string) => {
1126
+ const branchPath = session.sessionManager.getBranch();
1127
+ const selectedIndex = branchPath.findIndex((e) => e.id === entryId);
1128
+
1129
+ if (selectedIndex >= 0) {
1130
+ return branchPath.slice(selectedIndex);
1131
+ }
1132
+
1133
+ const tree = session.sessionManager.getTree();
1134
+ const selectedNode = findNodeInTree(tree, entryId);
1135
+ if (!selectedNode) return [];
1136
+
1137
+ const path = [selectedNode.entry];
1138
+ let current = selectedNode;
1139
+ while (current.children.length > 0) {
1140
+ const next = current.children[current.children.length - 1];
1141
+ if (!next) break;
1142
+ current = next;
1143
+ path.push(current.entry);
1144
+ }
1145
+ return path;
1146
+ },
1147
+ []
1148
+ );
1149
+
1150
+ const findBranchPoint = useCallback((forwardPath: ReturnType<SessionManagerType["getBranch"]>, entryId: string) => {
1151
+ if (forwardPath.length === 0) return entryId;
1152
+
1153
+ let nextUserIndex = -1;
1154
+ for (let i = 1; i < forwardPath.length; i++) {
1155
+ const entry = forwardPath[i];
1156
+ if (entry?.type === "message" && entry.message.role === "user") {
1157
+ nextUserIndex = i;
1158
+ break;
1159
+ }
1160
+ }
1161
+
1162
+ // Walk backward from the candidate to skip custom entries (snapshots, plans)
1163
+ // so we never branch from a synthetic node.
1164
+ const findLastNonCustom = (startIndex: number): string | undefined => {
1165
+ for (let i = startIndex; i >= 0; i--) {
1166
+ const entry = forwardPath[i];
1167
+ if (entry && entry.type !== "custom") {
1168
+ return entry.id;
1169
+ }
1170
+ }
1171
+ return undefined;
1172
+ };
1173
+
1174
+ if (nextUserIndex >= 1) {
1175
+ return findLastNonCustom(nextUserIndex - 1) ?? entryId;
1176
+ }
1177
+ if (nextUserIndex === -1) {
1178
+ return findLastNonCustom(forwardPath.length - 1) ?? entryId;
1179
+ }
1180
+ return entryId;
1181
+ }, []);
1182
+
1183
+ const forkSession = useCallback(
1184
+ async (entryId: string) => {
1185
+ if (!session) return;
1186
+
1187
+ // 1. Calculate branch point
1188
+ const forwardPath = computeForwardPath(session, entryId);
1189
+ const branchFromId = findBranchPoint(forwardPath, entryId);
1190
+ const branchPath = session.sessionManager.getBranch();
1191
+ fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[fork] entryId=${entryId} branchFromId=${branchFromId} forwardPath=${forwardPath.length} branchPath=${branchPath.length}\n`);
1192
+
1193
+ // 2. Execute session branching
1194
+ session.sessionManager.branch(branchFromId);
1195
+ const context = session.sessionManager.buildSessionContext();
1196
+ session.state.messages = context.messages;
1197
+
1198
+ // 3. Restore snapshot at the fork point (tasks + plan)
1199
+ const snapshotData = restoreSnapshot(session, entryId, globalTaskManager);
1200
+
1201
+ // 4. Determine restored or fallback state for metadata
1202
+ const restoredAgentMode = snapshotData?.agentMode ?? getAgentMode();
1203
+ const restoredActiveTools = snapshotData?.activeTools ?? getActiveToolNamesForMode(restoredAgentMode);
1204
+ const restoredPlan = snapshotData?.planText ?? getCurrentPlanText();
1205
+ const restoredTasks = globalTaskManager.listTasks();
1206
+ const currentKoiState = loadKoiState(session.sessionId);
1207
+
1208
+ // 5. Create and save fork metadata
1209
+ const restoredTaskStatuses = restoredTasks.map(t => `${t.id}:${t.status}`).join(", ");
1210
+ fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[fork] restoredTasks=[${restoredTaskStatuses}] plan=${restoredPlan?.slice(0, 20) ?? "null"} mode=${restoredAgentMode}\n`);
1211
+ const forkMetadata = {
1212
+ forkId: session.sessionId,
1213
+ sourceSessionId: session.sessionId,
1214
+ sourceBranchId: branchPath.find(e => e.id === branchFromId)?.id ?? '',
1215
+ forkPoint: branchFromId,
1216
+ forkedAt: Date.now(),
1217
+ tasksSnapshot: restoredTasks,
1218
+ agentMode: restoredAgentMode,
1219
+ activeTools: restoredActiveTools,
1220
+ pendingPlanText: restoredPlan,
1221
+ };
1222
+ forkManager.saveForkMetadata(session.sessionId, forkMetadata);
1223
+
1224
+ // 6. Update KoiSessionState with fork-related info
1225
+ const now = Date.now();
1226
+ const forkedState: KoiSessionState = {
1227
+ ...(currentKoiState ?? {
1228
+ sessionId: session.sessionId,
1229
+ title: session.sessionName || "Forked Session",
1230
+ currentModel: getCurrentModel(),
1231
+ auxiliaryModel: getAuxiliaryModel(),
1232
+ messages: [],
1233
+ createdAt: now,
1234
+ updatedAt: now,
1235
+ }),
1236
+ forkedFrom: session.sessionId,
1237
+ forkBranchId: branchFromId,
1238
+ forkedAt: now,
1239
+ agentMode: restoredAgentMode,
1240
+ activeTools: restoredActiveTools,
1241
+ expandedMessages: [],
1242
+ collapsedMessages: [],
1243
+ };
1244
+ saveKoiState(session.sessionId, forkedState);
1245
+
1246
+ // Update sessionStateRef for future saves
1247
+ sessionStateRef.current = {
1248
+ forkedFrom: session.sessionId,
1249
+ forkBranchId: branchFromId,
1250
+ forkedAt: Date.now(),
1251
+ agentMode: restoredAgentMode,
1252
+ activeTools: restoredActiveTools,
1253
+ };
1254
+
1255
+ // 7. Rebuild UI messages from the new branch context
1256
+ setMessages(buildUIMessagesFromAgentSession(session));
1257
+
1258
+ // 8. Restore agent mode state for the new branch
1259
+ setAgentMode(restoredAgentMode);
1260
+ session.setActiveToolsByName(restoredActiveTools);
1261
+ injectModeIntoSystemPrompt(session, restoredAgentMode);
1262
+
1263
+ // 9. Clear streaming state
1264
+ streamingMsgIdRef.current = null;
1265
+ pendingToolsRef.current.clear();
1266
+
1267
+ // 10. Save all state
1268
+ saveCurrentState();
1269
+ globalTaskManager.saveActive();
1270
+ },
1271
+ [session, computeForwardPath, findBranchPoint, saveCurrentState]
1272
+ );
1273
+
1274
+ // Persist the title to both React state and the Pi AgentSession so the JSONL file reflects the change.
1275
+ // Also save to koiState immediately so the title persists across sessions.
1276
+ const setSessionTitleWrapper = useCallback(
1277
+ (title: string) => {
1278
+ setSessionTitleState(title);
1279
+ setSessionTitle(title);
1280
+ session?.setSessionName(title);
1281
+ // Immediately persist the title change to koiState
1282
+ const sid = currentSessionIdRef.current;
1283
+ if (sid) {
1284
+ const koiState = loadKoiState(sid);
1285
+ if (koiState) {
1286
+ saveKoiState(sid, {
1287
+ ...koiState,
1288
+ title,
1289
+ updatedAt: Date.now(),
1290
+ });
1291
+ }
1292
+ }
1293
+ },
1294
+ [session]
1295
+ );
1296
+
1297
+ const refreshSessionList = useCallback(async () => {
1298
+ setSessionList(await listSessions());
1299
+ }, []);
1300
+
1301
+ // Deleting the active session disposes it and immediately creates a new blank session
1302
+ // so the UI never enters a "dead" state with no session available.
1303
+ const deleteSession = useCallback(
1304
+ async (sessionId: string) => {
1305
+ const isCurrent = sessionId === currentSessionId;
1306
+ const meta = sessionList.find((s) => s.id === sessionId);
1307
+ if (!meta) return;
1308
+
1309
+ if (isCurrent && session) {
1310
+ saveCurrentState();
1311
+ await session.abort();
1312
+ session.dispose();
1313
+ await deleteSessionStore(meta);
1314
+ try {
1315
+ const result = await createNewSession(globalTaskManager);
1316
+ resetSessionUI();
1317
+ setMessages([]);
1318
+ setSessionTitleState("New Session");
1319
+ setSessionTitle("New Session");
1320
+ currentModelRef.current = getCurrentModel();
1321
+ auxiliaryModelRef.current = getAuxiliaryModel();
1322
+ await setupSession(result);
1323
+ } catch (err: unknown) {
1324
+ setError(err instanceof Error ? err.message : String(err));
1325
+ setIsReady(true);
1326
+ }
1327
+ } else {
1328
+ await deleteSessionStore(meta);
1329
+ setSessionList((prev) => prev.filter((s) => s.id !== sessionId));
1330
+ }
1331
+ },
1332
+ [session, currentSessionId, sessionList, saveCurrentState, setupSession, resetSessionUI]
1333
+ );
1334
+
1335
+ // Internal function to trigger session naming (called after user prompt)
1336
+ const triggerSessionNaming = useCallback(
1337
+ async (allMessages: UIMessage[]) => {
1338
+ // Only name if:
1339
+ // 1. Session hasn't been named yet
1340
+ // 2. Current title is the default "New Session"
1341
+ // 3. There are user messages to base the name on
1342
+ fs.appendFileSync("/tmp/koi-debug.log", `[triggerSessionNaming] sessionNamedRef: ${sessionNamedRef.current}, sessionTitle: "${sessionTitle}", equals: ${sessionTitle === "New Session"}\n`);
1343
+ if (sessionNamedRef.current) return;
1344
+ if (sessionTitle !== "New Session") return;
1345
+
1346
+ const userMessages = allMessages
1347
+ .filter((m) => m.type === "user")
1348
+ .map((m) => m.content);
1349
+
1350
+ if (userMessages.length === 0) return;
1351
+
1352
+ const name = await generateSessionNameFromMessages(userMessages);
1353
+ if (name) {
1354
+ sessionNamedRef.current = true;
1355
+ // Update all: Pi AgentSession, React state, and settings file
1356
+ // Use setSessionTitleWrapper to also persist the title to koiState
1357
+ setSessionTitleWrapper(name);
1358
+ }
1359
+ },
1360
+ [sessionTitle, session, setSessionTitleWrapper] // intentional: omit sessionTitle to avoid re-running
1361
+ );
1362
+
1363
+ const prompt = useCallback(
1364
+ async (text: string) => {
1365
+ if (!session) return;
1366
+ setMessages((prev) => {
1367
+ const updated = prev.concat({ id: generateId("user"), type: "user", content: text });
1368
+ // Trigger naming asynchronously after state update
1369
+ void triggerSessionNaming(updated);
1370
+ return updated;
1371
+ });
1372
+ await session.prompt(text);
1373
+ },
1374
+ [session, triggerSessionNaming]
1375
+ );
1376
+
1377
+ const steer = useCallback(
1378
+ async (text: string) => {
1379
+ if (!session) return;
1380
+ localSteerQueueRef.current.push(text);
1381
+ await session.steer(text);
1382
+ },
1383
+ [session]
1384
+ );
1385
+
1386
+ const followUp = useCallback(
1387
+ async (text: string) => {
1388
+ if (!session) return;
1389
+ localFollowUpQueueRef.current.push(text);
1390
+ await session.followUp(text);
1391
+ },
1392
+ [session]
1393
+ );
1394
+
1395
+ const abort = useCallback(async () => {
1396
+ await session?.abort();
1397
+ }, [session]);
1398
+
1399
+ // Per-message collapse toggle: tool_calls collapse their full output;
1400
+ // agent messages collapse their thinking block (if present).
1401
+ const toggleCollapse = useCallback((id: string) => {
1402
+ setMessages((prev) =>
1403
+ prev.map((m) => {
1404
+ if (m.id === id && m.type === "tool_call") {
1405
+ if (!isToolExpandable(m.toolName)) return m;
1406
+ return { ...m, collapsed: !m.collapsed };
1407
+ }
1408
+ if (m.id === id && m.type === "agent" && m.thinking) return { ...m, thinkingCollapsed: !m.thinkingCollapsed };
1409
+ return m;
1410
+ })
1411
+ );
1412
+ }, []);
1413
+
1414
+ // Global expand/collapse: updates every collapsible message at once.
1415
+ // Also sets allExpandedRef so *new* tool calls inherit the current preference.
1416
+ const updateAllCollapsed = useCallback((collapsed: boolean) => {
1417
+ allExpandedRef.current = !collapsed;
1418
+ setMessages((prev) =>
1419
+ prev.map((m) => {
1420
+ if (m.type === "tool_call") {
1421
+ if (!isToolExpandable(m.toolName) || isToolForceExpanded(m.toolName)) return m;
1422
+ return { ...m, collapsed };
1423
+ }
1424
+ if (m.type === "agent" && m.thinking) return { ...m, thinkingCollapsed: collapsed };
1425
+ return m;
1426
+ })
1427
+ );
1428
+ }, []);
1429
+
1430
+ const expandAll = useCallback(() => updateAllCollapsed(false), [updateAllCollapsed]);
1431
+ const collapseAll = useCallback(() => updateAllCollapsed(true), [updateAllCollapsed]);
1432
+
1433
+ const clearMessages = useCallback(() => {
1434
+ setMessages([]);
1435
+ streamingMsgIdRef.current = null;
1436
+ pendingToolsRef.current.clear();
1437
+ }, []);
1438
+
1439
+ const removePendingMessage = useCallback(
1440
+ (type: "sheer" | "queued", index: number) => {
1441
+ if (!session) return null;
1442
+ const cleared = session.clearQueue();
1443
+ const newSteering = [...cleared.steering];
1444
+ const newFollowUp = [...cleared.followUp];
1445
+
1446
+ let removedText: string | null = null;
1447
+ if (type === "sheer") {
1448
+ removedText = newSteering.splice(index, 1)[0] ?? null;
1449
+ localSteerQueueRef.current.splice(index, 1);
1450
+ } else {
1451
+ removedText = newFollowUp.splice(index, 1)[0] ?? null;
1452
+ localFollowUpQueueRef.current.splice(index, 1);
1453
+ }
1454
+
1455
+ // Re-add remaining messages to Pi's queue
1456
+ for (const text of newSteering) {
1457
+ void session.steer(text);
1458
+ }
1459
+ for (const text of newFollowUp) {
1460
+ void session.followUp(text);
1461
+ }
1462
+
1463
+ return removedText;
1464
+ },
1465
+ [session]
1466
+ );
1467
+
1468
+ const retractMessage = useCallback((id: string) => {
1469
+ let retractedText: string | null = null;
1470
+ setMessages((prev) => {
1471
+ const idx = prev.findIndex((m) => m.id === id && m.type === "user");
1472
+ if (idx < 0) return prev;
1473
+ const msg = prev[idx];
1474
+ if (msg && msg.type === "user") {
1475
+ retractedText = msg.content;
1476
+ }
1477
+ return [...prev.slice(0, idx), ...prev.slice(idx + 1)];
1478
+ });
1479
+ return retractedText;
1480
+ }, []);
1481
+
1482
+ const addPlanMessage = useCallback(
1483
+ async (content: string) => {
1484
+ if (!session) return;
1485
+ // Remove any existing plan custom messages from the session so the new plan replaces the old one.
1486
+ const filtered = session.state.messages.filter((m) => !isCustomPlanMessage(m));
1487
+ if (filtered.length !== session.state.messages.length) {
1488
+ session.state.messages = filtered;
1489
+ }
1490
+ await session.sendCustomMessage(
1491
+ { customType: "plan", content, display: true },
1492
+ { triggerTurn: false }
1493
+ );
1494
+ // Update React state: replace any existing plan UI messages with the new one.
1495
+ setMessages((prev) => {
1496
+ const withoutOldPlan = prev.filter((m) => m.type !== "plan");
1497
+ return [...withoutOldPlan, { id: generateId("plan"), type: "plan", content }] as UIMessage[];
1498
+ });
1499
+ // Save snapshot since plan state changed.
1500
+ fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[plan] addPlanMessage calling saveSnapshotIfChanged plan=${content.slice(0, 30)}\n`);
1501
+ saveSnapshotIfChanged(session, {
1502
+ tasks: globalTaskManager.listTasks(),
1503
+ planText: content,
1504
+ agentMode: getAgentMode(),
1505
+ activeTools: getActiveToolNamesForMode(getAgentMode()),
1506
+ });
1507
+ },
1508
+ [session]
1509
+ );
1510
+
1511
+ // Sync agent mode changes to sessionStateRef for persistence
1512
+ const syncAgentMode = useCallback(
1513
+ (mode: "build" | "ask" | "plan") => {
1514
+ sessionStateRef.current = {
1515
+ ...sessionStateRef.current,
1516
+ agentMode: mode,
1517
+ activeTools: getActiveToolNamesForMode(mode),
1518
+ };
1519
+ if (session) {
1520
+ session.setActiveToolsByName(sessionStateRef.current.activeTools);
1521
+ injectModeIntoSystemPrompt(session, mode);
1522
+ }
1523
+ },
1524
+ [session]
1525
+ );
1526
+
1527
+ return {
1528
+ session,
1529
+ messages,
1530
+ isStreaming,
1531
+ isReady,
1532
+ error,
1533
+ steeringMessages,
1534
+ followUpMessages,
1535
+ isConnectingMcp,
1536
+ mcpConnectionProgress,
1537
+ prompt,
1538
+ steer,
1539
+ followUp,
1540
+ abort,
1541
+ toggleCollapse,
1542
+ expandAll,
1543
+ collapseAll,
1544
+ clearMessages,
1545
+ removePendingMessage,
1546
+ retractMessage,
1547
+ switchSession,
1548
+ newSession,
1549
+ forkSession,
1550
+ sessionList,
1551
+ refreshSessionList,
1552
+ currentSessionId,
1553
+ saveCurrentState,
1554
+ sessionTitle,
1555
+ setSessionTitle: setSessionTitleWrapper,
1556
+ deleteSession,
1557
+ addPlanMessage,
1558
+ syncAgentMode,
1559
+ };
1560
+ }