@lwmxiaobei/xbcode 1.0.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 +21 -0
- package/README.md +631 -0
- package/README.zh-CN.md +542 -0
- package/dist/agent.js +1450 -0
- package/dist/busy-status.js +29 -0
- package/dist/clipboard-image.js +97 -0
- package/dist/commands.js +109 -0
- package/dist/compact.js +262 -0
- package/dist/config.js +516 -0
- package/dist/error-log.js +80 -0
- package/dist/http.js +89 -0
- package/dist/idle-watchdog.js +88 -0
- package/dist/index.js +2031 -0
- package/dist/input-submit.js +41 -0
- package/dist/mcp/client.js +466 -0
- package/dist/mcp/manager.js +275 -0
- package/dist/mcp/runtime.js +420 -0
- package/dist/mcp/types.js +12 -0
- package/dist/message-bus.js +180 -0
- package/dist/oauth/openai.js +326 -0
- package/dist/prompt.js +156 -0
- package/dist/session-store.js +186 -0
- package/dist/skills/frontmatter.js +85 -0
- package/dist/skills/index.js +2 -0
- package/dist/skills/loader.js +88 -0
- package/dist/skills/render.js +35 -0
- package/dist/skills/types.js +1 -0
- package/dist/subagents.js +64 -0
- package/dist/supervisor.js +58 -0
- package/dist/task-manager.js +280 -0
- package/dist/team-types.js +1 -0
- package/dist/teammate-manager.js +266 -0
- package/dist/tools.js +1068 -0
- package/dist/trust-store.js +42 -0
- package/dist/types.js +1 -0
- package/dist/usage.js +226 -0
- package/dist/utils.js +21 -0
- package/package.json +67 -0
- package/scripts/postinstall.mjs +30 -0
- package/skills/code-review/SKILL.md +22 -0
- package/skills/pdf/SKILL.md +18 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2031 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { config as loadDotenv } from "dotenv";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { Box, Newline, Static, Text, render, useApp, useInput } from "ink";
|
|
9
|
+
import TextInput from "ink-text-input";
|
|
10
|
+
import { Marked } from "marked";
|
|
11
|
+
import { markedTerminal } from "marked-terminal";
|
|
12
|
+
import OpenAI from "openai";
|
|
13
|
+
import { useEffect, useRef, useState } from "react";
|
|
14
|
+
import wrapAnsi from "wrap-ansi";
|
|
15
|
+
import pkg from "../package.json" with { type: "json" };
|
|
16
|
+
import { isTurnInterruptedError, runAgentTurn } from "./agent.js";
|
|
17
|
+
import { extractImagePathsFromText, importClipboardImageMacos } from "./clipboard-image.js";
|
|
18
|
+
// P1:删除 supervisor.ts(processLeadInboxEvents 依赖的 eventType/taskId 协议字段已移除)。
|
|
19
|
+
// P3 协议消息阶段会用独立机制重做事件处理,不再混在 lead 邮箱消费流程里。
|
|
20
|
+
import { clearProviderCredentials, getCredentialsPath, getProviderModels, getProviderNames, getSettingsPath, loadCredentialsFile, loadSettings, needsModelSelection, normalizeModelEntry, reloadSettings, resolveConfig, resolveProviderAuthState, resolveRuntimeAuth, writeCredentialsFile, updateProviderModels, } from "./config.js";
|
|
21
|
+
import { estimateTokens, autoCompact, autoCompactResponseHistory } from "./compact.js";
|
|
22
|
+
import { createSharedFetch } from "./http.js";
|
|
23
|
+
import { normalizeCommand, parseStartupCommand, submissionNeedsSelectedModel } from "./commands.js";
|
|
24
|
+
import { createSubmitDeduper, getSubmittedValueFromInput } from "./input-submit.js";
|
|
25
|
+
import { formatTeammateMessages } from "./message-bus.js";
|
|
26
|
+
import { ensureMcpInitialized, getMcpPromptInstructions, mcpManager, primeMcpRuntime, refreshMcpFromSettings } from "./mcp/runtime.js";
|
|
27
|
+
import { OPENAI_CODEX_BASE_URL, createOpenAIOAuthFetch, getOpenAIOAuthDefaultHeaders, listAvailableModels, refreshAccessToken, startOpenAILogin, } from "./oauth/openai.js";
|
|
28
|
+
import { buildSystemPrompt } from "./prompt.js";
|
|
29
|
+
import { appendSessionCheckpoint, createSessionId, listRecentSessions, loadSession } from "./session-store.js";
|
|
30
|
+
import { isTrusted, markTrusted } from "./trust-store.js";
|
|
31
|
+
import { skillLoader, messageBus, taskManager, teammateManager } from "./tools.js";
|
|
32
|
+
import { formatBusyStatus } from "./busy-status.js";
|
|
33
|
+
import { fetchOpenAIUsage, formatUsageReport, UsageRequestError } from "./usage.js";
|
|
34
|
+
import { ellipsize } from "./utils.js";
|
|
35
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, "..");
|
|
37
|
+
loadDotenv({ path: path.join(PROJECT_ROOT, ".env"), override: true });
|
|
38
|
+
const WORKDIR = process.cwd();
|
|
39
|
+
function createAgentConfig(resolved, authState) {
|
|
40
|
+
const bearerToken = authState?.bearerToken || resolved.apiKey || undefined;
|
|
41
|
+
const isOpenAIOAuth = resolved.providerName === "openai" && authState?.authMode === "oauth" && authState.oauth;
|
|
42
|
+
/**
|
|
43
|
+
* OpenAI OAuth tokens issued by the Codex login flow do not carry the public
|
|
44
|
+
* API scopes required by `api.openai.com/v1/responses`. They are instead meant
|
|
45
|
+
* for ChatGPT's internal Codex backend, so OAuth sessions must use that base
|
|
46
|
+
* URL and header set while API-key sessions keep using the public OpenAI API.
|
|
47
|
+
*/
|
|
48
|
+
const clientOptions = isOpenAIOAuth
|
|
49
|
+
? {
|
|
50
|
+
apiKey: bearerToken,
|
|
51
|
+
baseURL: OPENAI_CODEX_BASE_URL,
|
|
52
|
+
defaultHeaders: getOpenAIOAuthDefaultHeaders(authState.oauth),
|
|
53
|
+
fetch: createOpenAIOAuthFetch(),
|
|
54
|
+
}
|
|
55
|
+
: {
|
|
56
|
+
apiKey: bearerToken,
|
|
57
|
+
baseURL: resolved.baseURL !== "https://api.openai.com/v1" ? resolved.baseURL : undefined,
|
|
58
|
+
// 共享 dispatcher 把闲置 keep-alive 连接保留时间压到 1s,避免在多轮工具
|
|
59
|
+
// 执行的间隔后下一轮 stream 拿到一条已被远端关闭的连接、立刻报 `terminated`。
|
|
60
|
+
fetch: createSharedFetch(),
|
|
61
|
+
};
|
|
62
|
+
const client = new OpenAI(clientOptions);
|
|
63
|
+
return {
|
|
64
|
+
client,
|
|
65
|
+
model: resolved.model,
|
|
66
|
+
system: buildSystemPrompt({
|
|
67
|
+
workdir: WORKDIR,
|
|
68
|
+
skillDescriptions: skillLoader.getDescriptions(),
|
|
69
|
+
mcpInstructions: getMcpPromptInstructions(),
|
|
70
|
+
}),
|
|
71
|
+
showThinking: resolved.showThinking,
|
|
72
|
+
apiMode: resolved.apiMode,
|
|
73
|
+
// ChatGPT Codex backend rejects `previous_response_id`, so only this
|
|
74
|
+
// OAuth-backed branch needs stateless replay. All other providers keep the
|
|
75
|
+
// original server-side Responses chaining behavior.
|
|
76
|
+
supportsPreviousResponseId: !isOpenAIOAuth,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// Initialized lazily after user selects model (or immediately if MODEL_ID env var is set)
|
|
80
|
+
let currentResolved = undefined;
|
|
81
|
+
let agentConfig = undefined;
|
|
82
|
+
let currentAuthState;
|
|
83
|
+
function ensureConfig(providerName, modelName) {
|
|
84
|
+
currentResolved = resolveConfig(providerName, modelName);
|
|
85
|
+
currentAuthState = resolveProviderAuthState(loadSettings(), currentResolved.providerName, loadCredentialsFile(getCredentialsPath()));
|
|
86
|
+
agentConfig = createAgentConfig(currentResolved, currentAuthState);
|
|
87
|
+
primeMcpRuntime();
|
|
88
|
+
}
|
|
89
|
+
async function refreshCurrentAuth() {
|
|
90
|
+
if (!currentResolved) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const settings = loadSettings();
|
|
94
|
+
const result = await resolveRuntimeAuth({
|
|
95
|
+
settings,
|
|
96
|
+
providerName: currentResolved.providerName,
|
|
97
|
+
credentials: loadCredentialsFile(getCredentialsPath()),
|
|
98
|
+
refreshOAuthToken: async (credentials) => await refreshAccessToken({
|
|
99
|
+
clientId: credentials.client_id,
|
|
100
|
+
refreshToken: credentials.refresh_token ?? "",
|
|
101
|
+
}),
|
|
102
|
+
onCredentialsUpdated: async (credentials) => {
|
|
103
|
+
await writeCredentialsFile(getCredentialsPath(), credentials);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
currentAuthState = result.state;
|
|
107
|
+
agentConfig = createAgentConfig(currentResolved, currentAuthState);
|
|
108
|
+
}
|
|
109
|
+
// Initialize immediately when startup can resolve a usable model without prompting.
|
|
110
|
+
if (!needsModelSelection()) {
|
|
111
|
+
ensureConfig();
|
|
112
|
+
}
|
|
113
|
+
/** Build a flat list of model choices from all providers */
|
|
114
|
+
function buildModelChoices() {
|
|
115
|
+
const settings = loadSettings();
|
|
116
|
+
const choices = [];
|
|
117
|
+
for (const [providerName, profile] of Object.entries(settings.providers)) {
|
|
118
|
+
for (const entry of profile.models) {
|
|
119
|
+
const normalized = normalizeModelEntry(entry);
|
|
120
|
+
choices.push({
|
|
121
|
+
provider: providerName,
|
|
122
|
+
modelId: normalized.id,
|
|
123
|
+
displayName: normalized.name || normalized.id,
|
|
124
|
+
description: normalized.description || "",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return choices;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Estimate the active conversation footprint for the current API mode.
|
|
132
|
+
*
|
|
133
|
+
* Why this exists:
|
|
134
|
+
* - `chat-completions` keeps its full transcript in `chatHistory`, while the
|
|
135
|
+
* Codex OAuth fallback now keeps replayable Responses items in
|
|
136
|
+
* `responseHistory`.
|
|
137
|
+
* - Reusing one helper keeps status and debug output honest without changing
|
|
138
|
+
* how other providers account for context.
|
|
139
|
+
* - The estimate remains intentionally rough; it only needs to show whether
|
|
140
|
+
* context is growing, not provide billing-grade numbers.
|
|
141
|
+
*/
|
|
142
|
+
function estimateActiveContextTokens(state) {
|
|
143
|
+
if (currentResolved?.apiMode === "responses") {
|
|
144
|
+
return estimateTokens(state.responseHistory);
|
|
145
|
+
}
|
|
146
|
+
return estimateTokens(state.chatHistory);
|
|
147
|
+
}
|
|
148
|
+
const MAX_VISIBLE_SLASH_MATCHES = 8;
|
|
149
|
+
const SLASH_COMMANDS = [
|
|
150
|
+
{ command: "/help", description: "show available commands" },
|
|
151
|
+
{ command: "/status", description: "show session status" },
|
|
152
|
+
{ command: "/usage", description: "show codex subscription usage (5h / weekly limits)" },
|
|
153
|
+
{ command: "/login", description: "start oauth login for the current provider" },
|
|
154
|
+
{ command: "/logout", description: "clear oauth credentials for the current provider" },
|
|
155
|
+
{ command: "/mcp", description: "show MCP server status" },
|
|
156
|
+
{ command: "/mcp refresh", description: "refresh MCP caches and reconnect servers" },
|
|
157
|
+
{ command: "/team", description: "show teammate statuses" },
|
|
158
|
+
{ command: "/tasks", description: "show task board" },
|
|
159
|
+
{ command: "/task", description: "show task details by id" },
|
|
160
|
+
{ command: "/inbox", description: "drain lead inbox" },
|
|
161
|
+
{ command: "/provider", description: "switch provider" },
|
|
162
|
+
{ command: "/model", description: "switch model within current provider" },
|
|
163
|
+
{ command: "/compact", description: "compact conversation history to free context space" },
|
|
164
|
+
{ command: "/new", description: "clear context and start a new conversation" },
|
|
165
|
+
{ command: "/resume", description: "list recent saved sessions or restore one by id" },
|
|
166
|
+
{ command: "/exit", description: "exit the CLI" },
|
|
167
|
+
];
|
|
168
|
+
function getSkillSlashCommands() {
|
|
169
|
+
const limit = 30;
|
|
170
|
+
return skillLoader.getPromptCommands().map((command) => ({
|
|
171
|
+
command: `/${command.name}`,
|
|
172
|
+
description: command.description.length > limit
|
|
173
|
+
? `${command.description.slice(0, limit)}...`
|
|
174
|
+
: command.description,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
function getAllSlashCommands() {
|
|
178
|
+
const commands = new Map();
|
|
179
|
+
for (const command of SLASH_COMMANDS) {
|
|
180
|
+
commands.set(command.command, command);
|
|
181
|
+
}
|
|
182
|
+
for (const command of getSkillSlashCommands()) {
|
|
183
|
+
if (!commands.has(command.command)) {
|
|
184
|
+
commands.set(command.command, command);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return [...commands.values()].sort((left, right) => left.command.localeCompare(right.command));
|
|
188
|
+
}
|
|
189
|
+
function formatDuration(ms) {
|
|
190
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
191
|
+
const minutes = Math.floor(seconds / 60);
|
|
192
|
+
const remainingSeconds = seconds % 60;
|
|
193
|
+
if (minutes === 0) {
|
|
194
|
+
return `${remainingSeconds}s`;
|
|
195
|
+
}
|
|
196
|
+
return `${minutes}m${remainingSeconds}s`;
|
|
197
|
+
}
|
|
198
|
+
function stripAnsi(text) {
|
|
199
|
+
return text.replaceAll(/\u001b\[[0-9;]*m/g, "");
|
|
200
|
+
}
|
|
201
|
+
function toolPreview(value, maxLength = 400) {
|
|
202
|
+
return ellipsize(value.replaceAll(/\s+/g, " ").trim(), maxLength);
|
|
203
|
+
}
|
|
204
|
+
function formatNum(n) {
|
|
205
|
+
if (n >= 1_000_000)
|
|
206
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
207
|
+
if (n >= 1_000)
|
|
208
|
+
return `${(n / 1_000).toFixed(1)}K`;
|
|
209
|
+
return String(n);
|
|
210
|
+
}
|
|
211
|
+
function formatEditFileDiff(args) {
|
|
212
|
+
const filePath = String(args.path ?? "");
|
|
213
|
+
const oldText = String(args.old_text ?? "");
|
|
214
|
+
const newText = String(args.new_text ?? "");
|
|
215
|
+
const oldLines = oldText.split("\n");
|
|
216
|
+
const newLines = newText.split("\n");
|
|
217
|
+
const added = newLines.length;
|
|
218
|
+
const removed = oldLines.length;
|
|
219
|
+
const parts = [];
|
|
220
|
+
if (added > 0)
|
|
221
|
+
parts.push(`Added ${added} line${added > 1 ? "s" : ""}`);
|
|
222
|
+
if (removed > 0)
|
|
223
|
+
parts.push(`removed ${removed} line${removed > 1 ? "s" : ""}`);
|
|
224
|
+
const subtitle = parts.length > 0 ? parts.join(", ") : undefined;
|
|
225
|
+
const CONTEXT = 3;
|
|
226
|
+
const lines = [];
|
|
227
|
+
// Try to read the file to get line numbers and context
|
|
228
|
+
let fileLines = null;
|
|
229
|
+
let matchLine = -1;
|
|
230
|
+
try {
|
|
231
|
+
const fullPath = path.resolve(WORKDIR, filePath);
|
|
232
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
233
|
+
fileLines = content.split("\n");
|
|
234
|
+
// Find where new_text starts in the edited file
|
|
235
|
+
const idx = content.indexOf(newText);
|
|
236
|
+
if (idx >= 0) {
|
|
237
|
+
matchLine = content.slice(0, idx).split("\n").length - 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// File might not exist or be readable; fall back to no-context view
|
|
242
|
+
}
|
|
243
|
+
if (fileLines && matchLine >= 0) {
|
|
244
|
+
// Show context before
|
|
245
|
+
const contextStart = Math.max(0, matchLine - CONTEXT);
|
|
246
|
+
for (let i = contextStart; i < matchLine; i++) {
|
|
247
|
+
lines.push({ text: ` ${String(i + 1).padStart(4)} ${fileLines[i]}`, color: "gray" });
|
|
248
|
+
}
|
|
249
|
+
// Show removed lines
|
|
250
|
+
for (let i = 0; i < oldLines.length; i++) {
|
|
251
|
+
const lineNum = matchLine + i;
|
|
252
|
+
lines.push({ text: `- ${String(lineNum + 1).padStart(4)} ${oldLines[i]}`, color: "red" });
|
|
253
|
+
}
|
|
254
|
+
// Show added lines
|
|
255
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
256
|
+
const lineNum = matchLine + i;
|
|
257
|
+
lines.push({ text: `+ ${String(lineNum + 1).padStart(4)} ${newLines[i]}`, color: "green" });
|
|
258
|
+
}
|
|
259
|
+
// Show context after
|
|
260
|
+
const afterStart = matchLine + newLines.length;
|
|
261
|
+
const afterEnd = Math.min(fileLines.length, afterStart + CONTEXT);
|
|
262
|
+
for (let i = afterStart; i < afterEnd; i++) {
|
|
263
|
+
lines.push({ text: ` ${String(i + 1).padStart(4)} ${fileLines[i]}`, color: "gray" });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Fallback: no context
|
|
268
|
+
for (const line of oldLines) {
|
|
269
|
+
lines.push({ text: `- ${line}`, color: "red" });
|
|
270
|
+
}
|
|
271
|
+
for (const line of newLines) {
|
|
272
|
+
lines.push({ text: `+ ${line}`, color: "green" });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { title: `Update(${filePath})`, subtitle, lines };
|
|
276
|
+
}
|
|
277
|
+
function formatWriteFile(args, result) {
|
|
278
|
+
const filePath = String(args.path ?? "");
|
|
279
|
+
const content = String(args.content ?? "");
|
|
280
|
+
const contentLines = content.split("\n");
|
|
281
|
+
const totalLines = contentLines.length;
|
|
282
|
+
const previewCount = Math.min(10, totalLines);
|
|
283
|
+
const lines = [];
|
|
284
|
+
for (let i = 0; i < previewCount; i++) {
|
|
285
|
+
lines.push({ text: ` ${String(i + 1).padStart(4)} ${contentLines[i]}`, color: "green" });
|
|
286
|
+
}
|
|
287
|
+
if (totalLines > previewCount) {
|
|
288
|
+
lines.push({ text: ` ... (${totalLines - previewCount} more lines)`, color: "gray" });
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
title: `Write(${filePath})`,
|
|
292
|
+
subtitle: result,
|
|
293
|
+
lines,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function formatBash(args, result) {
|
|
297
|
+
const command = String(args.command ?? "");
|
|
298
|
+
const lines = [];
|
|
299
|
+
const resultLines = result.split("\n");
|
|
300
|
+
const previewCount = Math.min(15, resultLines.length);
|
|
301
|
+
for (let i = 0; i < previewCount; i++) {
|
|
302
|
+
lines.push({ text: ` ${resultLines[i]}`, color: "white" });
|
|
303
|
+
}
|
|
304
|
+
if (resultLines.length > previewCount) {
|
|
305
|
+
lines.push({ text: ` ... (${resultLines.length - previewCount} more lines)`, color: "gray" });
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
title: `Bash(${ellipsize(command, 60)})`,
|
|
309
|
+
lines,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function formatToolDisplay(name, args, result) {
|
|
313
|
+
// A rejected call did not run, so skip the rich diff/command view (which would
|
|
314
|
+
// otherwise look as if it had been applied) and fall back to plain text.
|
|
315
|
+
if (result.startsWith("Rejected by user:")) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
switch (name) {
|
|
319
|
+
case "edit_file":
|
|
320
|
+
return result.startsWith("Error") ? null : formatEditFileDiff(args);
|
|
321
|
+
case "write_file":
|
|
322
|
+
return result.startsWith("Error") ? null : formatWriteFile(args, result);
|
|
323
|
+
case "bash":
|
|
324
|
+
return formatBash(args, result);
|
|
325
|
+
default:
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// One-line-per-entry summary of what a tool call will do, shown in the approval
|
|
330
|
+
// prompt so the user can decide without inspecting raw JSON args.
|
|
331
|
+
function summarizeApprovalTarget(name, args) {
|
|
332
|
+
switch (name) {
|
|
333
|
+
case "bash":
|
|
334
|
+
return String(args.command ?? "").split("\n").slice(0, 8);
|
|
335
|
+
case "write_file":
|
|
336
|
+
return [`write → ${String(args.path ?? "")}`];
|
|
337
|
+
case "edit_file":
|
|
338
|
+
return [`edit → ${String(args.path ?? "")}`];
|
|
339
|
+
default:
|
|
340
|
+
return [toolPreview(JSON.stringify(args))];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function ApprovalPrompt({ name, lines, selectedIndex, width }) {
|
|
344
|
+
const options = [
|
|
345
|
+
"Yes, run once",
|
|
346
|
+
`Yes, and always allow ${name} this session`,
|
|
347
|
+
"No, reject this call (Esc)",
|
|
348
|
+
];
|
|
349
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", wrap: "truncate", children: borderRule(width) }), _jsxs(Text, { bold: true, color: "yellow", children: ["Approve tool call: ", name, "?"] }), lines.map((line, index) => (_jsxs(Text, { color: "gray", wrap: "truncate", children: [" ", line || " "] }, index))), _jsx(Newline, {}), options.map((label, index) => (_jsxs(Text, { color: selectedIndex === index ? "cyan" : "white", children: [selectedIndex === index ? "›" : " ", " ", index + 1, ". ", label] }, label))), _jsx(Text, { color: "gray", children: "\u2191/\u2193 to choose, Enter to confirm, Esc to reject" }), _jsx(Text, { color: "yellow", wrap: "truncate", children: borderRule(width) })] }));
|
|
350
|
+
}
|
|
351
|
+
// Interactive menu for `ask_user_question`. Mirrors ApprovalPrompt but renders
|
|
352
|
+
// one question at a time, supporting single-choice and multi-select questions.
|
|
353
|
+
function QuestionPrompt({ questions, questionIndex, cursor, selections, width }) {
|
|
354
|
+
const question = questions[questionIndex];
|
|
355
|
+
if (!question) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const multi = Boolean(question.multiSelect);
|
|
359
|
+
const selected = selections[questionIndex] ?? [];
|
|
360
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "cyan", wrap: "truncate", children: borderRule(width) }), _jsxs(Text, { bold: true, color: "cyan", children: [questions.length > 1 ? `[${questionIndex + 1}/${questions.length}] ` : "", question.header ? `${question.header}: ` : "", question.question] }), _jsx(Newline, {}), question.options.map((option, index) => {
|
|
361
|
+
const isCursor = cursor === index;
|
|
362
|
+
const isChecked = selected.includes(option.label);
|
|
363
|
+
const marker = multi ? (isChecked ? "[x]" : "[ ]") : isCursor ? "›" : " ";
|
|
364
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: isCursor ? "cyan" : "white", children: [multi ? (isCursor ? "› " : " ") : "", marker, " ", index + 1, ". ", option.label] }), option.description ? (_jsxs(Text, { color: "gray", wrap: "truncate", children: [" ", option.description] })) : null] }, `${option.label}-${index}`));
|
|
365
|
+
}), _jsx(Text, { color: "gray", children: multi
|
|
366
|
+
? "↑/↓ to move, Space to toggle, Enter to confirm, Esc to skip"
|
|
367
|
+
: "↑/↓ to move, 1-9 or Enter to select, Esc to skip" }), _jsx(Text, { color: "cyan", wrap: "truncate", children: borderRule(width) })] }));
|
|
368
|
+
}
|
|
369
|
+
const marked = new Marked({ async: false }, markedTerminal({ reflowText: true, showSectionPrefix: false }));
|
|
370
|
+
function renderMarkdown(text) {
|
|
371
|
+
let rendered = marked.parse(text);
|
|
372
|
+
// marked-terminal doesn't handle inline bold/italic inside list items — fix up leftovers
|
|
373
|
+
rendered = rendered.replaceAll(/\*\*(.+?)\*\*/g, "\x1b[1m$1\x1b[22m"); // bold
|
|
374
|
+
rendered = rendered.replaceAll(/\*(.+?)\*/g, "\x1b[3m$1\x1b[23m"); // italic
|
|
375
|
+
return rendered.trimEnd();
|
|
376
|
+
}
|
|
377
|
+
function wrapTextToRows(text, width) {
|
|
378
|
+
const safeWidth = Math.max(1, width);
|
|
379
|
+
const logicalLines = text.split("\n");
|
|
380
|
+
const rows = [];
|
|
381
|
+
for (const logicalLine of logicalLines) {
|
|
382
|
+
const wrapped = wrapAnsi(logicalLine.length > 0 ? logicalLine : " ", safeWidth, {
|
|
383
|
+
hard: true,
|
|
384
|
+
trim: false,
|
|
385
|
+
wordWrap: false,
|
|
386
|
+
}).split("\n");
|
|
387
|
+
rows.push(...(wrapped.length > 0 ? wrapped : [" "]));
|
|
388
|
+
}
|
|
389
|
+
return rows.length > 0 ? rows : [" "];
|
|
390
|
+
}
|
|
391
|
+
function getMessageMarker(kind) {
|
|
392
|
+
switch (kind) {
|
|
393
|
+
case "user":
|
|
394
|
+
return { symbol: "›", color: "cyan" };
|
|
395
|
+
case "assistant":
|
|
396
|
+
return { symbol: "●", color: "white" };
|
|
397
|
+
default:
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function borderRule(width) {
|
|
402
|
+
return "─".repeat(Math.max(1, width));
|
|
403
|
+
}
|
|
404
|
+
function trimToLine(text) {
|
|
405
|
+
return text.replaceAll(/\s+/g, " ").trim();
|
|
406
|
+
}
|
|
407
|
+
function configMessage() {
|
|
408
|
+
return {
|
|
409
|
+
id: "config",
|
|
410
|
+
kind: "system",
|
|
411
|
+
title: "config",
|
|
412
|
+
text: `provider=${currentResolved.providerName} model=${currentResolved.model} configuredBaseURL=${currentResolved.baseURL} effectiveBaseURL=${getEffectiveBaseURL(currentResolved, currentAuthState)} apiMode=${currentResolved.apiMode}`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function formatAuthSummary(state) {
|
|
416
|
+
if (!state) {
|
|
417
|
+
return "auth none";
|
|
418
|
+
}
|
|
419
|
+
if (state.authMode === "oauth") {
|
|
420
|
+
const email = state.oauth?.email?.trim();
|
|
421
|
+
if (email) {
|
|
422
|
+
return `auth oauth(${email})`;
|
|
423
|
+
}
|
|
424
|
+
return "auth oauth";
|
|
425
|
+
}
|
|
426
|
+
if (state.authMode === "apiKey") {
|
|
427
|
+
if (state.oauth?.refresh_token?.trim()) {
|
|
428
|
+
return "auth apiKey (oauth fallback available)";
|
|
429
|
+
}
|
|
430
|
+
return "auth apiKey";
|
|
431
|
+
}
|
|
432
|
+
return "auth none";
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* 返回当前会话真正用于请求模型的 endpoint。
|
|
436
|
+
*
|
|
437
|
+
* 为什么不能只看配置里的 `baseURL`:
|
|
438
|
+
* - `ResolvedConfig.baseURL` 反映的是 provider 静态配置,便于用户理解和持久化,
|
|
439
|
+
* 但不代表运行时一定按它发请求。
|
|
440
|
+
* - OpenAI OAuth 是一个特殊分支:配置仍然指向公开 API,实际请求却会改走
|
|
441
|
+
* ChatGPT Codex backend。
|
|
442
|
+
* - 统一用这个 helper 计算“生效中的 endpoint”,可以让 `/status`、调试信息
|
|
443
|
+
* 和底部状态栏保持一致,避免排障时看到互相矛盾的地址。
|
|
444
|
+
*/
|
|
445
|
+
function getEffectiveBaseURL(resolved, authState) {
|
|
446
|
+
const usesOpenAICodexBackend = resolved.providerName === "openai"
|
|
447
|
+
&& authState?.authMode === "oauth"
|
|
448
|
+
&& Boolean(authState.oauth);
|
|
449
|
+
return usesOpenAICodexBackend ? OPENAI_CODEX_BASE_URL : resolved.baseURL;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Show credential problems directly in the welcome panel because a configured
|
|
453
|
+
* `defaultModel` can now bypass the selection screen entirely.
|
|
454
|
+
*
|
|
455
|
+
* Why this exists:
|
|
456
|
+
* - Users should see the problem before the first request fails.
|
|
457
|
+
* - OAuth-backed providers need a login reminder, while API-key providers need
|
|
458
|
+
* a config reminder. The fixes are different, so the warning should be too.
|
|
459
|
+
*/
|
|
460
|
+
function getProviderStartupAuthWarning() {
|
|
461
|
+
if (!currentResolved || !currentAuthState || currentAuthState.authMode !== "none") {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
const settings = loadSettings();
|
|
465
|
+
const provider = settings.providers[currentResolved.providerName];
|
|
466
|
+
if (!provider) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
return provider.auth?.type === "oauth" ? "未登录" : "未配置API";
|
|
470
|
+
}
|
|
471
|
+
function headerItem() {
|
|
472
|
+
return {
|
|
473
|
+
id: "header",
|
|
474
|
+
kind: "system",
|
|
475
|
+
text: "",
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function getMessagePalette(kind) {
|
|
479
|
+
switch (kind) {
|
|
480
|
+
case "tool":
|
|
481
|
+
return { titleColor: "yellow", bodyColor: "green" };
|
|
482
|
+
case "error":
|
|
483
|
+
return { titleColor: "red", bodyColor: "red" };
|
|
484
|
+
case "thinking":
|
|
485
|
+
return { titleColor: "blue", bodyColor: "blue" };
|
|
486
|
+
case "system":
|
|
487
|
+
return { titleColor: "gray", bodyColor: "white" };
|
|
488
|
+
case "user":
|
|
489
|
+
return { titleColor: "cyan", bodyColor: "cyan" };
|
|
490
|
+
default:
|
|
491
|
+
return { titleColor: "white", bodyColor: "white" };
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function WelcomePanel({ width, messages }) {
|
|
495
|
+
const contentWidth = Math.max(20, width - 4);
|
|
496
|
+
const authWarning = getProviderStartupAuthWarning();
|
|
497
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: "red", flexDirection: "column", marginBottom: 1, paddingX: 1, paddingY: 0, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "red", children: "xbcode" }), _jsxs(Text, { color: "gray", children: ["v", pkg.version] })] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Box, { width: "36%", flexDirection: "column", paddingRight: 2, children: [_jsxs(Text, { bold: true, children: ["Welcome back", currentResolved ? ` · ${currentResolved.providerName}` : ""] }), _jsx(Text, { color: "gray", children: ellipsize(WORKDIR, contentWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { color: "red", children: " \u259F\u2588\u2588\u2599" }), _jsx(Text, { color: "red", children: " \u259F\u2588\u2588\u2588\u2588\u2599" }), _jsx(Text, { color: "red", children: " \u259C\u2588\u2588\u259B" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: currentResolved ? `${currentResolved.model} · ${currentResolved.apiMode}` : "No model selected" })] }), _jsx(Box, { width: 1, children: _jsx(Text, { color: "red", children: "\u2502" }) }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingLeft: 2, children: [_jsx(Text, { bold: true, color: "red", children: "Tips for getting started" }), _jsxs(Text, { children: ["Run ", _jsx(Text, { color: "whiteBright", children: "/help" }), " to see commands, ", _jsx(Text, { color: "whiteBright", children: "/model" }), " to switch models."] }), _jsxs(Text, { children: ["Use ", _jsx(Text, { color: "whiteBright", children: "/new" }), " to reset, or ", _jsx(Text, { color: "whiteBright", children: "/resume" }), " to reopen a saved session."] }), authWarning
|
|
498
|
+
? _jsxs(Text, { color: "red", children: ["\u5F53\u524D provider ", authWarning, "\u3002\u8BF7\u5148\u914D\u7F6E API\uFF0C\u6216\u8005\u5B8C\u6210 /login\u3002"] })
|
|
499
|
+
: null] })] })] }));
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Footer line shown while busy. Reads heartbeat refs that the agent loop updates
|
|
503
|
+
* through `bridge.noteStreamActivity()`, and surfaces both elapsed time and
|
|
504
|
+
* (when relevant) the idle gap since the last stream event.
|
|
505
|
+
*
|
|
506
|
+
* `busyTick` is just a re-render trigger — the actual timing comes from refs so
|
|
507
|
+
* we don't churn React state once per second for every byte the model streams.
|
|
508
|
+
*/
|
|
509
|
+
function BusyStatusLine({ busyTick, turnStartedAtRef, lastActivityAtRef, }) {
|
|
510
|
+
// referenced so React links the prop to the render; value itself isn't used
|
|
511
|
+
void busyTick;
|
|
512
|
+
const now = Date.now();
|
|
513
|
+
const startedAt = turnStartedAtRef.current ?? now;
|
|
514
|
+
const lastActivityAt = lastActivityAtRef.current ?? startedAt;
|
|
515
|
+
const elapsedSeconds = (now - startedAt) / 1000;
|
|
516
|
+
const idleSeconds = (now - lastActivityAt) / 1000;
|
|
517
|
+
// 把"是否颜色提示卡顿"的阈值放在 UI 这一层而不是 formatter 里:
|
|
518
|
+
// formatter 决定文本,UI 决定外观,两者职责分开更易调。
|
|
519
|
+
const color = idleSeconds >= 15 ? "yellow" : "gray";
|
|
520
|
+
return _jsx(Text, { color: color, children: formatBusyStatus(elapsedSeconds, idleSeconds) });
|
|
521
|
+
}
|
|
522
|
+
function StatusBar({ width, busy, state, tokenUsage }) {
|
|
523
|
+
const left = currentResolved
|
|
524
|
+
? `[${currentResolved.providerName}] ${currentResolved.model}`
|
|
525
|
+
: "[no-model]";
|
|
526
|
+
const mid = `${state.turnCount} turns`;
|
|
527
|
+
const totalTokens = tokenUsage.inputTokens + tokenUsage.outputTokens;
|
|
528
|
+
const right = busy
|
|
529
|
+
? "working · Esc to stop"
|
|
530
|
+
: (totalTokens > 0
|
|
531
|
+
? `${formatNum(tokenUsage.inputTokens)}→ ${formatNum(tokenUsage.outputTokens)}↗ ${tokenUsage.cost.toFixed(4)}`
|
|
532
|
+
: `~${estimateTokens(state.chatHistory)} token`);
|
|
533
|
+
const text = `${left} | ${mid} | ${right}`;
|
|
534
|
+
return (_jsx(Text, { color: "gray", wrap: "truncate", children: ellipsize(text, width) }));
|
|
535
|
+
}
|
|
536
|
+
function helpMessage() {
|
|
537
|
+
const providers = getProviderNames();
|
|
538
|
+
const providerList = providers.length > 0 ? providers.join(", ") : "(none configured)";
|
|
539
|
+
const skillCommands = skillLoader.getPromptCommands();
|
|
540
|
+
const skillsLine = skillCommands.length > 0
|
|
541
|
+
? `skills /${skillCommands.slice(0, 8).map((command) => command.name).join(", /")}${skillCommands.length > 8 ? ", ..." : ""}`
|
|
542
|
+
: "skills (none available)";
|
|
543
|
+
return [
|
|
544
|
+
"help show available commands",
|
|
545
|
+
"status show session status",
|
|
546
|
+
"usage show codex subscription usage (5h / weekly limits)",
|
|
547
|
+
"mcp [refresh [name]] show MCP status or refresh all / one server",
|
|
548
|
+
"team show teammate statuses",
|
|
549
|
+
"inbox drain lead inbox",
|
|
550
|
+
"provider [name] switch provider (no arg = list providers)",
|
|
551
|
+
"model [name] switch model within current provider",
|
|
552
|
+
"compact compact conversation history to free context space",
|
|
553
|
+
"new clear context and start a new conversation",
|
|
554
|
+
"resume [sessionId] list recent sessions or restore one",
|
|
555
|
+
"exit exit the CLI",
|
|
556
|
+
skillsLine,
|
|
557
|
+
"",
|
|
558
|
+
`config ${getSettingsPath()}`,
|
|
559
|
+
`providers ${providerList}`,
|
|
560
|
+
"",
|
|
561
|
+
"Press Esc while working to stop the current turn without clearing session context.",
|
|
562
|
+
"Use Cmd+V (or Ctrl+V) to import a clipboard image as an attachment on macOS.",
|
|
563
|
+
"Slash variants also work, for example /help and /exit.",
|
|
564
|
+
"Dragging an image file into the terminal attaches it automatically.",
|
|
565
|
+
"Anything else is sent directly to the model.",
|
|
566
|
+
].join("\n");
|
|
567
|
+
}
|
|
568
|
+
// P1 异步化:lead 邮箱未读数走 MessageBus.unreadCount(async)。
|
|
569
|
+
// status 命令本身已经在 async 上下文中调用,把 sessionStatus 改成 async 即可。
|
|
570
|
+
async function sessionStatus(state, tokenUsage) {
|
|
571
|
+
const mcpSummary = mcpManager.getStatusSummary();
|
|
572
|
+
const modelsLine = currentResolved.availableModels.length > 0
|
|
573
|
+
? `models ${currentResolved.availableModels.join(", ")}`
|
|
574
|
+
: "";
|
|
575
|
+
const effectiveBaseURL = getEffectiveBaseURL(currentResolved, currentAuthState);
|
|
576
|
+
const leadUnread = await messageBus.unreadCount("lead");
|
|
577
|
+
return [
|
|
578
|
+
`workspace ${WORKDIR}`,
|
|
579
|
+
`provider ${currentResolved.providerName}`,
|
|
580
|
+
`model ${currentResolved.model}`,
|
|
581
|
+
modelsLine,
|
|
582
|
+
`api mode ${currentResolved.apiMode}`,
|
|
583
|
+
`baseURL ${currentResolved.baseURL}`,
|
|
584
|
+
`effective endpoint ${effectiveBaseURL}`,
|
|
585
|
+
formatAuthSummary(currentAuthState),
|
|
586
|
+
`session ${state.sessionId}`,
|
|
587
|
+
`turns ${state.turnCount}`,
|
|
588
|
+
`context ~${estimateActiveContextTokens(state)} tokens | compacted: ${state.compactCount} times`,
|
|
589
|
+
tokenUsage && tokenUsage.inputTokens + tokenUsage.outputTokens > 0
|
|
590
|
+
? `tokens ${formatNum(tokenUsage.inputTokens)} in → ${formatNum(tokenUsage.outputTokens)} out ${tokenUsage.cost.toFixed(4)}`
|
|
591
|
+
: `tokens ~${estimateTokens(state.chatHistory)} (estimated)`,
|
|
592
|
+
`mcp ${mcpSummary.connected} connected | ${mcpSummary.degraded} degraded | ${mcpSummary.disconnected} disconnected | ${mcpSummary.enabled} enabled`,
|
|
593
|
+
`team ${teammateManager.listMembers().length} teammates | lead inbox: ${leadUnread}`,
|
|
594
|
+
`uptime ${formatDuration(Date.now() - state.launchedAt)}`,
|
|
595
|
+
].filter(Boolean).join("\n");
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* 把运行时 UI 消息裁成可落盘的结构。
|
|
599
|
+
*
|
|
600
|
+
* 为什么不直接保存完整 `UiMessage`:
|
|
601
|
+
* - `id` 只服务当前渲染树,恢复后重新分配更简单,也能避免和新消息冲突。
|
|
602
|
+
* - header 这类纯展示占位消息不属于真实会话内容,恢复时重新生成即可。
|
|
603
|
+
* - 持久化层只保留继续会话所需的信息,避免把瞬时渲染细节写进 transcript。
|
|
604
|
+
*/
|
|
605
|
+
function serializeMessages(messages) {
|
|
606
|
+
return messages
|
|
607
|
+
.filter((message) => message.id !== "header")
|
|
608
|
+
.map((message) => ({
|
|
609
|
+
kind: message.kind,
|
|
610
|
+
title: message.title,
|
|
611
|
+
subtitle: message.subtitle,
|
|
612
|
+
text: message.text,
|
|
613
|
+
diffLines: message.diffLines ? [...message.diffLines] : undefined,
|
|
614
|
+
collapsed: message.collapsed,
|
|
615
|
+
}));
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* 把落盘消息恢复成当前会话可渲染的 `UiMessage`。
|
|
619
|
+
*
|
|
620
|
+
* 为什么恢复时重建 ID:
|
|
621
|
+
* - Ink 的消息列表只要求本次进程内唯一,不要求跨会话稳定。
|
|
622
|
+
* - 按顺序重新编号可以让恢复逻辑保持纯函数,不依赖旧进程的计数器状态。
|
|
623
|
+
* - 这也保证后续新增消息继续接在末尾,不会撞上历史 ID。
|
|
624
|
+
*/
|
|
625
|
+
function restoreMessages(messages) {
|
|
626
|
+
return messages.map((message, index) => ({
|
|
627
|
+
id: `message-${index + 1}`,
|
|
628
|
+
kind: message.kind,
|
|
629
|
+
title: message.title,
|
|
630
|
+
subtitle: message.subtitle,
|
|
631
|
+
text: message.text,
|
|
632
|
+
diffLines: message.diffLines ? [...message.diffLines] : undefined,
|
|
633
|
+
collapsed: message.collapsed,
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* 生成当前会话的持久化快照。
|
|
638
|
+
*
|
|
639
|
+
* 为什么快照里同时放 `AgentState` 和 UI 消息:
|
|
640
|
+
* - 仅恢复 `chatHistory/responseHistory` 只能让模型继续工作,但用户会看到空白界面。
|
|
641
|
+
* - 仅恢复 UI 消息又无法把模型上下文接上,等于只是“查看历史”而不是继续会话。
|
|
642
|
+
* - 两者一起保存可以把 `/resume` 做成真正的“从上次中断处继续”。
|
|
643
|
+
*/
|
|
644
|
+
function buildSessionSnapshot(state, messages) {
|
|
645
|
+
return {
|
|
646
|
+
state: {
|
|
647
|
+
...state,
|
|
648
|
+
responseHistory: JSON.parse(JSON.stringify(state.responseHistory)),
|
|
649
|
+
chatHistory: JSON.parse(JSON.stringify(state.chatHistory)),
|
|
650
|
+
},
|
|
651
|
+
messages: serializeMessages(messages),
|
|
652
|
+
providerName: currentResolved?.providerName,
|
|
653
|
+
model: currentResolved?.model,
|
|
654
|
+
apiMode: currentResolved?.apiMode,
|
|
655
|
+
savedAt: new Date().toISOString(),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* 把最近 session 列表格式化成 CLI 可读文本。
|
|
660
|
+
*
|
|
661
|
+
* 为什么保留短标题和 turns:
|
|
662
|
+
* - 恢复操作的本质是帮助用户从多个历史上下文里快速定位目标会话。
|
|
663
|
+
* - 仅显示 sessionId 太难识别,标题能提供主题,turn 数和模型能补充上下文规模。
|
|
664
|
+
* - 文本格式保持简单,后续若要改成交互选择器也可以直接复用这份摘要数据。
|
|
665
|
+
*/
|
|
666
|
+
function formatRecentSessions(currentSessionId) {
|
|
667
|
+
const sessions = listRecentSessions(WORKDIR);
|
|
668
|
+
if (sessions.length === 0) {
|
|
669
|
+
return "No saved sessions for this workspace yet.";
|
|
670
|
+
}
|
|
671
|
+
return [
|
|
672
|
+
"Recent sessions:",
|
|
673
|
+
...sessions.map((session) => {
|
|
674
|
+
const activeMark = session.sessionId === currentSessionId ? " ← current" : "";
|
|
675
|
+
const modelInfo = [session.providerName, session.model].filter(Boolean).join("/");
|
|
676
|
+
const suffix = modelInfo ? ` · ${modelInfo}` : "";
|
|
677
|
+
return ` ${session.sessionId}${activeMark} · ${session.turnCount} turns · ${session.savedAt}${suffix}\n ${session.title}`;
|
|
678
|
+
}),
|
|
679
|
+
"",
|
|
680
|
+
"Use /resume <sessionId> to restore one.",
|
|
681
|
+
].join("\n");
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Resolve startup argv into an optional preloaded session snapshot.
|
|
685
|
+
*
|
|
686
|
+
* Why this happens before Ink mounts:
|
|
687
|
+
* - `xbcode resume <id>` should land in the restored session immediately
|
|
688
|
+
* instead of booting an empty UI and then replaying a command.
|
|
689
|
+
* - Startup restore may also need to switch provider/model first so the in-memory
|
|
690
|
+
* agent state matches the resumed transcript before the first turn.
|
|
691
|
+
* - Returning a small structured result lets the UI reuse the same rendering
|
|
692
|
+
* path whether startup resume succeeded, failed, or only requested a list.
|
|
693
|
+
*/
|
|
694
|
+
function resolveStartupResumeState() {
|
|
695
|
+
const startupCommand = parseStartupCommand(process.argv.slice(2));
|
|
696
|
+
if (startupCommand.kind !== "resume") {
|
|
697
|
+
return {
|
|
698
|
+
hasResolvedModel: !needsModelSelection(),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
if (!startupCommand.sessionId) {
|
|
702
|
+
return {
|
|
703
|
+
startupMessage: {
|
|
704
|
+
id: "startup-resume",
|
|
705
|
+
kind: "system",
|
|
706
|
+
title: "resume",
|
|
707
|
+
text: formatRecentSessions(""),
|
|
708
|
+
},
|
|
709
|
+
hasResolvedModel: !needsModelSelection(),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
const snapshot = loadSession(WORKDIR, startupCommand.sessionId);
|
|
713
|
+
if (!snapshot) {
|
|
714
|
+
return {
|
|
715
|
+
startupMessage: {
|
|
716
|
+
id: "startup-resume",
|
|
717
|
+
kind: "error",
|
|
718
|
+
title: "resume",
|
|
719
|
+
text: `Session not found: "${startupCommand.sessionId}"`,
|
|
720
|
+
},
|
|
721
|
+
hasResolvedModel: !needsModelSelection(),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
let hasResolvedModel = !needsModelSelection();
|
|
725
|
+
let startupMessage = {
|
|
726
|
+
id: "startup-resume",
|
|
727
|
+
kind: "system",
|
|
728
|
+
title: "resume",
|
|
729
|
+
text: `Resumed session ${snapshot.state.sessionId}.`,
|
|
730
|
+
};
|
|
731
|
+
if (snapshot.providerName && snapshot.model) {
|
|
732
|
+
try {
|
|
733
|
+
ensureConfig(snapshot.providerName, snapshot.model);
|
|
734
|
+
hasResolvedModel = true;
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
startupMessage = {
|
|
738
|
+
id: "startup-resume",
|
|
739
|
+
kind: "error",
|
|
740
|
+
title: "resume",
|
|
741
|
+
text: `Restored session state, but could not switch model to ${snapshot.providerName}/${snapshot.model}: ${error instanceof Error ? error.message : String(error)}`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
snapshot,
|
|
747
|
+
startupMessage,
|
|
748
|
+
hasResolvedModel,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
// P1:buildLeadInboxQuery 已废弃。新的 runOneTurn helper(见组件内部)
|
|
752
|
+
// 直接拼接 formatTeammateMessages + 用户 query,不再用 <user_request> 包裹。
|
|
753
|
+
function findSlashCommandMatches(inputValue) {
|
|
754
|
+
if (!inputValue.startsWith("/")) {
|
|
755
|
+
return [];
|
|
756
|
+
}
|
|
757
|
+
const query = inputValue.trim().toLowerCase();
|
|
758
|
+
if (query === "/") {
|
|
759
|
+
return getAllSlashCommands();
|
|
760
|
+
}
|
|
761
|
+
return getAllSlashCommands().filter(({ command }) => command.startsWith(query));
|
|
762
|
+
}
|
|
763
|
+
function parseSkillSlashInvocation(inputValue) {
|
|
764
|
+
const trimmed = inputValue.trim();
|
|
765
|
+
if (!trimmed.startsWith("/")) {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
const withoutSlash = trimmed.slice(1).trim();
|
|
769
|
+
if (!withoutSlash) {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
const firstSpaceIndex = withoutSlash.search(/\s/);
|
|
773
|
+
const commandName = (firstSpaceIndex === -1 ? withoutSlash : withoutSlash.slice(0, firstSpaceIndex)).trim();
|
|
774
|
+
if (!commandName || !skillLoader.getCommand(commandName)) {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
const args = firstSpaceIndex === -1 ? "" : withoutSlash.slice(firstSpaceIndex + 1).trim();
|
|
778
|
+
return {
|
|
779
|
+
command: commandName,
|
|
780
|
+
args: args || undefined,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function getVisibleSlashMatches(matches, selectedIndex) {
|
|
784
|
+
if (matches.length <= MAX_VISIBLE_SLASH_MATCHES) {
|
|
785
|
+
return {
|
|
786
|
+
items: matches,
|
|
787
|
+
start: 0,
|
|
788
|
+
hiddenAbove: 0,
|
|
789
|
+
hiddenBelow: 0,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
const maxStart = Math.max(0, matches.length - MAX_VISIBLE_SLASH_MATCHES);
|
|
793
|
+
const idealStart = selectedIndex - Math.floor(MAX_VISIBLE_SLASH_MATCHES / 2);
|
|
794
|
+
const start = Math.max(0, Math.min(maxStart, idealStart));
|
|
795
|
+
const end = Math.min(matches.length, start + MAX_VISIBLE_SLASH_MATCHES);
|
|
796
|
+
return {
|
|
797
|
+
items: matches.slice(start, end),
|
|
798
|
+
start,
|
|
799
|
+
hiddenAbove: start,
|
|
800
|
+
hiddenBelow: matches.length - end,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function buildMessageRows(message, width) {
|
|
804
|
+
const { titleColor, bodyColor } = getMessagePalette(message.kind);
|
|
805
|
+
const marker = getMessageMarker(message.kind);
|
|
806
|
+
const contentWidth = Math.max(1, width - 2);
|
|
807
|
+
const rows = [];
|
|
808
|
+
const appendRows = (text, color, dimColor = false) => {
|
|
809
|
+
for (const row of wrapTextToRows(text, contentWidth)) {
|
|
810
|
+
rows.push({ text: row, color, dimColor });
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
if (message.kind === "tool" && message.diffLines && message.diffLines.length > 0) {
|
|
814
|
+
if (message.title) {
|
|
815
|
+
appendRows(message.title, "yellow");
|
|
816
|
+
}
|
|
817
|
+
if (message.subtitle) {
|
|
818
|
+
appendRows(` ${message.subtitle}`, "gray");
|
|
819
|
+
}
|
|
820
|
+
for (const line of message.diffLines) {
|
|
821
|
+
appendRows(line.text, line.color);
|
|
822
|
+
}
|
|
823
|
+
return rows.map((row, index) => ({
|
|
824
|
+
id: `${message.id}-${index}`,
|
|
825
|
+
prefix: index === 0 ? "● " : " ",
|
|
826
|
+
prefixColor: index === 0 ? "yellow" : undefined,
|
|
827
|
+
text: row.text,
|
|
828
|
+
color: row.color,
|
|
829
|
+
dimColor: row.dimColor,
|
|
830
|
+
}));
|
|
831
|
+
}
|
|
832
|
+
if (message.kind === "thinking" && message.collapsed) {
|
|
833
|
+
const lineCount = (message.text || "").split("\n").length;
|
|
834
|
+
const preview = ellipsize((message.text || "").split("\n")[0].trim(), 60);
|
|
835
|
+
appendRows(`[thinking · ${lineCount} lines] ${preview}`, "blue", true);
|
|
836
|
+
return rows.map((row, index) => ({
|
|
837
|
+
id: `${message.id}-${index}`,
|
|
838
|
+
prefix: index === 0 ? "▸ " : " ",
|
|
839
|
+
prefixColor: index === 0 ? "gray" : undefined,
|
|
840
|
+
text: row.text,
|
|
841
|
+
color: row.color,
|
|
842
|
+
dimColor: row.dimColor,
|
|
843
|
+
}));
|
|
844
|
+
}
|
|
845
|
+
const displayText = message.kind === "assistant"
|
|
846
|
+
? renderMarkdown(message.text || " ")
|
|
847
|
+
: (message.text || " ");
|
|
848
|
+
if (message.title) {
|
|
849
|
+
appendRows(`[${message.title}]`, titleColor);
|
|
850
|
+
}
|
|
851
|
+
appendRows(displayText, message.kind === "assistant" ? undefined : bodyColor, message.kind === "thinking");
|
|
852
|
+
return rows.map((row, index) => ({
|
|
853
|
+
id: `${message.id}-${index}`,
|
|
854
|
+
prefix: index === 0 ? `${marker?.symbol ?? " "} ` : " ",
|
|
855
|
+
prefixColor: index === 0 ? marker?.color : undefined,
|
|
856
|
+
text: row.text,
|
|
857
|
+
color: row.color,
|
|
858
|
+
dimColor: row.dimColor,
|
|
859
|
+
}));
|
|
860
|
+
}
|
|
861
|
+
function ViewportRowView({ row }) {
|
|
862
|
+
return (_jsxs(Text, { wrap: "truncate-end", color: row.color, dimColor: row.dimColor, children: [_jsx(Text, { color: row.prefixColor, children: row.prefix }), row.text] }));
|
|
863
|
+
}
|
|
864
|
+
function MessageBlock({ message, width }) {
|
|
865
|
+
const rows = buildMessageRows(message, width);
|
|
866
|
+
return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: rows.map((row) => (_jsx(ViewportRowView, { row: row }, row.id))) }));
|
|
867
|
+
}
|
|
868
|
+
function TrustPrompt({ selectedIndex }) {
|
|
869
|
+
const options = ["Yes, continue", "No, quit"];
|
|
870
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: ["You are in ", WORKDIR] }), _jsx(Text, { children: "Do you trust the contents of this directory? Working with untrusted contents comes with higher risk of prompt injection." }), _jsx(Newline, {}), options.map((label, index) => (_jsxs(Text, { color: selectedIndex === index ? "cyan" : "white", children: [selectedIndex === index ? "›" : " ", " ", index + 1, ". ", label] }, label))), _jsx(Newline, {}), _jsx(Text, { color: "gray", children: "Use \u2191/\u2193 to choose, Enter to confirm" })] }));
|
|
871
|
+
}
|
|
872
|
+
function ModelSelectPrompt({ choices, selectedIndex, activeModelId }) {
|
|
873
|
+
if (choices.length === 0) {
|
|
874
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "red", children: "No models configured." }), _jsxs(Text, { children: ["Edit ", _jsx(Text, { color: "cyan", children: getSettingsPath() }), " to add providers and models."] })] }));
|
|
875
|
+
}
|
|
876
|
+
// Compute column widths for alignment
|
|
877
|
+
const indexWidth = String(choices.length).length + 2; // "1. " etc.
|
|
878
|
+
const nameWidth = Math.max(...choices.map((c) => c.displayName.length)) + 3; // padding + checkmark
|
|
879
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: "green", children: "Select model" }), _jsx(Text, { color: "gray", children: "Switch between models. Applies to this session." }), _jsx(Text, { children: " " }), choices.map(({ displayName, description, modelId }, index) => {
|
|
880
|
+
const isSelected = index === selectedIndex;
|
|
881
|
+
const isActive = modelId === activeModelId;
|
|
882
|
+
const prefix = isSelected ? "› " : " ";
|
|
883
|
+
const num = `${index + 1}.`;
|
|
884
|
+
const check = isActive ? " ✓" : "";
|
|
885
|
+
const nameCol = `${displayName}${check}`;
|
|
886
|
+
const descCol = description ? `· ${description}` : "";
|
|
887
|
+
return (_jsxs(Text, { color: isSelected ? "cyan" : undefined, children: [prefix, num.padEnd(indexWidth), nameCol.padEnd(nameWidth), descCol] }, `${modelId}-${index}`));
|
|
888
|
+
}), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: "Use \u2191/\u2193 to choose, Enter to confirm, Esc to cancel" })] }));
|
|
889
|
+
}
|
|
890
|
+
function CliApp({ startupResume }) {
|
|
891
|
+
const { exit } = useApp();
|
|
892
|
+
const initialRestoredMessages = startupResume.snapshot ? restoreMessages(startupResume.snapshot.messages) : [];
|
|
893
|
+
const initialMessages = [
|
|
894
|
+
headerItem(),
|
|
895
|
+
...initialRestoredMessages,
|
|
896
|
+
...(startupResume.startupMessage ? [startupResume.startupMessage] : []),
|
|
897
|
+
];
|
|
898
|
+
const [messages, setMessages] = useState(initialMessages);
|
|
899
|
+
const messagesRef = useRef([]);
|
|
900
|
+
const [inputValue, setInputValue] = useState("");
|
|
901
|
+
const [busy, setBusy] = useState(false);
|
|
902
|
+
const [pendingAttachments, setPendingAttachments] = useState([]);
|
|
903
|
+
// P1:busyRef 与 busy state 镜像,给 onSend("lead") listener 闭包用。
|
|
904
|
+
// 闭包读 state 会拿旧值,必须用 ref 读最新值。所有 setBusy 调用处同步更新此 ref。
|
|
905
|
+
const busyRef = useRef(false);
|
|
906
|
+
// 心跳指示器:turn 起始时间 + 最近一次 stream 活动时间。每秒 tick 一次重渲染,
|
|
907
|
+
// UI 用它显示 "Working 12s · idle 7s · Esc to stop",让用户能区分
|
|
908
|
+
// "模型还在 thinking/talking" 和 "连接 stall / 网关缓冲"。
|
|
909
|
+
//
|
|
910
|
+
// 之所以走 useEffect([busy]) 集中管理,而不是在每个 setBusy 调用点初始化 ref:
|
|
911
|
+
// - setBusy(true) 在多个分支(手动 submit、idle 唤醒、resume)都会调用,
|
|
912
|
+
// 单点初始化漏写一处就会导致 "Working 0s" 永远不更新;
|
|
913
|
+
// - useEffect 在 React 渲染稳定后只触发一次,对 ref 写入即使在 strict mode 也可控。
|
|
914
|
+
const turnStartedAtRef = useRef(null);
|
|
915
|
+
const lastActivityAtRef = useRef(null);
|
|
916
|
+
const [busyTick, setBusyTick] = useState(0);
|
|
917
|
+
useEffect(() => {
|
|
918
|
+
if (busy) {
|
|
919
|
+
const now = Date.now();
|
|
920
|
+
turnStartedAtRef.current = now;
|
|
921
|
+
lastActivityAtRef.current = now;
|
|
922
|
+
const interval = setInterval(() => setBusyTick((t) => t + 1), 1000);
|
|
923
|
+
return () => clearInterval(interval);
|
|
924
|
+
}
|
|
925
|
+
turnStartedAtRef.current = null;
|
|
926
|
+
lastActivityAtRef.current = null;
|
|
927
|
+
return undefined;
|
|
928
|
+
}, [busy]);
|
|
929
|
+
const [trusted, setTrusted] = useState(() => (isTrusted(process.cwd()) ? true : null));
|
|
930
|
+
/**
|
|
931
|
+
* Startup can begin with a fully resolved model when either:
|
|
932
|
+
* - the user set `MODEL_ID` for this shell session, or
|
|
933
|
+
* - persisted defaults already point at a valid configured model.
|
|
934
|
+
*
|
|
935
|
+
* Why this state is initialized eagerly:
|
|
936
|
+
* - The render tree has two different concerns: whether the picker is open
|
|
937
|
+
* (`modelSelected`) and whether the footer should describe an active model
|
|
938
|
+
* (`userHasChosenModel`).
|
|
939
|
+
* - When startup skips the picker we must mark both as ready immediately,
|
|
940
|
+
* otherwise the UI ends up in a contradictory state where the header and
|
|
941
|
+
* status bar show a model while the footer still says "No model selected".
|
|
942
|
+
*/
|
|
943
|
+
const startupHasResolvedModel = startupResume.hasResolvedModel;
|
|
944
|
+
const [modelSelected, setModelSelected] = useState(startupHasResolvedModel);
|
|
945
|
+
const [userHasChosenModel, setUserHasChosenModel] = useState(startupHasResolvedModel);
|
|
946
|
+
const [trustSelection, setTrustSelection] = useState(0);
|
|
947
|
+
const [modelSelectionIndex, setModelSelectionIndex] = useState(0);
|
|
948
|
+
const [slashSelection, setSlashSelection] = useState(0);
|
|
949
|
+
const [streamingId, setStreamingId] = useState(undefined);
|
|
950
|
+
const modelChoices = useRef(buildModelChoices());
|
|
951
|
+
const [terminalSize, setTerminalSize] = useState({
|
|
952
|
+
columns: process.stdout.columns ?? 80,
|
|
953
|
+
rows: process.stdout.rows ?? 24,
|
|
954
|
+
});
|
|
955
|
+
const messageCounterRef = useRef(initialRestoredMessages.length + (startupResume.startupMessage ? 1 : 0));
|
|
956
|
+
const [sessionKey, setSessionKey] = useState(0);
|
|
957
|
+
const [totalTokenUsage, setTotalTokenUsage] = useState({ inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, cost: 0 });
|
|
958
|
+
const assistantMessageIdRef = useRef(undefined);
|
|
959
|
+
const thinkingMessageIdRef = useRef(undefined);
|
|
960
|
+
const skipSubmitRef = useRef(false);
|
|
961
|
+
const submitDeduperRef = useRef(createSubmitDeduper());
|
|
962
|
+
const loginInFlightRef = useRef(false);
|
|
963
|
+
const activeTurnAbortRef = useRef(null);
|
|
964
|
+
const stopRequestedRef = useRef(false);
|
|
965
|
+
const startupMcpReportShownRef = useRef(false);
|
|
966
|
+
// Human-in-the-loop tool approval. `pendingApproval` drives the overlay; the
|
|
967
|
+
// resolver settles the promise the agent loop is awaiting; the allowlist
|
|
968
|
+
// remembers "always allow <tool>" choices for the lifetime of this session.
|
|
969
|
+
const [pendingApproval, setPendingApproval] = useState(null);
|
|
970
|
+
const [approvalSelection, setApprovalSelection] = useState(0);
|
|
971
|
+
const approvalResolverRef = useRef(null);
|
|
972
|
+
const alwaysAllowToolsRef = useRef(new Set());
|
|
973
|
+
// Human-in-the-loop choice prompt (`ask_user_question`). Questions are answered
|
|
974
|
+
// one at a time; `questionSelections[i]` holds the chosen labels for question i.
|
|
975
|
+
const [pendingQuestion, setPendingQuestion] = useState(null);
|
|
976
|
+
const [questionIndex, setQuestionIndex] = useState(0);
|
|
977
|
+
const [questionCursor, setQuestionCursor] = useState(0);
|
|
978
|
+
const [questionSelections, setQuestionSelections] = useState([]);
|
|
979
|
+
const questionResolverRef = useRef(null);
|
|
980
|
+
const agentStateRef = useRef({
|
|
981
|
+
...(startupResume.snapshot?.state ?? {
|
|
982
|
+
sessionId: createSessionId(),
|
|
983
|
+
responseHistory: [],
|
|
984
|
+
chatHistory: [],
|
|
985
|
+
turnCount: 0,
|
|
986
|
+
launchedAt: Date.now(),
|
|
987
|
+
roundsSinceTask: 0,
|
|
988
|
+
compactCount: 0,
|
|
989
|
+
}),
|
|
990
|
+
});
|
|
991
|
+
const slashMatches = findSlashCommandMatches(inputValue);
|
|
992
|
+
const showSlashMenu = trusted === true && !busy && slashMatches.length > 0;
|
|
993
|
+
const activeSlashIndex = Math.min(slashSelection, Math.max(0, slashMatches.length - 1));
|
|
994
|
+
const visibleSlashMenu = getVisibleSlashMatches(slashMatches, activeSlashIndex);
|
|
995
|
+
useEffect(() => {
|
|
996
|
+
setSlashSelection(0);
|
|
997
|
+
}, [inputValue]);
|
|
998
|
+
useEffect(() => {
|
|
999
|
+
messagesRef.current = messages;
|
|
1000
|
+
}, [messages]);
|
|
1001
|
+
useEffect(() => {
|
|
1002
|
+
messagesRef.current = initialMessages;
|
|
1003
|
+
}, []);
|
|
1004
|
+
const requestExit = () => {
|
|
1005
|
+
/**
|
|
1006
|
+
* 这里只依赖 Ink 的 exit() 不够稳妥:
|
|
1007
|
+
* 它会卸载界面,但如果事件循环里还残留句柄,Node 进程可能继续存活,
|
|
1008
|
+
* 用户就会看到 `/exit` 像是“没反应”。因此这里先走正常卸载,
|
|
1009
|
+
* 再在下一个 tick 强制结束进程,确保命令语义就是立即退出。
|
|
1010
|
+
*/
|
|
1011
|
+
if (messagesRef.current.length > 1) {
|
|
1012
|
+
persistCurrentSession();
|
|
1013
|
+
}
|
|
1014
|
+
exit();
|
|
1015
|
+
setImmediate(() => {
|
|
1016
|
+
process.exit(0);
|
|
1017
|
+
});
|
|
1018
|
+
};
|
|
1019
|
+
useEffect(() => {
|
|
1020
|
+
const handleResize = () => {
|
|
1021
|
+
// Ink's <Static> output is append-only. After a resize, wrapped line counts change,
|
|
1022
|
+
// so we clear and remount the tree to prevent old rows from lingering on screen.
|
|
1023
|
+
process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
|
|
1024
|
+
setSessionKey((current) => current + 1);
|
|
1025
|
+
setTerminalSize({
|
|
1026
|
+
columns: process.stdout.columns ?? 80,
|
|
1027
|
+
rows: process.stdout.rows ?? 24,
|
|
1028
|
+
});
|
|
1029
|
+
};
|
|
1030
|
+
process.stdout.on("resize", handleResize);
|
|
1031
|
+
return () => {
|
|
1032
|
+
process.stdout.off("resize", handleResize);
|
|
1033
|
+
};
|
|
1034
|
+
}, []);
|
|
1035
|
+
const pushMessage = (kind, text, title) => {
|
|
1036
|
+
messageCounterRef.current += 1;
|
|
1037
|
+
const id = `message-${messageCounterRef.current}`;
|
|
1038
|
+
setMessages((current) => {
|
|
1039
|
+
const next = [...current, { id, kind, title, text }];
|
|
1040
|
+
messagesRef.current = next;
|
|
1041
|
+
return next;
|
|
1042
|
+
});
|
|
1043
|
+
};
|
|
1044
|
+
/**
|
|
1045
|
+
* 把当前会话安全地写入本地 transcript。
|
|
1046
|
+
*
|
|
1047
|
+
* 为什么把持久化失败吞成系统消息而不是直接抛错:
|
|
1048
|
+
* - 会话保存属于增强能力,不能因为磁盘问题阻断主对话流程。
|
|
1049
|
+
* - 用户仍然需要知道“恢复能力不可用”,所以这里转成可见告警最合适。
|
|
1050
|
+
* - 写入始终基于当前内存态快照,不依赖异步队列,避免进程退出时再丢一次。
|
|
1051
|
+
*/
|
|
1052
|
+
const persistCurrentSession = () => {
|
|
1053
|
+
try {
|
|
1054
|
+
appendSessionCheckpoint(WORKDIR, buildSessionSnapshot(agentStateRef.current, messagesRef.current));
|
|
1055
|
+
}
|
|
1056
|
+
catch (error) {
|
|
1057
|
+
pushMessage("error", `Failed to save session: ${error instanceof Error ? error.message : String(error)}`, "session");
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
const importClipboardImage = async () => {
|
|
1061
|
+
try {
|
|
1062
|
+
const attachment = await importClipboardImageMacos();
|
|
1063
|
+
setPendingAttachments((current) => [...current, attachment]);
|
|
1064
|
+
pushMessage("system", `Attached image from clipboard: ${path.basename(attachment.path)}`, "image");
|
|
1065
|
+
}
|
|
1066
|
+
catch (error) {
|
|
1067
|
+
pushMessage("error", error instanceof Error ? error.message : String(error), "image");
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
const appendStreamingMessage = (kind, ref, delta, title) => {
|
|
1071
|
+
if (!delta) {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
setMessages((current) => {
|
|
1075
|
+
if (!ref.current) {
|
|
1076
|
+
messageCounterRef.current += 1;
|
|
1077
|
+
ref.current = `message-${messageCounterRef.current}`;
|
|
1078
|
+
setStreamingId(ref.current);
|
|
1079
|
+
const next = [...current, { id: ref.current, kind, title, text: delta }];
|
|
1080
|
+
messagesRef.current = next;
|
|
1081
|
+
return next;
|
|
1082
|
+
}
|
|
1083
|
+
const next = current.map((message) => (message.id === ref.current ? { ...message, text: `${message.text}${delta}` } : message));
|
|
1084
|
+
messagesRef.current = next;
|
|
1085
|
+
return next;
|
|
1086
|
+
});
|
|
1087
|
+
};
|
|
1088
|
+
const finalizeStreaming = () => {
|
|
1089
|
+
assistantMessageIdRef.current = undefined;
|
|
1090
|
+
thinkingMessageIdRef.current = undefined;
|
|
1091
|
+
setStreamingId(undefined);
|
|
1092
|
+
};
|
|
1093
|
+
const bridge = {
|
|
1094
|
+
appendAssistantDelta(delta) {
|
|
1095
|
+
lastActivityAtRef.current = Date.now();
|
|
1096
|
+
appendStreamingMessage("assistant", assistantMessageIdRef, delta);
|
|
1097
|
+
},
|
|
1098
|
+
appendThinkingDelta(delta) {
|
|
1099
|
+
lastActivityAtRef.current = Date.now();
|
|
1100
|
+
appendStreamingMessage("thinking", thinkingMessageIdRef, delta, "thinking");
|
|
1101
|
+
},
|
|
1102
|
+
finalizeStreaming() {
|
|
1103
|
+
finalizeStreaming();
|
|
1104
|
+
},
|
|
1105
|
+
noteStreamActivity() {
|
|
1106
|
+
lastActivityAtRef.current = Date.now();
|
|
1107
|
+
},
|
|
1108
|
+
pushAssistant(text) {
|
|
1109
|
+
pushMessage("assistant", text);
|
|
1110
|
+
},
|
|
1111
|
+
pushTool(name, args, result) {
|
|
1112
|
+
// tool 完成本身也算"活动"——否则一个 60s 的 bash 跑完时,
|
|
1113
|
+
// 距离上次 stream event 已经过去 60s,UI 会显示 "idle 60s" 让用户误判为卡死。
|
|
1114
|
+
// 这里把 tool 完成时间点也喂给心跳,配合 stream 事件就能覆盖大多数正常路径。
|
|
1115
|
+
lastActivityAtRef.current = Date.now();
|
|
1116
|
+
finalizeStreaming();
|
|
1117
|
+
const display = formatToolDisplay(name, args, result);
|
|
1118
|
+
if (display) {
|
|
1119
|
+
messageCounterRef.current += 1;
|
|
1120
|
+
const id = `message-${messageCounterRef.current}`;
|
|
1121
|
+
setMessages((current) => {
|
|
1122
|
+
const next = [
|
|
1123
|
+
...current,
|
|
1124
|
+
{
|
|
1125
|
+
id,
|
|
1126
|
+
kind: "tool",
|
|
1127
|
+
title: display.title,
|
|
1128
|
+
subtitle: display.subtitle,
|
|
1129
|
+
text: "",
|
|
1130
|
+
diffLines: display.lines.map((l) => ({ text: l.text, color: l.color })),
|
|
1131
|
+
},
|
|
1132
|
+
];
|
|
1133
|
+
messagesRef.current = next;
|
|
1134
|
+
return next;
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
else {
|
|
1138
|
+
pushMessage("tool", `args ${toolPreview(JSON.stringify(args))}\nresult ${toolPreview(result)}`, `tool ${name}`);
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
updateUsage(usage) {
|
|
1142
|
+
setTotalTokenUsage(usage);
|
|
1143
|
+
},
|
|
1144
|
+
requestToolApproval(name, args) {
|
|
1145
|
+
// Already approved for the session — run without prompting.
|
|
1146
|
+
if (alwaysAllowToolsRef.current.has(name)) {
|
|
1147
|
+
return Promise.resolve("approved");
|
|
1148
|
+
}
|
|
1149
|
+
finalizeStreaming();
|
|
1150
|
+
return new Promise((resolve) => {
|
|
1151
|
+
approvalResolverRef.current = resolve;
|
|
1152
|
+
setApprovalSelection(0);
|
|
1153
|
+
setPendingApproval({ name, args, lines: summarizeApprovalTarget(name, args) });
|
|
1154
|
+
});
|
|
1155
|
+
},
|
|
1156
|
+
requestUserChoice(questions) {
|
|
1157
|
+
finalizeStreaming();
|
|
1158
|
+
return new Promise((resolve) => {
|
|
1159
|
+
questionResolverRef.current = resolve;
|
|
1160
|
+
setQuestionIndex(0);
|
|
1161
|
+
setQuestionCursor(0);
|
|
1162
|
+
setQuestionSelections(questions.map(() => []));
|
|
1163
|
+
setPendingQuestion(questions);
|
|
1164
|
+
});
|
|
1165
|
+
},
|
|
1166
|
+
};
|
|
1167
|
+
// Settle a pending approval and tear down the overlay. `alwaysAllowName`, when
|
|
1168
|
+
// set, remembers the tool so future calls skip the prompt this session.
|
|
1169
|
+
const resolveApproval = (decision, alwaysAllowName) => {
|
|
1170
|
+
if (alwaysAllowName) {
|
|
1171
|
+
alwaysAllowToolsRef.current.add(alwaysAllowName);
|
|
1172
|
+
}
|
|
1173
|
+
const resolve = approvalResolverRef.current;
|
|
1174
|
+
approvalResolverRef.current = null;
|
|
1175
|
+
setPendingApproval(null);
|
|
1176
|
+
setApprovalSelection(0);
|
|
1177
|
+
resolve?.(decision);
|
|
1178
|
+
};
|
|
1179
|
+
// Settle a pending question prompt and tear down the overlay, handing the
|
|
1180
|
+
// collected per-question selections back to the awaiting agent loop.
|
|
1181
|
+
const resolveQuestion = (answers) => {
|
|
1182
|
+
const resolve = questionResolverRef.current;
|
|
1183
|
+
questionResolverRef.current = null;
|
|
1184
|
+
setPendingQuestion(null);
|
|
1185
|
+
setQuestionIndex(0);
|
|
1186
|
+
setQuestionCursor(0);
|
|
1187
|
+
setQuestionSelections([]);
|
|
1188
|
+
resolve?.(answers);
|
|
1189
|
+
};
|
|
1190
|
+
const resetConversation = (snapshot) => {
|
|
1191
|
+
assistantMessageIdRef.current = undefined;
|
|
1192
|
+
thinkingMessageIdRef.current = undefined;
|
|
1193
|
+
setStreamingId(undefined);
|
|
1194
|
+
setPendingAttachments([]);
|
|
1195
|
+
agentStateRef.current = snapshot?.state ?? {
|
|
1196
|
+
sessionId: createSessionId(),
|
|
1197
|
+
responseHistory: [],
|
|
1198
|
+
chatHistory: [],
|
|
1199
|
+
turnCount: 0,
|
|
1200
|
+
launchedAt: Date.now(),
|
|
1201
|
+
roundsSinceTask: 0,
|
|
1202
|
+
compactCount: 0,
|
|
1203
|
+
};
|
|
1204
|
+
const restoredMessages = snapshot ? restoreMessages(snapshot.messages) : [];
|
|
1205
|
+
messageCounterRef.current = restoredMessages.length;
|
|
1206
|
+
// Clear the terminal then remount <Static> with fresh messages.
|
|
1207
|
+
process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
|
|
1208
|
+
setSessionKey((k) => k + 1);
|
|
1209
|
+
messagesRef.current = [headerItem(), ...restoredMessages];
|
|
1210
|
+
setMessages(messagesRef.current);
|
|
1211
|
+
};
|
|
1212
|
+
const clearPendingAttachments = () => {
|
|
1213
|
+
setPendingAttachments([]);
|
|
1214
|
+
};
|
|
1215
|
+
const attachImagesFromText = (rawValue) => {
|
|
1216
|
+
const { attachments, remainingText } = extractImagePathsFromText(rawValue);
|
|
1217
|
+
if (attachments.length === 0) {
|
|
1218
|
+
return { value: rawValue, newlyAttached: [] };
|
|
1219
|
+
}
|
|
1220
|
+
const newlyAttached = [];
|
|
1221
|
+
setPendingAttachments((current) => {
|
|
1222
|
+
const existing = new Set(current.map((attachment) => attachment.path));
|
|
1223
|
+
const next = [...current];
|
|
1224
|
+
for (const attachment of attachments) {
|
|
1225
|
+
if (!existing.has(attachment.path)) {
|
|
1226
|
+
next.push(attachment);
|
|
1227
|
+
newlyAttached.push(attachment);
|
|
1228
|
+
existing.add(attachment.path);
|
|
1229
|
+
pushMessage("system", `Attached image: ${path.basename(attachment.path)}`, "image");
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return next;
|
|
1233
|
+
});
|
|
1234
|
+
return { value: remainingText, newlyAttached };
|
|
1235
|
+
};
|
|
1236
|
+
const requestActiveTurnStop = () => {
|
|
1237
|
+
const controller = activeTurnAbortRef.current;
|
|
1238
|
+
if (!controller || controller.signal.aborted || stopRequestedRef.current) {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
stopRequestedRef.current = true;
|
|
1242
|
+
controller.abort();
|
|
1243
|
+
pushMessage("system", "Stopping current turn. Session context will be kept.", "stop");
|
|
1244
|
+
};
|
|
1245
|
+
const acceptTrust = () => {
|
|
1246
|
+
setTrusted(true);
|
|
1247
|
+
markTrusted(process.cwd());
|
|
1248
|
+
if (modelSelected) {
|
|
1249
|
+
// env var already set, skip selection
|
|
1250
|
+
if (!currentResolved)
|
|
1251
|
+
ensureConfig();
|
|
1252
|
+
setUserHasChosenModel(true);
|
|
1253
|
+
primeMcpRuntime();
|
|
1254
|
+
if (messagesRef.current.length === 0) {
|
|
1255
|
+
messagesRef.current = [headerItem()];
|
|
1256
|
+
setMessages(messagesRef.current);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
const acceptModelSelection = (index) => {
|
|
1261
|
+
const choice = modelChoices.current[index];
|
|
1262
|
+
if (!choice)
|
|
1263
|
+
return;
|
|
1264
|
+
const isInitialSelection = messages.length === 0;
|
|
1265
|
+
finalizeStreaming();
|
|
1266
|
+
ensureConfig(choice.provider, choice.modelId);
|
|
1267
|
+
setModelSelected(true);
|
|
1268
|
+
setUserHasChosenModel(true);
|
|
1269
|
+
primeMcpRuntime();
|
|
1270
|
+
if (isInitialSelection && messagesRef.current.length <= 1) {
|
|
1271
|
+
messagesRef.current = [headerItem()];
|
|
1272
|
+
setMessages(messagesRef.current);
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1275
|
+
// Clear screen and rebuild <Static> to avoid Ink re-rendering stale items out of order
|
|
1276
|
+
messageCounterRef.current += 1;
|
|
1277
|
+
const switchMsg = {
|
|
1278
|
+
id: `message-${messageCounterRef.current}`,
|
|
1279
|
+
kind: "system",
|
|
1280
|
+
title: "model",
|
|
1281
|
+
text: `Switched to model "${choice.displayName}" (provider: ${choice.provider})`,
|
|
1282
|
+
};
|
|
1283
|
+
process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
|
|
1284
|
+
setSessionKey((k) => k + 1);
|
|
1285
|
+
setMessages((current) => {
|
|
1286
|
+
const next = [...current, switchMsg];
|
|
1287
|
+
messagesRef.current = next;
|
|
1288
|
+
return next;
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
const handleTrustInput = (input, key) => {
|
|
1293
|
+
if (key.upArrow || input === "k") {
|
|
1294
|
+
setTrustSelection((current) => (current - 1 + 2) % 2);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (key.downArrow || input === "j") {
|
|
1298
|
+
setTrustSelection((current) => (current + 1) % 2);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
if (key.return) {
|
|
1302
|
+
if (trustSelection === 0) {
|
|
1303
|
+
acceptTrust();
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
requestExit();
|
|
1307
|
+
}
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
if (["1", "y", "Y"].includes(input)) {
|
|
1311
|
+
acceptTrust();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if (["2", "n", "N", "q", "Q"].includes(input)) {
|
|
1315
|
+
requestExit();
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
// P1 单轮运行入口:把未读 lead 消息 + 用户 query 合并注入,调用 runAgentTurn。
|
|
1319
|
+
// 之所以拆成 helper:用户主动提交(带 query)和自动续轮(query 为空)共用同一段逻辑,避免重复。
|
|
1320
|
+
// 命名为 runOneTurn 与 spec §3.5 对齐。
|
|
1321
|
+
const runOneTurn = async (userQuery, attachments, signal) => {
|
|
1322
|
+
const unread = await messageBus.readUnread("lead");
|
|
1323
|
+
let injected = "";
|
|
1324
|
+
if (unread.length > 0) {
|
|
1325
|
+
await messageBus.markRead("lead", unread);
|
|
1326
|
+
pushMessage("system", `(injecting ${unread.length} teammate message(s))`, "inbox");
|
|
1327
|
+
injected = formatTeammateMessages(unread);
|
|
1328
|
+
}
|
|
1329
|
+
const effectiveQuery = userQuery && injected
|
|
1330
|
+
? `${injected}\n\n${userQuery}`
|
|
1331
|
+
: (userQuery || injected);
|
|
1332
|
+
if (!effectiveQuery)
|
|
1333
|
+
return; // 双空:无事可做
|
|
1334
|
+
await runAgentTurn(agentConfig, effectiveQuery, attachments, agentStateRef.current, bridge, { signal });
|
|
1335
|
+
};
|
|
1336
|
+
// P1 lead idle 唤醒:listener 触发,但当前轮在跑就什么都不做(路径 A 的 while 兜底)。
|
|
1337
|
+
// 只有 lead 当前空闲才主动开一轮。
|
|
1338
|
+
const triggerLeadAutoTurn = async () => {
|
|
1339
|
+
if (busyRef.current)
|
|
1340
|
+
return;
|
|
1341
|
+
if ((await messageBus.unreadCount("lead")) === 0)
|
|
1342
|
+
return;
|
|
1343
|
+
setBusy(true);
|
|
1344
|
+
busyRef.current = true;
|
|
1345
|
+
stopRequestedRef.current = false;
|
|
1346
|
+
const abortController = new AbortController();
|
|
1347
|
+
activeTurnAbortRef.current = abortController;
|
|
1348
|
+
try {
|
|
1349
|
+
await ensureMcpInitialized();
|
|
1350
|
+
await refreshCurrentAuth();
|
|
1351
|
+
await runOneTurn("", [], abortController.signal);
|
|
1352
|
+
while (!abortController.signal.aborted
|
|
1353
|
+
&& (await messageBus.unreadCount("lead")) > 0) {
|
|
1354
|
+
pushMessage("system", "(continuing turn for teammate message(s))", "inbox");
|
|
1355
|
+
await runOneTurn("", [], abortController.signal);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
catch (error) {
|
|
1359
|
+
finalizeStreaming();
|
|
1360
|
+
if (isTurnInterruptedError(error)) {
|
|
1361
|
+
pushMessage("system", "Stopped current turn. Session context preserved.", "stop");
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
pushMessage("error", error instanceof Error ? error.message : String(error), "error");
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
finally {
|
|
1368
|
+
finalizeStreaming();
|
|
1369
|
+
persistCurrentSession();
|
|
1370
|
+
activeTurnAbortRef.current = null;
|
|
1371
|
+
stopRequestedRef.current = false;
|
|
1372
|
+
setBusy(false);
|
|
1373
|
+
busyRef.current = false;
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
// P1:注册 onSend("lead") listener。teammate / 工具调用 send(to=lead) 后,
|
|
1377
|
+
// 写盘成功立刻触发此处回调;如果 lead 不在跑(busyRef=false)就主动开一轮。
|
|
1378
|
+
// useEffect 空依赖:只在挂载时注册一次,卸载时 unregister。
|
|
1379
|
+
useEffect(() => {
|
|
1380
|
+
const unregister = messageBus.onSend("lead", () => {
|
|
1381
|
+
void triggerLeadAutoTurn();
|
|
1382
|
+
});
|
|
1383
|
+
return unregister;
|
|
1384
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1385
|
+
}, []);
|
|
1386
|
+
const submitQuery = async (query, attachments = []) => {
|
|
1387
|
+
const trimmed = query.trim();
|
|
1388
|
+
if ((!trimmed && attachments.length === 0) || busy) {
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const command = attachments.length > 0 ? null : normalizeCommand(trimmed);
|
|
1392
|
+
const skillSlashInvocation = command ? null : parseSkillSlashInvocation(trimmed);
|
|
1393
|
+
const hasSelectedModel = userHasChosenModel && Boolean(currentResolved);
|
|
1394
|
+
if (["q", "exit"].includes(trimmed.toLowerCase()) || command === "exit") {
|
|
1395
|
+
requestExit();
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (!hasSelectedModel && submissionNeedsSelectedModel(trimmed, Boolean(skillSlashInvocation))) {
|
|
1399
|
+
pushMessage("error", "No model selected. Use /model to select one.", "model");
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (command) {
|
|
1403
|
+
if (command === "help") {
|
|
1404
|
+
pushMessage("system", helpMessage(), "help");
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
if (command === "status") {
|
|
1408
|
+
await ensureMcpInitialized();
|
|
1409
|
+
await refreshCurrentAuth();
|
|
1410
|
+
pushMessage("system", await sessionStatus(agentStateRef.current, totalTokenUsage), "status");
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
if (command === "usage") {
|
|
1414
|
+
/**
|
|
1415
|
+
* `/usage` 走的是 ChatGPT backend `/wham/usage`,所以前置条件是:
|
|
1416
|
+
* 1. 当前 provider 必须是 openai;
|
|
1417
|
+
* 2. 必须以 oauth 模式登录(API key 模式拿不到订阅维度的额度信息)。
|
|
1418
|
+
*
|
|
1419
|
+
* 为什么这里要先 refreshCurrentAuth:
|
|
1420
|
+
* - 用户可能上次登录后过了好几天才用 /usage,access_token 大概率已经过期;
|
|
1421
|
+
* - 这一步会用 refresh_token 主动续期,并把新的 token 写回 credentials 文件,
|
|
1422
|
+
* 后面真正请求 /wham/usage 时就不会再收到 401。
|
|
1423
|
+
*/
|
|
1424
|
+
if (currentResolved.providerName !== "openai") {
|
|
1425
|
+
pushMessage("error", `/usage 仅支持 openai provider,当前 provider 是 "${currentResolved.providerName}"。请先 /provider openai。`, "usage");
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
await refreshCurrentAuth();
|
|
1429
|
+
const credentials = currentAuthState?.oauth;
|
|
1430
|
+
if (currentAuthState?.authMode !== "oauth" || !credentials?.access_token) {
|
|
1431
|
+
pushMessage("error", "/usage 需要 openai oauth 登录,未检测到有效凭据。请先 /login openai。", "usage");
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
try {
|
|
1435
|
+
const usage = await fetchOpenAIUsage(credentials);
|
|
1436
|
+
pushMessage("system", formatUsageReport(usage), "usage");
|
|
1437
|
+
}
|
|
1438
|
+
catch (error) {
|
|
1439
|
+
if (error instanceof UsageRequestError) {
|
|
1440
|
+
// status + body 一起带出来,方便用户区分是 token 过期(401)、账号未启用 codex(403)
|
|
1441
|
+
// 还是 backend 临时故障(5xx)。body 截断到 200 字符避免刷屏。
|
|
1442
|
+
const bodyPreview = error.body
|
|
1443
|
+
? ` body=${error.body.slice(0, 200)}${error.body.length > 200 ? "..." : ""}`
|
|
1444
|
+
: "";
|
|
1445
|
+
pushMessage("error", `${error.message}${error.status ? ` (status=${error.status})` : ""}${bodyPreview}`, "usage");
|
|
1446
|
+
}
|
|
1447
|
+
else {
|
|
1448
|
+
pushMessage("error", `获取订阅用量失败:${error instanceof Error ? error.message : String(error)}`, "usage");
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
if (command.startsWith("mcp")) {
|
|
1454
|
+
const mcpArgs = command.slice(3).trim();
|
|
1455
|
+
try {
|
|
1456
|
+
if (!mcpArgs) {
|
|
1457
|
+
await ensureMcpInitialized();
|
|
1458
|
+
pushMessage("system", mcpManager.formatStatusReport(), "mcp");
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (mcpArgs === "refresh") {
|
|
1462
|
+
await refreshMcpFromSettings();
|
|
1463
|
+
agentConfig = createAgentConfig(currentResolved, currentAuthState);
|
|
1464
|
+
pushMessage("system", mcpManager.formatStatusReport(), "mcp");
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
if (mcpArgs.startsWith("refresh ")) {
|
|
1468
|
+
const serverName = mcpArgs.slice("refresh ".length).trim();
|
|
1469
|
+
if (!serverName) {
|
|
1470
|
+
pushMessage("error", 'Usage: /mcp refresh <name>', "mcp");
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
await refreshMcpFromSettings(serverName);
|
|
1474
|
+
agentConfig = createAgentConfig(currentResolved, currentAuthState);
|
|
1475
|
+
pushMessage("system", mcpManager.formatStatusReport(), "mcp");
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
pushMessage("error", 'Unknown MCP command. Use "/mcp" or "/mcp refresh [name]".', "mcp");
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
catch (error) {
|
|
1482
|
+
pushMessage("error", error instanceof Error ? error.message : String(error), "mcp");
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (command === "team") {
|
|
1487
|
+
// P1:formatTeamStatus 已异步化(并发查询每个成员的未读数)。
|
|
1488
|
+
pushMessage("system", await teammateManager.formatTeamStatus(), "team");
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (command === "tasks") {
|
|
1492
|
+
pushMessage("system", await taskManager.list(), "tasks");
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (command.startsWith("task")) {
|
|
1496
|
+
const taskArg = command.slice(4).trim();
|
|
1497
|
+
if (!taskArg) {
|
|
1498
|
+
pushMessage("error", 'Usage: "/task <id>"', "task");
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const taskId = Number(taskArg);
|
|
1502
|
+
if (!Number.isInteger(taskId) || taskId <= 0) {
|
|
1503
|
+
pushMessage("error", `Invalid task id: "${taskArg}"`, "task");
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const task = await taskManager.getTask(taskId);
|
|
1507
|
+
if (!task) {
|
|
1508
|
+
pushMessage("error", `Task not found: #${taskId}`, "task");
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
// P1:删除 thread 事件展示。MessageBus.readThread 已废弃;
|
|
1512
|
+
// P3 协议消息阶段会用独立机制(不再复用 mailbox)做事件审计。
|
|
1513
|
+
pushMessage("system", await taskManager.formatTask(taskId), "task");
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (command === "inbox") {
|
|
1517
|
+
// P1:/inbox 改为只读视图(含已读+未读全部历史),便于调试。
|
|
1518
|
+
// 自动注入由 runOneTurn 在新一轮开始时处理,不再需要用户手动 drain。
|
|
1519
|
+
const all = await messageBus.readAll("lead");
|
|
1520
|
+
const formatted = all.length === 0
|
|
1521
|
+
? "(empty inbox)"
|
|
1522
|
+
: all
|
|
1523
|
+
.map((m) => `[${m.timestamp}] ${m.from} ${m.read ? "(read)" : "(UNREAD)"}\n${m.text}`)
|
|
1524
|
+
.join("\n\n");
|
|
1525
|
+
pushMessage("system", formatted, "inbox");
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (command === "new") {
|
|
1529
|
+
resetConversation();
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (command.startsWith("resume")) {
|
|
1533
|
+
const sessionArg = command.slice(6).trim();
|
|
1534
|
+
if (!sessionArg) {
|
|
1535
|
+
pushMessage("system", formatRecentSessions(agentStateRef.current.sessionId), "resume");
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
const snapshot = loadSession(WORKDIR, sessionArg);
|
|
1539
|
+
if (!snapshot) {
|
|
1540
|
+
pushMessage("error", `Session not found: "${sessionArg}"`, "resume");
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
if (snapshot.providerName && snapshot.model) {
|
|
1544
|
+
try {
|
|
1545
|
+
ensureConfig(snapshot.providerName, snapshot.model);
|
|
1546
|
+
setUserHasChosenModel(true);
|
|
1547
|
+
}
|
|
1548
|
+
catch (error) {
|
|
1549
|
+
pushMessage("error", `Restored session state, but could not switch model to ${snapshot.providerName}/${snapshot.model}: ${error instanceof Error ? error.message : String(error)}`, "resume");
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
resetConversation(snapshot);
|
|
1553
|
+
pushMessage("system", `Resumed session ${snapshot.state.sessionId}.`, "resume");
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
if (command === "compact") {
|
|
1557
|
+
const state = agentStateRef.current;
|
|
1558
|
+
if (agentConfig.apiMode === "chat-completions") {
|
|
1559
|
+
const before = estimateTokens(state.chatHistory);
|
|
1560
|
+
pushMessage("system", "Compacting conversation history...", "compact");
|
|
1561
|
+
const compacted = await autoCompact(agentConfig.client, agentConfig.model, state.chatHistory);
|
|
1562
|
+
state.chatHistory.length = 0;
|
|
1563
|
+
state.chatHistory.push(...compacted.messages);
|
|
1564
|
+
state.compactCount += 1;
|
|
1565
|
+
const after = estimateTokens(state.chatHistory);
|
|
1566
|
+
pushMessage("system", `Compacted: ~${before} → ~${after} tokens`, "compact");
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
const before = estimateTokens(state.responseHistory);
|
|
1570
|
+
if (state.responseHistory.length > 0) {
|
|
1571
|
+
pushMessage("system", "Compacting Responses API context chain...", "compact");
|
|
1572
|
+
const compacted = await autoCompactResponseHistory(agentConfig.client, agentConfig.model, state.responseHistory);
|
|
1573
|
+
state.responseHistory = compacted.messages;
|
|
1574
|
+
state.pendingCompactedContext = agentConfig.supportsPreviousResponseId
|
|
1575
|
+
? compacted.continuationMessage
|
|
1576
|
+
: undefined;
|
|
1577
|
+
}
|
|
1578
|
+
state.previousResponseId = undefined;
|
|
1579
|
+
state.compactCount += 1;
|
|
1580
|
+
const after = estimateTokens(state.responseHistory);
|
|
1581
|
+
pushMessage("system", `Responses context compacted: ~${before} → ~${after} tokens`, "compact");
|
|
1582
|
+
}
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
if (command.startsWith("provider")) {
|
|
1586
|
+
const providerArg = command.slice(8).trim();
|
|
1587
|
+
reloadSettings();
|
|
1588
|
+
const providers = getProviderNames();
|
|
1589
|
+
if (!providerArg) {
|
|
1590
|
+
if (providers.length === 0) {
|
|
1591
|
+
pushMessage("system", `No providers configured.\nEdit ${getSettingsPath()} to add providers.`, "provider");
|
|
1592
|
+
}
|
|
1593
|
+
else {
|
|
1594
|
+
const lines = providers.map((p) => {
|
|
1595
|
+
const marker = p === currentResolved.providerName ? " ← active" : "";
|
|
1596
|
+
const models = getProviderModels(p);
|
|
1597
|
+
return ` ${p}${marker} [${models.join(", ")}]`;
|
|
1598
|
+
});
|
|
1599
|
+
pushMessage("system", `Available providers:\n${lines.join("\n")}\n\nUsage: /provider <name>`, "provider");
|
|
1600
|
+
}
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
if (!providers.includes(providerArg)) {
|
|
1604
|
+
pushMessage("error", `Unknown provider: "${providerArg}". Available: ${providers.join(", ") || "(none)"}`, "provider");
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
currentResolved = resolveConfig(providerArg);
|
|
1608
|
+
currentAuthState = resolveProviderAuthState(loadSettings(), currentResolved.providerName, loadCredentialsFile(getCredentialsPath()));
|
|
1609
|
+
agentConfig = createAgentConfig(currentResolved, currentAuthState);
|
|
1610
|
+
setUserHasChosenModel(true);
|
|
1611
|
+
pushMessage("system", `Switched to provider "${providerArg}"\nmodel=${currentResolved.model} models=[${currentResolved.availableModels.join(", ")}]`, "provider");
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (command.startsWith("model")) {
|
|
1615
|
+
const modelArg = command.slice(5).trim();
|
|
1616
|
+
if (!modelArg) {
|
|
1617
|
+
// Show interactive model selection UI
|
|
1618
|
+
modelChoices.current = buildModelChoices();
|
|
1619
|
+
// Pre-select the currently active model
|
|
1620
|
+
const activeIdx = modelChoices.current.findIndex((c) => c.modelId === currentResolved.model && c.provider === currentResolved.providerName);
|
|
1621
|
+
setModelSelectionIndex(activeIdx >= 0 ? activeIdx : 0);
|
|
1622
|
+
setModelSelected(false);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
// Direct model switch by name — search across all providers
|
|
1626
|
+
const allChoices = buildModelChoices();
|
|
1627
|
+
const match = allChoices.find((c) => c.modelId === modelArg);
|
|
1628
|
+
if (!match) {
|
|
1629
|
+
pushMessage("error", `Unknown model: "${modelArg}". Use /model to see available models.`, "model");
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
ensureConfig(match.provider, match.modelId);
|
|
1633
|
+
setUserHasChosenModel(true);
|
|
1634
|
+
pushMessage("system", `Switched to model "${match.displayName}" (provider: ${match.provider})`, "model");
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
if (command.startsWith("login")) {
|
|
1638
|
+
const providerArg = command.slice(5).trim() || currentResolved.providerName;
|
|
1639
|
+
const settings = loadSettings();
|
|
1640
|
+
const provider = settings.providers[providerArg];
|
|
1641
|
+
if (!provider) {
|
|
1642
|
+
pushMessage("error", `Unknown provider: "${providerArg}"`, "login");
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
if (providerArg !== "openai" || provider.auth?.type !== "oauth") {
|
|
1646
|
+
pushMessage("error", `Provider "${providerArg}" is not configured for OAuth.`, "login");
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* OAuth login relies on a single in-memory state value for the current
|
|
1651
|
+
* browser round-trip. If `/login` is triggered twice before the first
|
|
1652
|
+
* callback returns, the second invocation replaces the expected state and
|
|
1653
|
+
* the first browser redirect is rejected as a mismatch. This guard keeps
|
|
1654
|
+
* the CSRF check intact while preventing overlapping login attempts.
|
|
1655
|
+
*/
|
|
1656
|
+
if (loginInFlightRef.current) {
|
|
1657
|
+
pushMessage("system", "OpenAI OAuth login is already in progress.", "login");
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
pushMessage("system", "Starting OpenAI OAuth login. A browser URL will be shown below.", "login");
|
|
1661
|
+
loginInFlightRef.current = true;
|
|
1662
|
+
try {
|
|
1663
|
+
const result = await startOpenAILogin({
|
|
1664
|
+
openUrl: (url) => {
|
|
1665
|
+
pushMessage("system", `Open this URL to continue login:\n${url}`, "login");
|
|
1666
|
+
},
|
|
1667
|
+
});
|
|
1668
|
+
const credentials = loadCredentialsFile(getCredentialsPath());
|
|
1669
|
+
await writeCredentialsFile(getCredentialsPath(), {
|
|
1670
|
+
providers: {
|
|
1671
|
+
...credentials.providers,
|
|
1672
|
+
[providerArg]: {
|
|
1673
|
+
...result.credentials,
|
|
1674
|
+
email: credentials.providers[providerArg]?.email,
|
|
1675
|
+
},
|
|
1676
|
+
},
|
|
1677
|
+
});
|
|
1678
|
+
ensureConfig(providerArg, currentResolved.model);
|
|
1679
|
+
let syncedModelCount = 0;
|
|
1680
|
+
try {
|
|
1681
|
+
const discoveredModels = await listAvailableModels({
|
|
1682
|
+
accessToken: result.credentials.access_token ?? "",
|
|
1683
|
+
baseURL: provider.baseURL,
|
|
1684
|
+
});
|
|
1685
|
+
if (discoveredModels.length > 0) {
|
|
1686
|
+
await updateProviderModels(getSettingsPath(), providerArg, discoveredModels);
|
|
1687
|
+
reloadSettings();
|
|
1688
|
+
const nextModel = discoveredModels.includes(currentResolved.model)
|
|
1689
|
+
? currentResolved.model
|
|
1690
|
+
: (discoveredModels[0] ?? currentResolved.model);
|
|
1691
|
+
ensureConfig(providerArg, nextModel);
|
|
1692
|
+
syncedModelCount = discoveredModels.length;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
catch (error) {
|
|
1696
|
+
/**
|
|
1697
|
+
* Model sync is a post-login convenience step. Failing it should not
|
|
1698
|
+
* discard a valid OAuth session, so we surface the error separately
|
|
1699
|
+
* and keep the successful login result.
|
|
1700
|
+
*/
|
|
1701
|
+
pushMessage("system", `OAuth login succeeded, but model sync failed: ${error instanceof Error ? error.message : String(error)}`, "login");
|
|
1702
|
+
}
|
|
1703
|
+
pushMessage("system", syncedModelCount > 0
|
|
1704
|
+
? `Logged in to "${providerArg}" with OAuth. Synced ${syncedModelCount} models.`
|
|
1705
|
+
: `Logged in to "${providerArg}" with OAuth.`, "login");
|
|
1706
|
+
}
|
|
1707
|
+
finally {
|
|
1708
|
+
loginInFlightRef.current = false;
|
|
1709
|
+
}
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (command.startsWith("logout")) {
|
|
1713
|
+
const providerArg = command.slice(6).trim() || currentResolved.providerName;
|
|
1714
|
+
await clearProviderCredentials(getCredentialsPath(), providerArg);
|
|
1715
|
+
ensureConfig(providerArg, currentResolved.model);
|
|
1716
|
+
pushMessage("system", `Cleared OAuth credentials for "${providerArg}".`, "logout");
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
if (trimmed.startsWith("/")) {
|
|
1721
|
+
if (skillSlashInvocation) {
|
|
1722
|
+
pushMessage("user", trimmed);
|
|
1723
|
+
setBusy(true);
|
|
1724
|
+
busyRef.current = true;
|
|
1725
|
+
stopRequestedRef.current = false;
|
|
1726
|
+
const abortController = new AbortController();
|
|
1727
|
+
activeTurnAbortRef.current = abortController;
|
|
1728
|
+
try {
|
|
1729
|
+
await ensureMcpInitialized();
|
|
1730
|
+
await refreshCurrentAuth();
|
|
1731
|
+
await runAgentTurn(agentConfig, skillLoader.renderSkill(skillSlashInvocation.command, skillSlashInvocation.args), [], agentStateRef.current, bridge, { signal: abortController.signal });
|
|
1732
|
+
}
|
|
1733
|
+
catch (error) {
|
|
1734
|
+
finalizeStreaming();
|
|
1735
|
+
if (isTurnInterruptedError(error)) {
|
|
1736
|
+
const stopMessage = agentConfig.apiMode === "responses" && !error.responseId
|
|
1737
|
+
? "Stopped current turn. Prior session context was kept. Resend the interrupted prompt if you want to continue it."
|
|
1738
|
+
: "Stopped current turn. Session context preserved.";
|
|
1739
|
+
pushMessage("system", stopMessage, "stop");
|
|
1740
|
+
}
|
|
1741
|
+
else {
|
|
1742
|
+
pushMessage("error", error instanceof Error ? error.message : String(error), "error");
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
finally {
|
|
1746
|
+
finalizeStreaming();
|
|
1747
|
+
persistCurrentSession();
|
|
1748
|
+
activeTurnAbortRef.current = null;
|
|
1749
|
+
stopRequestedRef.current = false;
|
|
1750
|
+
setBusy(false);
|
|
1751
|
+
busyRef.current = false;
|
|
1752
|
+
}
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
pushMessage("error", `Unknown command: ${trimmed}. Try help or /help`, "error");
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
pushMessage("user", trimmed);
|
|
1759
|
+
setBusy(true);
|
|
1760
|
+
busyRef.current = true;
|
|
1761
|
+
stopRequestedRef.current = false;
|
|
1762
|
+
const abortController = new AbortController();
|
|
1763
|
+
activeTurnAbortRef.current = abortController;
|
|
1764
|
+
try {
|
|
1765
|
+
await ensureMcpInitialized();
|
|
1766
|
+
await refreshCurrentAuth();
|
|
1767
|
+
// P1:用户主动提交时合并未读邮件 + 用户 query 一起注入。
|
|
1768
|
+
await runOneTurn(trimmed, attachments, abortController.signal);
|
|
1769
|
+
// 严格 CC 自动续轮:邮箱有未读就持续开新轮,直到清空或被 abort。
|
|
1770
|
+
// 为什么不加熔断:北哥要求严格对齐 CC,CC 自身也无熔断。
|
|
1771
|
+
// 安全网仍在:用户随时可 Ctrl+C / Esc 触发 abortController。
|
|
1772
|
+
while (!abortController.signal.aborted
|
|
1773
|
+
&& (await messageBus.unreadCount("lead")) > 0) {
|
|
1774
|
+
pushMessage("system", "(continuing turn for teammate message(s))", "inbox");
|
|
1775
|
+
await runOneTurn("", [], abortController.signal);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
catch (error) {
|
|
1779
|
+
finalizeStreaming();
|
|
1780
|
+
if (isTurnInterruptedError(error)) {
|
|
1781
|
+
const stopMessage = agentConfig.apiMode === "responses" && !error.responseId
|
|
1782
|
+
? "Stopped current turn. Prior session context was kept. Resend the interrupted prompt if you want to continue it."
|
|
1783
|
+
: "Stopped current turn. Session context preserved.";
|
|
1784
|
+
pushMessage("system", stopMessage, "stop");
|
|
1785
|
+
}
|
|
1786
|
+
else {
|
|
1787
|
+
pushMessage("error", error instanceof Error ? error.message : String(error), "error");
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
finally {
|
|
1791
|
+
finalizeStreaming();
|
|
1792
|
+
persistCurrentSession();
|
|
1793
|
+
activeTurnAbortRef.current = null;
|
|
1794
|
+
stopRequestedRef.current = false;
|
|
1795
|
+
setBusy(false);
|
|
1796
|
+
busyRef.current = false;
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
useInput((input, key) => {
|
|
1800
|
+
if (key.ctrl && input === "c") {
|
|
1801
|
+
requestExit();
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
if ((key.meta || key.ctrl) && input.toLowerCase() === "v" && !busy && trusted === true && modelSelected) {
|
|
1805
|
+
void importClipboardImage();
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
if (trusted === null) {
|
|
1809
|
+
handleTrustInput(input, key);
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
if (!modelSelected) {
|
|
1813
|
+
const choices = modelChoices.current;
|
|
1814
|
+
const count = choices.length;
|
|
1815
|
+
if (count === 0)
|
|
1816
|
+
return;
|
|
1817
|
+
if (key.upArrow || input === "k") {
|
|
1818
|
+
setModelSelectionIndex((c) => (c - 1 + count) % count);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
if (key.downArrow || input === "j") {
|
|
1822
|
+
setModelSelectionIndex((c) => (c + 1) % count);
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
if (key.return) {
|
|
1826
|
+
acceptModelSelection(modelSelectionIndex);
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if (key.escape) {
|
|
1830
|
+
setModelSelected(true);
|
|
1831
|
+
if (!currentResolved)
|
|
1832
|
+
ensureConfig();
|
|
1833
|
+
messagesRef.current = [headerItem()];
|
|
1834
|
+
setMessages(messagesRef.current);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
if (pendingApproval) {
|
|
1840
|
+
const optionCount = 3;
|
|
1841
|
+
const confirm = (index) => {
|
|
1842
|
+
if (index === 0)
|
|
1843
|
+
resolveApproval("approved");
|
|
1844
|
+
else if (index === 1)
|
|
1845
|
+
resolveApproval("approved", pendingApproval.name);
|
|
1846
|
+
else
|
|
1847
|
+
resolveApproval("rejected");
|
|
1848
|
+
};
|
|
1849
|
+
if (key.escape) {
|
|
1850
|
+
resolveApproval("rejected");
|
|
1851
|
+
}
|
|
1852
|
+
else if (key.upArrow || input === "k") {
|
|
1853
|
+
setApprovalSelection((c) => (c - 1 + optionCount) % optionCount);
|
|
1854
|
+
}
|
|
1855
|
+
else if (key.downArrow || input === "j") {
|
|
1856
|
+
setApprovalSelection((c) => (c + 1) % optionCount);
|
|
1857
|
+
}
|
|
1858
|
+
else if (input === "1" || input === "2" || input === "3") {
|
|
1859
|
+
confirm(Number(input) - 1);
|
|
1860
|
+
}
|
|
1861
|
+
else if (key.return) {
|
|
1862
|
+
confirm(approvalSelection);
|
|
1863
|
+
}
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
if (pendingQuestion) {
|
|
1867
|
+
const question = pendingQuestion[questionIndex];
|
|
1868
|
+
if (!question) {
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
const optionCount = question.options.length;
|
|
1872
|
+
const multi = Boolean(question.multiSelect);
|
|
1873
|
+
// Persist `next` selections, then advance to the next question or finish.
|
|
1874
|
+
const commitAndAdvance = (next) => {
|
|
1875
|
+
if (questionIndex + 1 < pendingQuestion.length) {
|
|
1876
|
+
setQuestionSelections(next);
|
|
1877
|
+
setQuestionIndex(questionIndex + 1);
|
|
1878
|
+
setQuestionCursor(0);
|
|
1879
|
+
}
|
|
1880
|
+
else {
|
|
1881
|
+
resolveQuestion(next);
|
|
1882
|
+
}
|
|
1883
|
+
};
|
|
1884
|
+
if (key.escape) {
|
|
1885
|
+
// Skip the rest: answered questions keep their picks, unanswered stay empty.
|
|
1886
|
+
resolveQuestion(questionSelections.map((selection) => selection ?? []));
|
|
1887
|
+
}
|
|
1888
|
+
else if (key.upArrow || input === "k") {
|
|
1889
|
+
setQuestionCursor((c) => (c - 1 + optionCount) % optionCount);
|
|
1890
|
+
}
|
|
1891
|
+
else if (key.downArrow || input === "j") {
|
|
1892
|
+
setQuestionCursor((c) => (c + 1) % optionCount);
|
|
1893
|
+
}
|
|
1894
|
+
else if (multi && input === " ") {
|
|
1895
|
+
setQuestionSelections((prev) => {
|
|
1896
|
+
const next = prev.map((selection) => [...selection]);
|
|
1897
|
+
const label = question.options[questionCursor].label;
|
|
1898
|
+
const current = next[questionIndex] ?? [];
|
|
1899
|
+
const at = current.indexOf(label);
|
|
1900
|
+
if (at >= 0)
|
|
1901
|
+
current.splice(at, 1);
|
|
1902
|
+
else
|
|
1903
|
+
current.push(label);
|
|
1904
|
+
next[questionIndex] = current;
|
|
1905
|
+
return next;
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
else if (!multi && /^[1-9]$/.test(input) && Number(input) <= optionCount) {
|
|
1909
|
+
const next = questionSelections.map((selection) => [...selection]);
|
|
1910
|
+
next[questionIndex] = [question.options[Number(input) - 1].label];
|
|
1911
|
+
commitAndAdvance(next);
|
|
1912
|
+
}
|
|
1913
|
+
else if (key.return) {
|
|
1914
|
+
const next = questionSelections.map((selection) => [...selection]);
|
|
1915
|
+
if (!multi) {
|
|
1916
|
+
next[questionIndex] = [question.options[questionCursor].label];
|
|
1917
|
+
}
|
|
1918
|
+
commitAndAdvance(next);
|
|
1919
|
+
}
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
if (busy) {
|
|
1923
|
+
if (key.escape) {
|
|
1924
|
+
requestActiveTurnStop();
|
|
1925
|
+
}
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
const submittedValue = getSubmittedValueFromInput(inputValue, input, key.return);
|
|
1929
|
+
if (submittedValue !== null) {
|
|
1930
|
+
handleSubmit(submittedValue);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
if (showSlashMenu) {
|
|
1934
|
+
if (key.escape) {
|
|
1935
|
+
setInputValue("");
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
if (key.upArrow) {
|
|
1939
|
+
setSlashSelection((current) => (current - 1 + slashMatches.length) % slashMatches.length);
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
if (key.downArrow) {
|
|
1943
|
+
setSlashSelection((current) => (current + 1) % slashMatches.length);
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
if (key.tab) {
|
|
1947
|
+
skipSubmitRef.current = true;
|
|
1948
|
+
setInputValue(slashMatches[activeSlashIndex]?.command ?? inputValue);
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
const handleSubmit = (value) => {
|
|
1954
|
+
// Enter 可能同时从 TextInput 和父级 useInput 两条路径冒出来。
|
|
1955
|
+
// 这里统一做去重,避免兜底提交把同一条输入执行两次。
|
|
1956
|
+
if (!submitDeduperRef.current.shouldSubmit(value)) {
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
if (showSlashMenu) {
|
|
1960
|
+
const selectedCommand = slashMatches[activeSlashIndex]?.command ?? value;
|
|
1961
|
+
setInputValue("");
|
|
1962
|
+
void submitQuery(selectedCommand);
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
if (skipSubmitRef.current) {
|
|
1966
|
+
skipSubmitRef.current = false;
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
const normalized = attachImagesFromText(value);
|
|
1970
|
+
const attachments = [...pendingAttachments, ...normalized.newlyAttached];
|
|
1971
|
+
setInputValue("");
|
|
1972
|
+
clearPendingAttachments();
|
|
1973
|
+
void submitQuery(normalized.value, attachments);
|
|
1974
|
+
};
|
|
1975
|
+
const innerWidth = Math.max(0, Math.min(terminalSize.columns - 2, 110));
|
|
1976
|
+
// Determine which overlay to show (trust / model selection / none)
|
|
1977
|
+
const showTrustPrompt = trusted === null;
|
|
1978
|
+
const showModelSelect = !showTrustPrompt && !modelSelected;
|
|
1979
|
+
const showMainUi = !showTrustPrompt && modelSelected;
|
|
1980
|
+
/**
|
|
1981
|
+
* 历史消息交给 Ink 的 <Static> 输出到终端 scrollback,
|
|
1982
|
+
* 进入滚动缓冲区后即可由终端原生支持鼠标滚轮与拖动复制。
|
|
1983
|
+
* 流式中的消息不放进 static(delta 会频繁变动),而是在底部动态区渲染,
|
|
1984
|
+
* 流式结束后 streamingId 变为 undefined,该消息自动归入 static 列表。
|
|
1985
|
+
*/
|
|
1986
|
+
const liveMessage = streamingId ? messages.find((m) => m.id === streamingId) : undefined;
|
|
1987
|
+
const staticMessages = streamingId ? messages.filter((m) => m.id !== streamingId) : messages;
|
|
1988
|
+
useEffect(() => {
|
|
1989
|
+
if (trusted !== true || !modelSelected || !currentResolved || startupMcpReportShownRef.current) {
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
startupMcpReportShownRef.current = true;
|
|
1993
|
+
let cancelled = false;
|
|
1994
|
+
void (async () => {
|
|
1995
|
+
try {
|
|
1996
|
+
await ensureMcpInitialized();
|
|
1997
|
+
if (cancelled) {
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
pushMessage("system", mcpManager.formatStartupReport(), "mcp");
|
|
2001
|
+
}
|
|
2002
|
+
catch (error) {
|
|
2003
|
+
if (cancelled) {
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
pushMessage("error", error instanceof Error ? error.message : String(error), "mcp");
|
|
2007
|
+
}
|
|
2008
|
+
})();
|
|
2009
|
+
return () => {
|
|
2010
|
+
cancelled = true;
|
|
2011
|
+
};
|
|
2012
|
+
}, [trusted, modelSelected, userHasChosenModel]);
|
|
2013
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [showTrustPrompt ? (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: "blue", wrap: "truncate", children: borderRule(innerWidth) }), _jsxs(Text, { color: "blue", children: ["xbcode ", _jsx(Text, { color: "gray", children: "CLI" })] }), _jsxs(Text, { color: "gray", wrap: "truncate", children: ["workspace ", WORKDIR] }), _jsx(Text, { color: "blue", wrap: "truncate", children: borderRule(innerWidth) })] }), _jsx(TrustPrompt, { selectedIndex: trustSelection })] })) : null, showModelSelect ? (_jsx(ModelSelectPrompt, { choices: modelChoices.current, selectedIndex: modelSelectionIndex, activeModelId: currentResolved?.model })) : null, showMainUi ? (_jsxs(_Fragment, { children: [_jsx(Static, { items: staticMessages, children: (item) => (item.id === "header"
|
|
2014
|
+
? _jsx(WelcomePanel, { width: innerWidth, messages: messages }, item.id)
|
|
2015
|
+
: _jsx(MessageBlock, { message: item, width: innerWidth }, item.id)) }), liveMessage ? _jsx(MessageBlock, { message: liveMessage, width: innerWidth }) : null, _jsxs(Box, { flexDirection: "column", width: "100%", flexShrink: 0, children: [_jsx(Text, { color: "white", wrap: "truncate", children: borderRule(innerWidth) }), _jsx(StatusBar, { width: innerWidth, busy: busy, state: agentStateRef.current, tokenUsage: totalTokenUsage }), pendingQuestion ? (_jsx(QuestionPrompt, { questions: pendingQuestion, questionIndex: questionIndex, cursor: questionCursor, selections: questionSelections, width: innerWidth })) : pendingApproval ? (_jsx(ApprovalPrompt, { name: pendingApproval.name, lines: pendingApproval.lines, selectedIndex: approvalSelection, width: innerWidth })) : busy ? (_jsx(BusyStatusLine, { busyTick: busyTick, turnStartedAtRef: turnStartedAtRef, lastActivityAtRef: lastActivityAtRef })) : null, _jsxs(Box, { width: "100%", flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: "\u203A " }) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(TextInput, { value: inputValue, onChange: (value) => setInputValue(attachImagesFromText(value).value), onSubmit: handleSubmit, focus: !busy, showCursor: !busy }) })] }), pendingAttachments.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["Attachments (", pendingAttachments.length, ")"] }), pendingAttachments.map((attachment, index) => (_jsxs(Text, { color: "gray", children: [" ", index + 1, ". ", path.basename(attachment.path), " \u00B7 ", attachment.mimeType] }, `${attachment.path}-${index}`)))] })) : null, showSlashMenu ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [visibleSlashMenu.hiddenAbove > 0 ? (_jsxs(Text, { color: "gray", children: [" \u2191 ", visibleSlashMenu.hiddenAbove, " more"] })) : null, visibleSlashMenu.items.map(({ command, description }, index) => {
|
|
2016
|
+
const absoluteIndex = visibleSlashMenu.start + index;
|
|
2017
|
+
const selected = absoluteIndex === activeSlashIndex;
|
|
2018
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: 2, children: _jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "*" : " " }) }), _jsx(Text, { color: selected ? "cyan" : "white", children: command }), _jsxs(Text, { color: "gray", children: [" \u2013 ", description] })] }, command));
|
|
2019
|
+
}), visibleSlashMenu.hiddenBelow > 0 ? (_jsxs(Text, { color: "gray", children: [" \u2193 ", visibleSlashMenu.hiddenBelow, " more"] })) : null] })) : null, _jsx(Text, { color: "white", wrap: "truncate", children: borderRule(innerWidth) }), userHasChosenModel && currentResolved
|
|
2020
|
+
? _jsxs(Text, { color: "gray", children: [currentResolved.providerName, ": ", currentResolved.model, " | api: ", currentResolved.apiMode, " | attachments: ", pendingAttachments.length, " | ", ellipsize(getEffectiveBaseURL(currentResolved, currentAuthState), 32)] })
|
|
2021
|
+
: _jsx(Text, { color: "red", children: "No model selected. Use /model to select one." })] })] })) : null] }, `session-${sessionKey}`));
|
|
2022
|
+
}
|
|
2023
|
+
const startupResume = resolveStartupResumeState();
|
|
2024
|
+
const app = render(_jsx(CliApp, { startupResume: startupResume }), { exitOnCtrlC: false });
|
|
2025
|
+
await app.waitUntilExit();
|
|
2026
|
+
/**
|
|
2027
|
+
* Ink 的 exit() 只保证卸载 TUI,不保证 Node 进程一定结束。
|
|
2028
|
+
* 这个 CLI 在退出后仍可能保留活跃句柄(例如底层流、计时器或连接),
|
|
2029
|
+
* 导致界面看起来“没退出”。这里显式结束进程,确保 `/exit` 的行为稳定符合预期。
|
|
2030
|
+
*/
|
|
2031
|
+
process.exit(0);
|