@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,1178 @@
1
+ /**
2
+ * TUI Application
3
+ *
4
+ * Orchestrates the terminal UI using OpenTUI React: layout manager,
5
+ * event routing, and the main render loop.
6
+ * Integrates with Pi AgentSession for LLM agent loop.
7
+ */
8
+
9
+ import { useState, useCallback, useMemo, useRef, useEffect } from "react";
10
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
11
+ import { SyntaxStyle, createTextAttributes, type TextareaRenderable, type KeyBinding } from "@opentui/core";
12
+ import { useDialog } from "@opentui-ui/dialog/react";
13
+
14
+ /* ───────── Components ───────── */
15
+ import {
16
+ ChatPanel,
17
+ type ChatPanelHandle,
18
+ wrapText,
19
+ isToolExpandable,
20
+ isToolForceExpanded,
21
+ } from "./components/chat-panel.js";
22
+ import { InputBox } from "./components/input-box.js";
23
+ import { PendingArea } from "./components/pending-area.js";
24
+ import { EditPendingModal } from "./components/edit-pending-modal.js";
25
+ import { InfoBar } from "./components/info-bar.js";
26
+ import { SideBar } from "./components/side-bar.js";
27
+ import { ExitModal } from "./components/exit-modal.js";
28
+ import { CommandPanel, type CommandDef } from "./components/command-panel.js";
29
+ import { RenameModal } from "./components/rename-modal.js";
30
+ import { ConnectModal } from "./components/connect-modal.js";
31
+ import { ModelModal } from "./components/model-modal.js";
32
+ import { SessionModal } from "./components/session-modal.js";
33
+ import { ConfirmModal } from "./components/confirm-modal.js";
34
+ import { ConnectingModal } from "./components/connecting-modal.js";
35
+ import { ForkModal } from "./components/fork-modal.js";
36
+ import { ImagePreviewModal } from "./components/image-preview-modal.js";
37
+ import { MCPSettings } from "./components/mcp/MCPSettings.js";
38
+ import { SkillsMenu } from "../skills/SkillsMenu.js";
39
+ import {
40
+ getActiveSkills,
41
+ detectSkillInvocation,
42
+ invokeSkill,
43
+ loadAllSkills,
44
+ getSkillCountBySource,
45
+ initBundledSkills,
46
+ } from "../skills/index.js";
47
+ import type { SkillCommand } from "../skills/types.js";
48
+ import type { InputBoxHandle } from "./components/input-box.js";
49
+ import { refreshMcpTools } from "../agent/session.js";
50
+
51
+ /* ───────── Skill Helpers ───────── */
52
+
53
+ function extractTextFromContent(content: unknown[]): string {
54
+ const blocks = content as Array<{ type: string; text?: string }>;
55
+ return blocks
56
+ .filter((block): block is { type: "text"; text: string } => block.type === "text" && "text" in block)
57
+ .map((block) => block.text)
58
+ .join("\n\n");
59
+ }
60
+
61
+ /* ───────── Agent & Config ───────── */
62
+ import {
63
+ getCurrentModel,
64
+ setCurrentModel,
65
+ getAuxiliaryModel,
66
+ setAuxiliaryModel,
67
+ resolvePiModel,
68
+ } from "../config/settings.js";
69
+ import { useKoiAgent, isInternalNotification } from "../agent/hooks.js";
70
+ import type { SessionMeta } from "../agent/session-store.js";
71
+ import { globalTaskManager, type Task } from "../agent/session-tasks.js";
72
+ import { subagentRegistry, type AsyncSubagentEntry } from "../agent/subagent-registry.js";
73
+ import { monitorRegistry, type MonitorEntry } from "../agent/monitor-registry.js";
74
+ import {
75
+ subscribePermissions,
76
+ getPermissionQueue,
77
+ resolvePermission,
78
+ isYoloMode,
79
+ setYoloMode as setYoloModeGlobal,
80
+ } from "../agent/permission-ui.js";
81
+ import {
82
+ getAgentMode,
83
+ setAgentMode as setGlobalAgentMode,
84
+ cycleAgentMode,
85
+ getActiveToolNamesForMode,
86
+ subscribeModeChanges,
87
+ injectModeIntoSystemPrompt,
88
+ type AgentMode,
89
+ } from "../agent/mode.js";
90
+ import {
91
+ subscribeQuestions,
92
+ getQuestionQueue,
93
+ resolveQuestion,
94
+ } from "../agent/question-ui.js";
95
+ import {
96
+ subscribePlanApprovals,
97
+ getPlanApprovalQueue,
98
+ resolvePlanApproval,
99
+ type PlanApprovalResult,
100
+ } from "../agent/plan-ui.js";
101
+
102
+ const SIDEBAR_WIDTH = 28;
103
+
104
+ interface AppProps {
105
+ onExit: () => void;
106
+ }
107
+
108
+ /**
109
+ * Permission Formatting
110
+ *
111
+ * Converts raw tool arguments into a one-line human-readable string for the confirmation modal.
112
+ * Each tool has a tailored formatter so the user sees "Command: rm -rf /" instead of raw JSON.
113
+ */
114
+
115
+ const PERMISSION_FORMATTERS: Record<string, (args: Record<string, unknown>) => string> = {
116
+ bash: (a) => `Command: ${String(a["command"] ?? "?")}`,
117
+ webfetch: (a) => `URL: ${String(a["url"] ?? "?")}`,
118
+ read: (a) => `Path: ${String(a["path"] ?? a["file"] ?? "?")}`,
119
+ write: (a) => `Path: ${String(a["path"] ?? a["file"] ?? "?")}`,
120
+ edit: (a) => `Path: ${String(a["path"] ?? a["file"] ?? "?")}`,
121
+ grep: (a) => `Pattern: ${String(a["pattern"] ?? "?")}`,
122
+ find: (a) => `Path: ${String(a["path"] ?? ".")}`,
123
+ ls: (a) => `Path: ${String(a["path"] ?? ".")}`,
124
+ };
125
+
126
+ function formatPermissionArgs(toolName: string, args: unknown): string {
127
+ if (!args || typeof args !== "object") return JSON.stringify(args);
128
+ const formatter = PERMISSION_FORMATTERS[toolName];
129
+ return formatter ? formatter(args as Record<string, unknown>) : JSON.stringify(args, null, 2);
130
+ }
131
+
132
+ function CustomPromptContent({
133
+ resolve,
134
+ question,
135
+ width,
136
+ height,
137
+ }: {
138
+ resolve: (value: string) => void;
139
+ question: string;
140
+ width: number;
141
+ height: number;
142
+ }) {
143
+ const taRef = useRef<TextareaRenderable>(null);
144
+ const handleSubmit = () => {
145
+ resolve(taRef.current?.editBuffer.getText() ?? "");
146
+ };
147
+ const contentWidth = Math.min(70, Math.max(20, width - 8));
148
+ const questionLines = wrapText(question, contentWidth - 4, 0);
149
+ const keyBindings = useMemo<KeyBinding[]>(() => [{ name: "return", action: "submit" }], []);
150
+
151
+ return (
152
+ <box
153
+ flexDirection="column"
154
+ alignSelf="center"
155
+ borderStyle="rounded"
156
+ borderColor="#4a4a5a"
157
+ backgroundColor="#1a1a2e"
158
+ paddingX={2}
159
+ paddingY={1}
160
+ width={contentWidth}
161
+ maxHeight={Math.max(10, height - 6)}
162
+ >
163
+ <text alignSelf="center" wrapMode="none" attributes={createTextAttributes({ bold: true })} fg="#60a5fa">
164
+ Custom Answer
165
+ </text>
166
+ <box flexDirection="column" gap={1}>
167
+ {questionLines.map((line, i) => (
168
+ <text key={`q-${i}`} wrapMode="none" fg="#f8f8f2">{line}</text>
169
+ ))}
170
+ </box>
171
+ <box marginTop={1} height={3}>
172
+ <textarea
173
+ ref={taRef}
174
+ initialValue=""
175
+ focused={true}
176
+ showCursor={true}
177
+ height={3}
178
+ onSubmit={handleSubmit}
179
+ keyBindings={keyBindings}
180
+ />
181
+ </box>
182
+ <box alignSelf="center" marginTop={1} flexDirection="row" gap={2}>
183
+ <box paddingX={2} backgroundColor="#2dd4bf" onMouseUp={handleSubmit}>
184
+ <text fg="white" attributes={createTextAttributes({ bold: true })}>Submit</text>
185
+ </box>
186
+ <box paddingX={2} backgroundColor="#f43f5e" onMouseUp={() => resolve("")}>
187
+ <text fg="white" attributes={createTextAttributes({ bold: true })}>Cancel</text>
188
+ </box>
189
+ </box>
190
+ </box>
191
+ );
192
+ }
193
+
194
+ /**
195
+ * App Component
196
+ *
197
+ * Root TUI layout: chat panel + input + sidebar on the left, modals overlay on top.
198
+ * Keyboard shortcuts are globally bound here; modal-open state blocks shortcuts underneath.
199
+ */
200
+
201
+ export function App({ onExit }: AppProps) {
202
+ const { width, height } = useTerminalDimensions();
203
+ const [showExitModal, setShowExitModal] = useState(false);
204
+ const [showCommandPanel, setShowCommandPanel] = useState(false);
205
+ const [showRenameModal, setShowRenameModal] = useState(false);
206
+ const [showConnectModal, setShowConnectModal] = useState(false);
207
+ const [showModelModal, setShowModelModal] = useState(false);
208
+ const [showSessionModal, setShowSessionModal] = useState(false);
209
+ const [showForkModal, setShowForkModal] = useState(false);
210
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
211
+ const [sessionToDelete, setSessionToDelete] = useState<SessionMeta | null>(null);
212
+ const [showImageModal, setShowImageModal] = useState(false);
213
+ const [imageModalUrl, setImageModalUrl] = useState("");
214
+ const [showEditPendingModal, setShowEditPendingModal] = useState(false);
215
+ const [editPendingType, setEditPendingType] = useState<"sheer" | "queued" | null>(null);
216
+ const [editPendingIndex, setEditPendingIndex] = useState(-1);
217
+ const [editPendingText, setEditPendingText] = useState("");
218
+ const [currentModel, setCurrentModelState] = useState(getCurrentModel);
219
+ const [, setAuxiliaryModelState] = useState(getAuxiliaryModel);
220
+
221
+ const [sidebarContextUsage, setSidebarContextUsage] = useState("0%");
222
+ const [sidebarTokenCount, setSidebarTokenCount] = useState("(0)");
223
+ const [sidebarCost, setSidebarCost] = useState("$0.00");
224
+ const [tasks, setTasks] = useState<Task[]>([]);
225
+ const [subagents, setSubagents] = useState<AsyncSubagentEntry[]>([]);
226
+ const [monitors, setMonitors] = useState<MonitorEntry[]>([]);
227
+ const [yoloMode, setYoloMode] = useState(false);
228
+ const [agentMode, setAgentMode] = useState<AgentMode>(getAgentMode());
229
+ const [showMCPSettings, setShowMCPSettings] = useState(false);
230
+ const [showSkillsModal, setShowSkillsModal] = useState(false);
231
+ const [skills, setSkills] = useState<SkillCommand[]>([]);
232
+
233
+ // Sync yoloMode to global permission-ui state
234
+ useEffect(() => {
235
+ setYoloModeGlobal(yoloMode);
236
+ }, [yoloMode]);
237
+
238
+ const dialog = useDialog();
239
+
240
+ // Load skills on mount
241
+ useEffect(() => {
242
+ void (async () => {
243
+ // Register built-in bundled skills first
244
+ initBundledSkills();
245
+
246
+ // Load all skills from configured directories
247
+ await loadAllSkills(process.cwd());
248
+
249
+ // Use getActiveSkills to get all loaded skills
250
+ const activeSkills = getActiveSkills();
251
+ setSkills(activeSkills as SkillCommand[]);
252
+
253
+ console.log("[skills] Loaded:", getSkillCountBySource());
254
+ })();
255
+ }, []);
256
+
257
+ const {
258
+ session,
259
+ messages,
260
+ isStreaming,
261
+ isReady,
262
+ error,
263
+ steeringMessages,
264
+ followUpMessages,
265
+ isConnectingMcp,
266
+ mcpConnectionProgress,
267
+ prompt,
268
+ steer,
269
+ followUp,
270
+ abort,
271
+ toggleCollapse,
272
+ expandAll,
273
+ collapseAll,
274
+ removePendingMessage,
275
+ switchSession,
276
+ newSession,
277
+ forkSession,
278
+ sessionList,
279
+ refreshSessionList,
280
+ currentSessionId,
281
+ saveCurrentState,
282
+ sessionTitle,
283
+ setSessionTitle,
284
+ deleteSession,
285
+ addPlanMessage,
286
+ syncAgentMode,
287
+ } = useKoiAgent();
288
+
289
+ // Handle skill invocation from skills menu
290
+ const handleInvokeSkill = useCallback(
291
+ async (skill: SkillCommand, args: string) => {
292
+ const content = await invokeSkill(skill, args, session);
293
+ const skillPrompt = extractTextFromContent(content);
294
+ if (skillPrompt) {
295
+ await prompt(skillPrompt);
296
+ }
297
+ setShowSkillsModal(false);
298
+ },
299
+ [session, prompt]
300
+ );
301
+
302
+ // Sync agent mode changes to the active session's tool set
303
+ const applyAgentMode = useCallback(
304
+ (mode: AgentMode) => {
305
+ setGlobalAgentMode(mode);
306
+ setAgentMode(mode);
307
+ syncAgentMode(mode);
308
+ },
309
+ [syncAgentMode]
310
+ );
311
+
312
+ const handleModeSwitch = useCallback(() => {
313
+ const next = cycleAgentMode();
314
+ applyAgentMode(next);
315
+ }, [applyAgentMode]);
316
+
317
+ // Subscribe to external mode changes (e.g. from tools) so UI stays in sync
318
+ useEffect(() => {
319
+ return subscribeModeChanges(() => {
320
+ const mode = getAgentMode();
321
+ setAgentMode(mode);
322
+ });
323
+ }, []);
324
+
325
+ // Apply tool restrictions and inject mode awareness into system prompt
326
+ useEffect(() => {
327
+ if (!session) return;
328
+ session.setActiveToolsByName(getActiveToolNamesForMode(agentMode));
329
+ injectModeIntoSystemPrompt(session, agentMode);
330
+ }, [agentMode, session]);
331
+
332
+ // Subscribe to subagent registry changes for live sidebar updates.
333
+ useEffect(() => {
334
+ const unsubscribe = subagentRegistry.subscribe(() => {
335
+ setSubagents(subagentRegistry.getAll());
336
+ });
337
+ return unsubscribe;
338
+ }, []);
339
+
340
+ // Subscribe to monitor registry changes for live sidebar updates.
341
+ // Filters to show only monitors for the current session.
342
+ useEffect(() => {
343
+ const unsubscribe = monitorRegistry.subscribe(() => {
344
+ // Only show monitors belonging to the current session
345
+ if (currentSessionId) {
346
+ setMonitors(monitorRegistry.getBySession(currentSessionId));
347
+ }
348
+ });
349
+ // Re-filter when session changes
350
+ if (currentSessionId) {
351
+ setMonitors(monitorRegistry.getBySession(currentSessionId));
352
+ }
353
+ return unsubscribe;
354
+ }, [currentSessionId]);
355
+
356
+ // Polls session stats (token count, cost, context usage) every 2s for the sidebar.
357
+ // Falls back to zeroed values when no session is active.
358
+ useEffect(() => {
359
+ const update = () => {
360
+ if (!session) {
361
+ setSidebarContextUsage("0%");
362
+ setSidebarTokenCount("(0)");
363
+ setSidebarCost("$0.00");
364
+ setTasks([]);
365
+ setSubagents([]);
366
+ return;
367
+ }
368
+
369
+ const usage = session.getContextUsage();
370
+ const stats = session.getSessionStats();
371
+ const model = session.model;
372
+
373
+ let totalCost = 0;
374
+ if (model && stats) {
375
+ const costInput = (stats.tokens.input * model.cost.input) / 1_000_000;
376
+ const costOutput = (stats.tokens.output * model.cost.output) / 1_000_000;
377
+ const costCacheRead = (stats.tokens.cacheRead * model.cost.cacheRead) / 1_000_000;
378
+ const costCacheWrite = (stats.tokens.cacheWrite * model.cost.cacheWrite) / 1_000_000;
379
+ totalCost = costInput + costOutput + costCacheRead + costCacheWrite;
380
+ }
381
+
382
+ const tokens = usage?.tokens ?? 0;
383
+ const tokenStr =
384
+ tokens >= 1000 ? `(${(tokens / 1000).toFixed(1)}K)` : tokens > 0 ? `(${tokens})` : "(0)";
385
+ const percentStr = usage?.percent != null ? `${Math.round(usage.percent)}%` : "0%";
386
+ const costStr = totalCost > 0 ? `$${totalCost.toFixed(2)}` : "$0.00";
387
+
388
+ setSidebarContextUsage(percentStr);
389
+ setSidebarTokenCount(tokenStr);
390
+ setSidebarCost(costStr);
391
+ setTasks(globalTaskManager.listTasks());
392
+ setSubagents(subagentRegistry.getAll());
393
+ // Only show monitors for the current session
394
+ setMonitors(currentSessionId ? monitorRegistry.getBySession(currentSessionId) : []);
395
+ };
396
+
397
+ update();
398
+ const interval = setInterval(update, 2000);
399
+ return () => clearInterval(interval);
400
+ }, [session, currentSessionId]);
401
+
402
+ // Processes the permission-request queue one item at a time.
403
+ // Shows a styled confirm modal; keyboard y/n also works while the modal is open.
404
+ const processingPermissionRef = useRef(false);
405
+ const permissionResolveRef = useRef<((value: boolean) => void) | null>(null);
406
+ const [permissionModalOpen, setPermissionModalOpen] = useState(false);
407
+
408
+ // Keyboard shortcut refs for dialog-based modals (not React-state modals).
409
+ const planApprovalResolveRef = useRef<((value: string) => void) | null>(null);
410
+ const questionResolveRef = useRef<((value: string) => void) | null>(null);
411
+ const questionOptionsRef = useRef<string[]>([]);
412
+
413
+ // Process the next permission in the queue (if any).
414
+ // This function is called both when new permissions are added and when
415
+ // a permission is resolved, to ensure multiple pending permissions are handled.
416
+ const processPermissionQueue = useCallback(async () => {
417
+ if (processingPermissionRef.current) return;
418
+ const queue = getPermissionQueue();
419
+ if (queue.length === 0) return;
420
+
421
+ const request = queue[0];
422
+ if (!request) {
423
+ processingPermissionRef.current = false;
424
+ return;
425
+ }
426
+
427
+ // In YOLO mode, auto-approve all permissions
428
+ if (isYoloMode()) {
429
+ resolvePermission(request.id, true);
430
+ // Use setTimeout to allow the queue to be processed recursively
431
+ setTimeout(() => { void processPermissionQueue(); }, 0);
432
+ return;
433
+ }
434
+
435
+ processingPermissionRef.current = true;
436
+ setPermissionModalOpen(true);
437
+
438
+ const allowed = await dialog.confirm({
439
+ backdropColor: "#000000",
440
+ backdropOpacity: "50%",
441
+ closeOnEscape: true,
442
+ unstyled: true,
443
+ content: ({ resolve }) => {
444
+ permissionResolveRef.current = resolve;
445
+ const contentWidth = Math.min(70, Math.max(20, width - 8));
446
+ const textWidth = Math.max(1, contentWidth - 6);
447
+ const toolLines = wrapText(`Tool: ${request.toolName}`, textWidth, 0);
448
+ const argsLines = wrapText(formatPermissionArgs(request.toolName, request.args), textWidth, 0);
449
+ const reasonLines = wrapText(`Reason: ${request.reason}`, textWidth, 0);
450
+ return (
451
+ <box
452
+ flexDirection="column"
453
+ alignSelf="center"
454
+ borderStyle="rounded"
455
+ borderColor="#4a4a5a"
456
+ backgroundColor="#1a1a2e"
457
+ paddingX={2}
458
+ paddingY={1}
459
+ width={contentWidth}
460
+ maxHeight={Math.max(10, height - 6)}
461
+ >
462
+ <text alignSelf="center" wrapMode="none" attributes={createTextAttributes({ bold: true })} fg="#fbbf24">
463
+ Permission Request
464
+ </text>
465
+ <box flexDirection="column" gap={1}>
466
+ <box flexDirection="column">
467
+ {toolLines.map((line, i) => (
468
+ <text key={`t-${i}`} wrapMode="none" fg="#00f5ff">{line}</text>
469
+ ))}
470
+ </box>
471
+ <box flexDirection="column">
472
+ {argsLines.map((line, i) => (
473
+ <text key={`a-${i}`} wrapMode="none" fg="#a5b4fc">{line}</text>
474
+ ))}
475
+ </box>
476
+ <box flexDirection="column">
477
+ {reasonLines.map((line, i) => (
478
+ <text key={`r-${i}`} wrapMode="none" fg="#ff79c6">{line}</text>
479
+ ))}
480
+ </box>
481
+ </box>
482
+ <box alignSelf="center" marginTop={1} flexDirection="row" gap={2}>
483
+ <box paddingX={2} backgroundColor="#2dd4bf" onMouseUp={() => resolve(true)}>
484
+ <text fg="white" attributes={createTextAttributes({ bold: true })}>Yes</text>
485
+ </box>
486
+ <box paddingX={2} backgroundColor="#f43f5e" onMouseUp={() => resolve(false)}>
487
+ <text fg="white" attributes={createTextAttributes({ bold: true })}>No!</text>
488
+ </box>
489
+ </box>
490
+ </box>
491
+ );
492
+ },
493
+ });
494
+
495
+ resolvePermission(request.id, allowed);
496
+ processingPermissionRef.current = false;
497
+ setPermissionModalOpen(false);
498
+ permissionResolveRef.current = null;
499
+
500
+ // After resolving, check if there are more permissions in the queue and process them.
501
+ // Use setTimeout to avoid blocking and allow the event loop to settle.
502
+ setTimeout(() => { void processPermissionQueue(); }, 0);
503
+ }, [dialog, width, height]);
504
+
505
+ useEffect(() => {
506
+ const unsubscribe = subscribePermissions(() => {
507
+ void processPermissionQueue();
508
+ });
509
+ return unsubscribe;
510
+ }, [processPermissionQueue]);
511
+
512
+ // Processes the askUserQuestion queue one item at a time.
513
+ const processingQuestionRef = useRef(false);
514
+ useEffect(() => {
515
+ const unsubscribe = subscribeQuestions(async () => {
516
+ if (processingQuestionRef.current) return;
517
+ const queue = getQuestionQueue();
518
+ if (queue.length === 0) return;
519
+ const request = queue[0];
520
+ if (!request) {
521
+ processingQuestionRef.current = false;
522
+ return;
523
+ }
524
+
525
+ processingQuestionRef.current = true;
526
+ const allOptions = [...request.options, "__other__"];
527
+ let answer = "";
528
+
529
+ for (;;) {
530
+ const choiceResult = await dialog.choice<string>({
531
+ backdropColor: "#000000",
532
+ backdropOpacity: "50%",
533
+ closeOnEscape: true,
534
+ unstyled: true,
535
+ content: ({ resolve, dismiss: _dismiss }) => {
536
+ questionResolveRef.current = resolve;
537
+ questionOptionsRef.current = allOptions;
538
+ const contentWidth = Math.min(70, Math.max(20, width - 8));
539
+ const questionLines = wrapText(request.question, contentWidth - 4, 0);
540
+ return (
541
+ <box
542
+ flexDirection="column"
543
+ alignSelf="center"
544
+ borderStyle="rounded"
545
+ borderColor="#4a4a5a"
546
+ backgroundColor="#1a1a2e"
547
+ paddingX={2}
548
+ paddingY={1}
549
+ width={contentWidth}
550
+ maxHeight={Math.max(10, height - 6)}
551
+ >
552
+ <text alignSelf="center" wrapMode="none" attributes={createTextAttributes({ bold: true })} fg="#60a5fa">
553
+ Question
554
+ </text>
555
+ <box flexDirection="column" gap={1}>
556
+ {questionLines.map((line, i) => (
557
+ <text key={`q-${i}`} wrapMode="none" fg="#f8f8f2">{line}</text>
558
+ ))}
559
+ </box>
560
+ <box flexDirection="column" gap={1} marginTop={1}>
561
+ {allOptions.map((opt, idx) => {
562
+ const label = opt === "__other__" ? "Other (custom)" : opt;
563
+ return (
564
+ <box
565
+ key={opt}
566
+ paddingX={1}
567
+ paddingY={1}
568
+ backgroundColor="#2d2d44"
569
+ onMouseUp={() => resolve(opt)}
570
+ >
571
+ <text fg="#f8f8f2">{`[${idx + 1}] ${label}`}</text>
572
+ </box>
573
+ );
574
+ })}
575
+ </box>
576
+ <box alignSelf="center" marginTop={1}>
577
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
578
+ {`Press 1-${allOptions.length} to select, Esc to cancel`}
579
+ </text>
580
+ </box>
581
+ </box>
582
+ );
583
+ },
584
+ });
585
+
586
+ questionResolveRef.current = null;
587
+ questionOptionsRef.current = [];
588
+ if (choiceResult === "__other__") {
589
+ const custom = await dialog.prompt<string>({
590
+ backdropColor: "#000000",
591
+ backdropOpacity: "50%",
592
+ closeOnEscape: true,
593
+ unstyled: true,
594
+ content: ({ resolve }) => (
595
+ <CustomPromptContent
596
+ resolve={resolve}
597
+ question={request.question}
598
+ width={width}
599
+ height={height}
600
+ />
601
+ ),
602
+ });
603
+ if (custom !== undefined && custom.trim() !== "") {
604
+ answer = custom;
605
+ break;
606
+ }
607
+ // cancelled or empty input — loop back to choice dialog
608
+ } else {
609
+ answer = choiceResult ?? "";
610
+ break;
611
+ }
612
+ }
613
+
614
+ resolveQuestion(request.id, answer);
615
+ processingQuestionRef.current = false;
616
+ });
617
+
618
+ return unsubscribe;
619
+ }, [dialog, width, height]);
620
+
621
+ // Processes the plan-approval queue.
622
+ const processingPlanApprovalRef = useRef(false);
623
+ useEffect(() => {
624
+ const unsubscribe = subscribePlanApprovals(async () => {
625
+ if (processingPlanApprovalRef.current) return;
626
+ const queue = getPlanApprovalQueue();
627
+ if (queue.length === 0) return;
628
+ const request = queue[0];
629
+ if (!request) {
630
+ processingPlanApprovalRef.current = false;
631
+ return;
632
+ }
633
+
634
+ processingPlanApprovalRef.current = true;
635
+ const modalWidth = Math.min(80, Math.max(40, width - 10));
636
+ const planHeight = Math.max(8, height - 14);
637
+
638
+ let approvalResult: PlanApprovalResult = { approved: false };
639
+
640
+ for (;;) {
641
+ const result = await dialog.choice<string>({
642
+ backdropColor: "#000000",
643
+ backdropOpacity: "50%",
644
+ closeOnEscape: true,
645
+ unstyled: true,
646
+ content: ({ resolve, dismiss: _dismiss }) => {
647
+ planApprovalResolveRef.current = resolve;
648
+ const syntaxStyle = SyntaxStyle.create();
649
+ syntaxStyle.registerStyle("markup.heading", { fg: "#60a5fa", bold: true });
650
+ syntaxStyle.registerStyle("markup.strong", { bold: true });
651
+ syntaxStyle.registerStyle("markup.italic", { fg: "#bd93f9", italic: true });
652
+ syntaxStyle.registerStyle("markup.link", { fg: "#8be9fd", underline: true });
653
+ syntaxStyle.registerStyle("markup.raw", { fg: "#a5b4fc" });
654
+ syntaxStyle.registerStyle("markup.raw.block", { fg: "#f8f8f2", bg: "#44475a" });
655
+ syntaxStyle.registerStyle("markup.list", { fg: "#ff79c6" });
656
+ return (
657
+ <box
658
+ flexDirection="column"
659
+ alignSelf="center"
660
+ borderStyle="rounded"
661
+ borderColor="#4a4a5a"
662
+ backgroundColor="#1a1a2e"
663
+ paddingX={2}
664
+ paddingY={1}
665
+ width={modalWidth}
666
+ maxHeight={height - 4}
667
+ >
668
+ <text alignSelf="center" wrapMode="none" attributes={createTextAttributes({ bold: true })} fg="#60a5fa">
669
+ Review Plan
670
+ </text>
671
+ <scrollbox scrollY={true} scrollX={false} height={planHeight} marginTop={1}>
672
+ <box flexDirection="column" width={modalWidth - 6}>
673
+ <markdown
674
+ content={request.plan}
675
+ syntaxStyle={syntaxStyle}
676
+ width={modalWidth - 6}
677
+ streaming={false}
678
+ conceal={true}
679
+ />
680
+ </box>
681
+ </scrollbox>
682
+ <box alignSelf="center" marginTop={1} flexDirection="row" gap={2}>
683
+ <box paddingX={2} backgroundColor="#2dd4bf" onMouseUp={() => resolve("yes")}>
684
+ <text fg="white" attributes={createTextAttributes({ bold: true })}>[Y]es</text>
685
+ </box>
686
+ <box paddingX={2} backgroundColor="#f43f5e" onMouseUp={() => resolve("no")}>
687
+ <text fg="white" attributes={createTextAttributes({ bold: true })}>[N]o</text>
688
+ </box>
689
+ <box paddingX={2} backgroundColor="#fbbf24" onMouseUp={() => resolve("comment")}>
690
+ <text fg="white" attributes={createTextAttributes({ bold: true })}>[C]omment</text>
691
+ </box>
692
+ </box>
693
+ </box>
694
+ );
695
+ },
696
+ });
697
+
698
+ planApprovalResolveRef.current = null;
699
+ if (result === "yes") {
700
+ approvalResult = { approved: true };
701
+ break;
702
+ } else if (result === "comment") {
703
+ const comment = await dialog.prompt<string>({
704
+ backdropColor: "#000000",
705
+ backdropOpacity: "50%",
706
+ closeOnEscape: true,
707
+ unstyled: true,
708
+ content: ({ resolve }) => (
709
+ <CustomPromptContent
710
+ resolve={resolve}
711
+ question="Enter your feedback on the plan:"
712
+ width={width}
713
+ height={height}
714
+ />
715
+ ),
716
+ });
717
+ if (comment !== undefined && comment.trim() !== "") {
718
+ approvalResult = { approved: false, comment };
719
+ break;
720
+ }
721
+ // cancelled or empty — loop back to plan review dialog
722
+ } else {
723
+ // "no" or ESC
724
+ approvalResult = { approved: false };
725
+ break;
726
+ }
727
+ }
728
+
729
+ resolvePlanApproval(request.id, approvalResult);
730
+ if (approvalResult.approved) {
731
+ await addPlanMessage(request.plan);
732
+ applyAgentMode("build");
733
+ }
734
+ processingPlanApprovalRef.current = false;
735
+ });
736
+
737
+ return unsubscribe;
738
+ }, [addPlanMessage, applyAgentMode, dialog, width, height]);
739
+
740
+ // Responsive layout: left column fills remaining width; sidebar is fixed at SIDEBAR_WIDTH.
741
+ const leftWidth = Math.max(1, width - SIDEBAR_WIDTH - 2);
742
+ const pendingCount = steeringMessages.length + followUpMessages.length;
743
+ const pendingHeight = pendingCount > 0 ? Math.min(pendingCount, 3) + (pendingCount > 3 ? 1 : 0) : 0;
744
+ const chatPanelHeight = Math.max(1, height - (error ? 1 : 0) - 5 - 1 - pendingHeight);
745
+ const chatPanelRef = useRef<ChatPanelHandle>(null);
746
+ const inputBoxRef = useRef<InputBoxHandle>(null);
747
+
748
+ // Filter out internal subagent notifications from the chat display.
749
+ // These messages are still present in the session state (so the LLM sees
750
+ // them), but we don't want to clutter the UI with XML task notifications.
751
+ const visibleMessages = useMemo(
752
+ () => messages.filter((m) => !(m.type === "user" && isInternalNotification(m.content))),
753
+ [messages]
754
+ );
755
+
756
+ const anyModalOpen =
757
+ showExitModal || showCommandPanel || showRenameModal || showConnectModal ||
758
+ showModelModal || showSessionModal || showForkModal || permissionModalOpen || showDeleteConfirm || showImageModal || showEditPendingModal || showMCPSettings || showSkillsModal;
759
+
760
+ // Thin wrapper handlers: mostly close modals after delegating to useKoiAgent actions.
761
+ const handleSubmit = useCallback(
762
+ (text: string) => {
763
+ if (!text.trim() || !isReady) return;
764
+
765
+ // Handle /plan command to switch to plan mode
766
+ if (text.trim() === "/plan") {
767
+ applyAgentMode("plan");
768
+ return;
769
+ }
770
+
771
+ // Handle /exit and /quit commands
772
+ if (text.trim() === "/exit" || text.trim() === "/quit") {
773
+ setShowExitModal(true);
774
+ return;
775
+ }
776
+
777
+ // Handle skill invocation
778
+ const skillInvocation = detectSkillInvocation(text);
779
+ if (skillInvocation) {
780
+ void (async () => {
781
+ const content = await invokeSkill(skillInvocation.skill, skillInvocation.args, session);
782
+ // Convert skill content to a prompt string
783
+ const skillPrompt = extractTextFromContent(content);
784
+ if (skillPrompt) {
785
+ await prompt(skillPrompt);
786
+ }
787
+ })();
788
+ return;
789
+ }
790
+
791
+ if (isStreaming) {
792
+ void steer(text);
793
+ } else {
794
+ void prompt(text);
795
+ }
796
+ },
797
+ [isReady, isStreaming, steer, prompt, applyAgentMode, session]
798
+ );
799
+
800
+ const handleQueueSubmit = useCallback(
801
+ (text: string) => {
802
+ if (!text.trim() || !isReady) return;
803
+ if (isStreaming) {
804
+ void followUp(text);
805
+ } else {
806
+ void prompt(text);
807
+ }
808
+ },
809
+ [isReady, isStreaming, followUp, prompt]
810
+ );
811
+
812
+ const handleRename = useCallback((newTitle: string) => {
813
+ setSessionTitle(newTitle);
814
+ setShowRenameModal(false);
815
+ }, [setSessionTitle]);
816
+
817
+ const modelInfo = useMemo(() => {
818
+ if (!currentModel) {
819
+ return { modelName: "Not configured", provider: "Use /model to select" };
820
+ }
821
+ return { modelName: currentModel.modelId, provider: `via ${currentModel.provider}` };
822
+ }, [currentModel]);
823
+
824
+ const handleNewSession = useCallback(async () => {
825
+ await newSession();
826
+ setShowSessionModal(false);
827
+ }, [newSession]);
828
+
829
+ const handleSwitchSession = useCallback(async (filePath: string) => {
830
+ await switchSession(filePath);
831
+ setShowSessionModal(false);
832
+ }, [switchSession]);
833
+
834
+ const handleFork = useCallback(async (entryId: string) => {
835
+ await forkSession(entryId);
836
+ setShowForkModal(false);
837
+ }, [forkSession]);
838
+
839
+ const handleDeleteRequest = useCallback((sessionId: string) => {
840
+ const meta = sessionList.find((s) => s.id === sessionId);
841
+ if (!meta) return;
842
+ setSessionToDelete(meta);
843
+ setShowDeleteConfirm(true);
844
+ }, [sessionList]);
845
+
846
+ const handleConfirmDelete = useCallback(async () => {
847
+ if (!sessionToDelete) return;
848
+ await deleteSession(sessionToDelete.id);
849
+ setShowDeleteConfirm(false);
850
+ setSessionToDelete(null);
851
+ }, [sessionToDelete, deleteSession]);
852
+
853
+ const handleCancelDelete = useCallback(() => {
854
+ setShowDeleteConfirm(false);
855
+ setSessionToDelete(null);
856
+ }, []);
857
+
858
+ const handleImageClick = useCallback((url: string) => {
859
+ setImageModalUrl(url);
860
+ setShowImageModal(true);
861
+ }, []);
862
+
863
+ const handleEditPending = useCallback(
864
+ (type: "sheer" | "queued", index: number) => {
865
+ const text = type === "sheer" ? steeringMessages[index] : followUpMessages[index];
866
+ if (text === undefined) return;
867
+ setEditPendingType(type);
868
+ setEditPendingIndex(index);
869
+ setEditPendingText(text);
870
+ setShowEditPendingModal(true);
871
+ },
872
+ [steeringMessages, followUpMessages]
873
+ );
874
+
875
+ const handleConfirmEditPending = useCallback(
876
+ (text: string) => {
877
+ if (!editPendingType || editPendingIndex < 0) return;
878
+ removePendingMessage(editPendingType, editPendingIndex);
879
+ if (editPendingType === "sheer") {
880
+ void steer(text);
881
+ } else {
882
+ void followUp(text);
883
+ }
884
+ setShowEditPendingModal(false);
885
+ },
886
+ [editPendingType, editPendingIndex, removePendingMessage, steer, followUp]
887
+ );
888
+
889
+ const handleCloseImageModal = useCallback(() => {
890
+ setShowImageModal(false);
891
+ setImageModalUrl("");
892
+ }, []);
893
+
894
+ // Build skill commands for the command palette
895
+ const skillCommands = useMemo<CommandDef[]>(() => {
896
+ return skills
897
+ .filter((skill) => skill.userInvocable && !skill.isHidden)
898
+ .map((skill) => ({
899
+ id: `/${skill.name}`,
900
+ label: skill.description || skill.name,
901
+ section: "Skills",
902
+ action: async () => {
903
+ const content = await invokeSkill(skill, "", session);
904
+ const skillPrompt = extractTextFromContent(content);
905
+ if (skillPrompt) {
906
+ await prompt(skillPrompt);
907
+ }
908
+ },
909
+ }));
910
+ }, [skills, session, prompt]);
911
+
912
+ // Slash-command definitions for the command palette (Ctrl+P).
913
+ const commands = useMemo<CommandDef[]>(
914
+ () => [
915
+ { id: "/new", label: "Start a new session", section: "Session", action: () => void handleNewSession() },
916
+ { id: "/fork", label: "Fork current session", section: "Session", action: () => setShowForkModal(true) },
917
+ { id: "/sessions", label: "Browse sessions", section: "Session", action: async () => { await refreshSessionList(); setShowSessionModal(true); } },
918
+ { id: "/compact", label: "Compact current session", section: "Session", action: () => { session?.compact().catch(() => {}); } },
919
+ { id: "/rename", label: "Rename session", section: "Session", action: () => setShowRenameModal(true) },
920
+ { id: "/exit", label: "Exit Koi", section: "Session", action: () => setShowExitModal(true) },
921
+ { id: "/quit", label: "Exit Koi (alias)", section: "Session", action: () => setShowExitModal(true) },
922
+ { id: "/yolo", label: "Toggle YOLO mode (auto-approve all permissions)", section: "Mode", action: () => setYoloMode((prev) => !prev) },
923
+ { id: "/mode", label: `Cycle agent mode (${agentMode})`, section: "Mode", action: () => handleModeSwitch() },
924
+ { id: "/plan", label: "Switch to plan mode (read-only, no file modifications)", section: "Mode", action: () => applyAgentMode("plan") },
925
+ { id: "/connect", label: "Connect to a provider", section: "Model", action: () => setShowConnectModal(true) },
926
+ { id: "/model", label: "Select a model", section: "Model", action: () => setShowModelModal(true) },
927
+ { id: "/mcp", label: "Open MCP settings", section: "Extensions", action: () => setShowMCPSettings(true) },
928
+ { id: "/skills", label: "List and manage skills", section: "Extensions", action: () => setShowSkillsModal(true) },
929
+ ...skillCommands,
930
+ ],
931
+ [session, handleNewSession, refreshSessionList, agentMode, handleModeSwitch, applyAgentMode, skillCommands]
932
+ );
933
+
934
+ // Global keyboard shortcuts. Guarded by anyModalOpen so typing in a modal doesn't trigger app actions.
935
+ useKeyboard((key) => {
936
+ if (anyModalOpen && !permissionModalOpen) return;
937
+
938
+ if (permissionModalOpen && permissionResolveRef.current) {
939
+ if (key.name === "y" || key.name === "Y") {
940
+ permissionResolveRef.current(true);
941
+ return;
942
+ }
943
+ if (key.name === "n" || key.name === "N") {
944
+ permissionResolveRef.current(false);
945
+ return;
946
+ }
947
+ return;
948
+ }
949
+
950
+ if (planApprovalResolveRef.current) {
951
+ if (key.name === "y" || key.name === "Y") {
952
+ planApprovalResolveRef.current("yes");
953
+ return;
954
+ }
955
+ if (key.name === "n" || key.name === "N") {
956
+ planApprovalResolveRef.current("no");
957
+ return;
958
+ }
959
+ if (key.name === "c" || key.name === "C") {
960
+ planApprovalResolveRef.current("comment");
961
+ return;
962
+ }
963
+ }
964
+
965
+ if (questionResolveRef.current && questionOptionsRef.current.length > 0) {
966
+ const digit = parseInt(key.name, 10);
967
+ if (!isNaN(digit) && digit >= 1 && digit <= questionOptionsRef.current.length) {
968
+ questionResolveRef.current(questionOptionsRef.current[digit - 1]!);
969
+ return;
970
+ }
971
+ }
972
+
973
+ if (key.ctrl && key.name === "c") {
974
+ if (isStreaming) {
975
+ void abort();
976
+ } else if (inputBoxRef.current && !inputBoxRef.current.isInputEmpty()) {
977
+ inputBoxRef.current.clearInput();
978
+ } else {
979
+ setShowExitModal(true);
980
+ }
981
+ return;
982
+ }
983
+
984
+ if (key.ctrl && key.name === "p") {
985
+ setShowCommandPanel(true);
986
+ return;
987
+ }
988
+
989
+ if (key.ctrl && key.name === "s") {
990
+ void (async () => { await refreshSessionList(); setShowSessionModal(true); })();
991
+ return;
992
+ }
993
+
994
+ if (key.ctrl && key.name === "f") {
995
+ setShowForkModal(true);
996
+ return;
997
+ }
998
+
999
+ if (key.name === "tab" && key.shift) {
1000
+ handleModeSwitch();
1001
+ return;
1002
+ }
1003
+
1004
+ if (key.ctrl && key.name === "o") {
1005
+ const hasExpanded = messages.some(
1006
+ (m) =>
1007
+ (m.type === "agent" && m.thinking && !m.thinkingCollapsed) ||
1008
+ (m.type === "tool_call" && !m.collapsed && isToolExpandable(m.toolName) && !isToolForceExpanded(m.toolName))
1009
+ );
1010
+ if (hasExpanded) {
1011
+ collapseAll();
1012
+ } else {
1013
+ expandAll();
1014
+ }
1015
+ return;
1016
+ }
1017
+
1018
+ if (key.name === "pageup") {
1019
+ chatPanelRef.current?.scrollUp?.();
1020
+ return;
1021
+ }
1022
+ if (key.name === "pagedown") {
1023
+ chatPanelRef.current?.scrollDown?.();
1024
+ return;
1025
+ }
1026
+ });
1027
+
1028
+ const handleSlashEmpty = useCallback(() => {
1029
+ setShowCommandPanel(true);
1030
+ }, []);
1031
+
1032
+ const handleSelectPrimary = useCallback(
1033
+ (model: { provider: string; modelId: string }) => {
1034
+ setCurrentModelState(model);
1035
+ setCurrentModel(model);
1036
+ setShowModelModal(false);
1037
+ if (session) {
1038
+ const piModel = resolvePiModel(model);
1039
+ if (piModel) session.setModel(piModel).catch(() => {});
1040
+ }
1041
+ },
1042
+ [session]
1043
+ );
1044
+
1045
+ const handleSelectAuxiliary = useCallback(
1046
+ (model: { provider: string; modelId: string }) => {
1047
+ setAuxiliaryModelState(model);
1048
+ setAuxiliaryModel(model);
1049
+ setShowModelModal(false);
1050
+ },
1051
+ []
1052
+ );
1053
+
1054
+ // Render: main layout + modal overlay layer.
1055
+ return (
1056
+ <box width={width} height={height} flexDirection="column">
1057
+ <box width={width} height={height} flexDirection="row">
1058
+ {/* Left column */}
1059
+ <box width={leftWidth} flexDirection="column">
1060
+ {error && (
1061
+ <box height={1}>
1062
+ <text fg="#ff5555">Error: {error}</text>
1063
+ </box>
1064
+ )}
1065
+ <ChatPanel ref={chatPanelRef} messages={visibleMessages} width={leftWidth} height={chatPanelHeight} isStreaming={isStreaming} onToggleCollapse={toggleCollapse} onImageClick={handleImageClick} />
1066
+ {pendingCount > 0 && (
1067
+ <PendingArea
1068
+ steering={steeringMessages}
1069
+ followUp={followUpMessages}
1070
+ width={leftWidth}
1071
+ onRemove={removePendingMessage}
1072
+ onEdit={handleEditPending}
1073
+ />
1074
+ )}
1075
+ <InputBox
1076
+ ref={inputBoxRef}
1077
+ onSubmit={handleSubmit}
1078
+ onQueueSubmit={handleQueueSubmit}
1079
+ onSlashEmpty={handleSlashEmpty}
1080
+ focused={!anyModalOpen}
1081
+ disabled={!isReady}
1082
+ width={leftWidth}
1083
+ mode={agentMode}
1084
+ isBusy={isStreaming}
1085
+ onModeSwitch={handleModeSwitch}
1086
+ />
1087
+ <InfoBar width={leftWidth} exitMode={showExitModal} yoloMode={yoloMode} onToggleYolo={() => setYoloMode((prev) => !prev)} />
1088
+ </box>
1089
+
1090
+ {/* Divider + Sidebar */}
1091
+ <box width={SIDEBAR_WIDTH + 2} flexDirection="row">
1092
+ <box width={1} height={height} border={["left"]} borderStyle="single" borderColor="gray" />
1093
+ <box width={1} />
1094
+ <SideBar
1095
+ width={SIDEBAR_WIDTH}
1096
+ workingDir={process.cwd()}
1097
+ sessionTitle={sessionTitle}
1098
+ modelName={modelInfo.modelName}
1099
+ provider={modelInfo.provider}
1100
+ contextUsage={sidebarContextUsage}
1101
+ tokenCount={sidebarTokenCount}
1102
+ cost={sidebarCost}
1103
+ tasks={tasks}
1104
+ subagents={subagents}
1105
+ monitors={monitors}
1106
+ />
1107
+ </box>
1108
+ </box>
1109
+
1110
+ {/* Modals */}
1111
+ {showExitModal && (
1112
+ <ExitModal
1113
+ isActive={showExitModal}
1114
+ onConfirm={() => { saveCurrentState(); onExit(); }}
1115
+ onCancel={() => setShowExitModal(false)}
1116
+ />
1117
+ )}
1118
+
1119
+ <CommandPanel isActive={showCommandPanel} onClose={() => setShowCommandPanel(false)} commands={commands} />
1120
+ <RenameModal isActive={showRenameModal} currentTitle={sessionTitle} onConfirm={handleRename} onCancel={() => setShowRenameModal(false)} />
1121
+ <ConnectModal isActive={showConnectModal} onClose={() => setShowConnectModal(false)} />
1122
+ <ModelModal isActive={showModelModal} onClose={() => setShowModelModal(false)} onSelectPrimary={handleSelectPrimary} onSelectAuxiliary={handleSelectAuxiliary} />
1123
+ <SessionModal
1124
+ isActive={showSessionModal}
1125
+ keyboardDisabled={showDeleteConfirm}
1126
+ onClose={() => setShowSessionModal(false)}
1127
+ sessions={sessionList}
1128
+ currentSessionId={currentSessionId}
1129
+ onSelect={handleSwitchSession}
1130
+ onNewSession={handleNewSession}
1131
+ onDelete={handleDeleteRequest}
1132
+ />
1133
+ {showDeleteConfirm && sessionToDelete && (
1134
+ <ConfirmModal
1135
+ isActive={showDeleteConfirm}
1136
+ title="Delete Session?"
1137
+ message={`Are you sure you want to delete "${sessionToDelete.title}"?`}
1138
+ confirmLabel="Delete"
1139
+ cancelLabel="Cancel"
1140
+ onConfirm={handleConfirmDelete}
1141
+ onCancel={handleCancelDelete}
1142
+ />
1143
+ )}
1144
+
1145
+ {/* MCP Connection Progress Modal */}
1146
+ <ConnectingModal
1147
+ isActive={isConnectingMcp}
1148
+ progress={mcpConnectionProgress}
1149
+ />
1150
+ <ForkModal
1151
+ isActive={showForkModal}
1152
+ onClose={() => setShowForkModal(false)}
1153
+ session={session}
1154
+ onFork={handleFork}
1155
+ />
1156
+ <ImagePreviewModal isActive={showImageModal} url={imageModalUrl} onClose={handleCloseImageModal} terminalWidth={width} terminalHeight={height} />
1157
+ <EditPendingModal
1158
+ isActive={showEditPendingModal}
1159
+ initialText={editPendingText}
1160
+ type={editPendingType ?? "sheer"}
1161
+ onConfirm={handleConfirmEditPending}
1162
+ onCancel={() => setShowEditPendingModal(false)}
1163
+ width={Math.min(70, leftWidth)}
1164
+ />
1165
+ <MCPSettings
1166
+ isActive={showMCPSettings}
1167
+ onClose={() => setShowMCPSettings(false)}
1168
+ onMcpChange={() => { void refreshMcpTools(session); }}
1169
+ />
1170
+ <SkillsMenu
1171
+ isActive={showSkillsModal}
1172
+ onClose={() => setShowSkillsModal(false)}
1173
+ skills={skills}
1174
+ onInvokeSkill={handleInvokeSkill}
1175
+ />
1176
+ </box>
1177
+ );
1178
+ }