@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.
- package/LICENSE +34 -0
- package/NOTICE +35 -0
- package/README.md +15 -0
- package/bin/koi +12 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/main.js +489918 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +51 -0
- package/src/agent/check-permissions.ts +239 -0
- package/src/agent/hooks/message-utils.ts +305 -0
- package/src/agent/hooks/types.ts +32 -0
- package/src/agent/hooks.ts +1560 -0
- package/src/agent/mode.ts +163 -0
- package/src/agent/monitor-registry.ts +308 -0
- package/src/agent/permission-ui.ts +71 -0
- package/src/agent/plan-ui.ts +74 -0
- package/src/agent/question-ui.ts +58 -0
- package/src/agent/session-fork.ts +299 -0
- package/src/agent/session-snapshots.ts +216 -0
- package/src/agent/session-store.ts +649 -0
- package/src/agent/session-tasks.ts +305 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +176 -0
- package/src/agent/subagent.ts +194 -0
- package/src/agent/tool-orchestration.ts +55 -0
- package/src/agent/tools.ts +8 -0
- package/src/cli/args.ts +6 -0
- package/src/cli/commands.ts +5 -0
- package/src/commands/skills/index.ts +23 -0
- package/src/config/models.ts +6 -0
- package/src/config/settings.ts +392 -0
- package/src/main.tsx +64 -0
- package/src/services/mcp/client.ts +194 -0
- package/src/services/mcp/config.ts +232 -0
- package/src/services/mcp/connection-manager.ts +258 -0
- package/src/services/mcp/index.ts +80 -0
- package/src/services/mcp/mcp-commands.ts +114 -0
- package/src/services/mcp/stdio-transport.ts +246 -0
- package/src/services/mcp/types.ts +155 -0
- package/src/skills/SkillsMenu.tsx +370 -0
- package/src/skills/bundled/batch.ts +106 -0
- package/src/skills/bundled/debug.ts +86 -0
- package/src/skills/bundled/loremIpsum.ts +101 -0
- package/src/skills/bundled/remember.ts +97 -0
- package/src/skills/bundled/simplify.ts +100 -0
- package/src/skills/bundled/skillify.ts +123 -0
- package/src/skills/bundled/stuck.ts +101 -0
- package/src/skills/bundled/updateConfig.ts +228 -0
- package/src/skills/bundled.ts +46 -0
- package/src/skills/frontmatter.ts +179 -0
- package/src/skills/index.ts +87 -0
- package/src/skills/invoke.ts +231 -0
- package/src/skills/loader.ts +710 -0
- package/src/skills/substitution.ts +169 -0
- package/src/skills/types.ts +201 -0
- package/src/tools/agent.ts +143 -0
- package/src/tools/ask-user-question.ts +46 -0
- package/src/tools/bash.ts +148 -0
- package/src/tools/edit.ts +164 -0
- package/src/tools/glob.ts +102 -0
- package/src/tools/grep.ts +248 -0
- package/src/tools/index.ts +73 -0
- package/src/tools/list-mcp-resources.ts +74 -0
- package/src/tools/ls.ts +85 -0
- package/src/tools/mcp.ts +76 -0
- package/src/tools/monitor.ts +159 -0
- package/src/tools/plan-mode.ts +134 -0
- package/src/tools/read-mcp-resource.ts +79 -0
- package/src/tools/read.ts +137 -0
- package/src/tools/skill.ts +176 -0
- package/src/tools/task.ts +349 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/webfetch-domains.ts +239 -0
- package/src/tools/webfetch.ts +533 -0
- package/src/tools/write.ts +101 -0
- package/src/tui/app.tsx +1178 -0
- package/src/tui/components/chat-panel.tsx +1071 -0
- package/src/tui/components/command-panel.tsx +261 -0
- package/src/tui/components/confirm-modal.tsx +135 -0
- package/src/tui/components/connect-modal.tsx +435 -0
- package/src/tui/components/connecting-modal.tsx +167 -0
- package/src/tui/components/edit-pending-modal.tsx +103 -0
- package/src/tui/components/exit-modal.tsx +131 -0
- package/src/tui/components/fork-modal.tsx +377 -0
- package/src/tui/components/image-preview-modal.tsx +141 -0
- package/src/tui/components/image-utils.ts +128 -0
- package/src/tui/components/info-bar.tsx +103 -0
- package/src/tui/components/input-box.tsx +352 -0
- package/src/tui/components/mcp/MCPSettings.tsx +386 -0
- package/src/tui/components/mcp/index.ts +7 -0
- package/src/tui/components/model-modal.tsx +310 -0
- package/src/tui/components/pending-area.tsx +88 -0
- package/src/tui/components/rename-modal.tsx +119 -0
- package/src/tui/components/session-modal.tsx +233 -0
- package/src/tui/components/side-bar.tsx +349 -0
- package/src/tui/components/tool-output.ts +6 -0
- package/src/tui/hooks/user-prompt-history.ts +114 -0
- package/src/tui/theme.ts +63 -0
- package/src/types/commands.ts +80 -0
- package/src/types/cross-spawn.d.ts +24 -0
package/src/tui/app.tsx
ADDED
|
@@ -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
|
+
}
|