bingocode 1.1.113 → 1.1.114
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/package.json +1 -1
- package/src/manager/CliMenuManager.tsx +1234 -1238
- package/src/manager/CliMenuUi.tsx +9 -22
- package/src/manager/TopToolbar.tsx +111 -137
|
@@ -1,1238 +1,1234 @@
|
|
|
1
|
-
//@C:M ID=M.CM.CliMenuManager;K=M;V=1.5;P=module;D=CLI;M=cli;S=main
|
|
2
|
-
import React, { useState, useEffect, useMemo } from 'react';
|
|
3
|
-
import axios from 'axios';
|
|
4
|
-
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
5
|
-
import SelectInput from 'ink-select-input';
|
|
6
|
-
import ProviderPanel from '../cli/ProviderPanel.tsx';
|
|
7
|
-
import { LogoV2 } from '../components/LogoV2/LogoV2.tsx';
|
|
8
|
-
import { CondensedLogo } from '../components/LogoV2/CondensedLogo.tsx';
|
|
9
|
-
import fs from 'fs';
|
|
10
|
-
import path from 'path';
|
|
11
|
-
import os from 'os';
|
|
12
|
-
import { ensureSingletonLocalServer } from '../server/ensureSingletonLocalServer.ts';
|
|
13
|
-
// New: Common UI elements and top toolbar
|
|
14
|
-
import { TopBar, BottomBar, Panel, Hint, Kbd, SecondaryMenu, StateDisplay, ScrollBar, truncate, safePadEnd } from '../manager/CliMenuUi.tsx';
|
|
15
|
-
import { WelcomeV2 } from '../components/LogoV2/WelcomeV2.tsx';
|
|
16
|
-
import { TopToolbar } from '../manager/TopToolbar.tsx';
|
|
17
|
-
|
|
18
|
-
// Theme switching (Hook)
|
|
19
|
-
import { useTheme } from '../components/design-system/ThemeProvider.js';
|
|
20
|
-
// Markdown rendering (Pure function, no AppStateProvider context dependency)
|
|
21
|
-
import { applyMarkdown } from '../utils/markdown.js';
|
|
22
|
-
import { Ansi } from '../ink/Ansi.js';
|
|
23
|
-
|
|
24
|
-
// Config related (using available interfaces)
|
|
25
|
-
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.ts';
|
|
26
|
-
|
|
27
|
-
// markedSessions stored in ~/.claude-cli/ fixed directory, regardless of cwd
|
|
28
|
-
const MARKED_FILE = path.join(os.homedir(), '.claude-cli', 'markedSessions.json');
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Determine if in "official" mode (no custom provider active).
|
|
32
|
-
* Logic matches ConversationService.shouldMarkManagedOAuth().
|
|
33
|
-
*/
|
|
34
|
-
function isOfficialMode(): boolean {
|
|
35
|
-
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
36
|
-
const settingsPath = path.join(configDir, 'bingo', 'settings.json');
|
|
37
|
-
try {
|
|
38
|
-
const raw = fs.readFileSync(settingsPath, 'utf-8');
|
|
39
|
-
const parsed = JSON.parse(raw) as { env?: Record<string, string> };
|
|
40
|
-
const env = parsed.env ?? {};
|
|
41
|
-
const hasProviderEnv = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']
|
|
42
|
-
.some(key => typeof env[key] === 'string' && env[key]!.trim().length > 0);
|
|
43
|
-
return !hasProviderEnv;
|
|
44
|
-
} catch {
|
|
45
|
-
return true; // Cannot read settings.json -> Treat as official mode
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Build spawn env for child process.
|
|
51
|
-
* In official mode, inject CLAUDE_CODE_ENTRYPOINT=claude-desktop + CLAUDE_CODE_OAUTH_TOKEN,
|
|
52
|
-
* so new/resumed bingocode windows can use OAuth directly.
|
|
53
|
-
*/
|
|
54
|
-
async function buildSpawnEnv(): Promise<NodeJS.ProcessEnv> {
|
|
55
|
-
const base = { ...process.env };
|
|
56
|
-
if (!isOfficialMode()) return base;
|
|
57
|
-
|
|
58
|
-
// Official mode: mark as managed-OAuth and inject OAuth token
|
|
59
|
-
base.CLAUDE_CODE_ENTRYPOINT = 'claude-desktop';
|
|
60
|
-
try {
|
|
61
|
-
const { hahaOAuthService } = await import('../server/services/hahaOAuthService.js');
|
|
62
|
-
const token = await hahaOAuthService.ensureFreshAccessToken();
|
|
63
|
-
if (token) {
|
|
64
|
-
base.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
65
|
-
} else {
|
|
66
|
-
// No valid token -> don't inject, use normal login flow
|
|
67
|
-
delete base.CLAUDE_CODE_OAUTH_TOKEN;
|
|
68
|
-
}
|
|
69
|
-
} catch {
|
|
70
|
-
delete base.CLAUDE_CODE_OAUTH_TOKEN;
|
|
71
|
-
}
|
|
72
|
-
return base;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Top height: Home fits LogoV2 + Toolbar, other pages more compact
|
|
76
|
-
const TOP_H_HOME = Number(process.env.CLI_TOP_H_HOME ||
|
|
77
|
-
const TOP_H_COMPACT = Number(process.env.CLI_TOP_H_COMPACT ||
|
|
78
|
-
// Bottom bar height
|
|
79
|
-
const BOTTOM_H = Number(process.env.CLI_BOTTOM_H || 3);
|
|
80
|
-
|
|
81
|
-
const i18nMap = {
|
|
82
|
-
zh: {
|
|
83
|
-
menu: {
|
|
84
|
-
newSession: 'New Session',
|
|
85
|
-
history: 'History',
|
|
86
|
-
provider: 'API Config',
|
|
87
|
-
settings: 'Settings',
|
|
88
|
-
about: 'About',
|
|
89
|
-
exit: 'Exit',
|
|
90
|
-
},
|
|
91
|
-
about: 'Bingo CLI - Version Info & About',
|
|
92
|
-
aboutContent: [
|
|
93
|
-
'Bingo is an AI assistant terminal client.',
|
|
94
|
-
'
|
|
95
|
-
'
|
|
96
|
-
'
|
|
97
|
-
'
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
mark: '→ Mark Session',
|
|
102
|
-
unmark: '→ Unmark Session',
|
|
103
|
-
tipsSimple: 'L Lang | ESC Back | ←→ Menu | ↩ Enter | ? Help',
|
|
104
|
-
noData: 'No data',
|
|
105
|
-
emptyHistory: 'Nothing here yet. Start a new session?',
|
|
106
|
-
deleting: 'Delete this session? (Irreversible)',
|
|
107
|
-
historyHint: 'Enter to open · j next · k first · q back',
|
|
108
|
-
helpTitle: 'Shortcuts',
|
|
109
|
-
},
|
|
110
|
-
en: {
|
|
111
|
-
menu: {
|
|
112
|
-
newSession: 'New Session',
|
|
113
|
-
history: 'Session History',
|
|
114
|
-
provider: 'API Config',
|
|
115
|
-
settings: 'Settings',
|
|
116
|
-
about: 'About',
|
|
117
|
-
exit: 'Exit',
|
|
118
|
-
},
|
|
119
|
-
about: 'Bingo CLI Terminal - Version Info & About',
|
|
120
|
-
aboutContent: [
|
|
121
|
-
'Bingo is an AI assistant terminal client.',
|
|
122
|
-
'
|
|
123
|
-
'
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
mark: '→ Mark Session',
|
|
130
|
-
unmark: '→ Unmark Session',
|
|
131
|
-
tipsSimple: 'L Lang | ESC Back | ←→ Menu | ↩ Enter | ? Help',
|
|
132
|
-
noData: 'No data',
|
|
133
|
-
emptyHistory: 'Nothing here yet. Start a new session?',
|
|
134
|
-
deleting: 'Delete this session? (Irreversible)',
|
|
135
|
-
historyHint: 'Enter to open · j next · k first · q back',
|
|
136
|
-
helpTitle: 'Shortcuts',
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const menuKeys = [
|
|
141
|
-
'newSession', 'history', 'provider', 'settings', 'about', 'exit'
|
|
142
|
-
] as const;
|
|
143
|
-
type MenuKey = typeof menuKeys[number];
|
|
144
|
-
type Lang = keyof typeof i18nMap;
|
|
145
|
-
|
|
146
|
-
//@C:F ID=F.CM.loadMarkedSessionIds;K=F;V=1.0;P=load marked ids;D=CLI;M=cli;S=init;In=;Out=Set<string>
|
|
147
|
-
function loadMarkedSessionIds(): Set<string> {
|
|
148
|
-
try {
|
|
149
|
-
const arr = JSON.parse(fs.readFileSync(MARKED_FILE, 'utf-8'));
|
|
150
|
-
return new Set(typeof arr === 'object' && Array.isArray(arr) ? arr : []);
|
|
151
|
-
} catch {
|
|
152
|
-
return new Set();
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
//@C:F ID=F.CM.saveMarkedSessionIds;K=F;V=1.1;P=save marked ids;D=CLI;M=cli;S=persist;In=Set<string>;Out=void
|
|
157
|
-
function saveMarkedSessionIds(set: Set<string>) {
|
|
158
|
-
try {
|
|
159
|
-
const dir = path.dirname(MARKED_FILE);
|
|
160
|
-
if (!fs.existsSync(dir)) {
|
|
161
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
-
}
|
|
163
|
-
fs.writeFileSync(MARKED_FILE, JSON.stringify([...set]), 'utf-8');
|
|
164
|
-
} catch (err) {
|
|
165
|
-
console.error('[saveMarkedSessionIds] Save failed:', err);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Message Entry (Aligned with backend MessageEntry)
|
|
170
|
-
type MessageEntry = {
|
|
171
|
-
id: string;
|
|
172
|
-
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result';
|
|
173
|
-
content: unknown; // string 或 ContentBlock[]
|
|
174
|
-
timestamp: string;
|
|
175
|
-
model?: string;
|
|
176
|
-
parentUuid?: string;
|
|
177
|
-
parentToolUseId?: string;
|
|
178
|
-
isSidechain?: boolean;
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
/** Extract plain text from MessageEntry.content */
|
|
182
|
-
function extractTextFromContent(content: unknown): string {
|
|
183
|
-
if (typeof content === 'string') return content;
|
|
184
|
-
if (Array.isArray(content)) {
|
|
185
|
-
return content
|
|
186
|
-
.map((block: any) => {
|
|
187
|
-
if (block.type === 'text' && typeof block.text === 'string') return block.text;
|
|
188
|
-
if (block.type === 'tool_use') return `[Tool: ${block.name || 'unknown'}]`;
|
|
189
|
-
if (block.type === 'tool_result') {
|
|
190
|
-
if (typeof block.content === 'string') return block.content;
|
|
191
|
-
if (Array.isArray(block.content)) {
|
|
192
|
-
return block.content
|
|
193
|
-
.filter((b: any) => b.type === 'text')
|
|
194
|
-
.map((b: any) => b.text)
|
|
195
|
-
.join('\n');
|
|
196
|
-
}
|
|
197
|
-
return '[Tool Result]';
|
|
198
|
-
}
|
|
199
|
-
return '';
|
|
200
|
-
})
|
|
201
|
-
.filter(Boolean)
|
|
202
|
-
.join('\n');
|
|
203
|
-
}
|
|
204
|
-
return String(content ?? '');
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
//@C:F ID=F.CM.CliMenuManager;K=F;V=1.5;P=CLI Main Menu;D=CLI;M=cli;S=main;In=;Out=JSX.Element
|
|
208
|
-
export const CliMenuManager: React.FC = () => {
|
|
209
|
-
const { stdout } = useStdout();
|
|
210
|
-
const [terminalSize, setTerminalSize] = useState({
|
|
211
|
-
columns: stdout?.columns || 80,
|
|
212
|
-
rows: stdout?.rows || 24
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
useEffect(() => {
|
|
216
|
-
const onResize = () => {
|
|
217
|
-
setTerminalSize({
|
|
218
|
-
columns: stdout?.columns || 80,
|
|
219
|
-
rows: stdout?.rows || 24
|
|
220
|
-
});
|
|
221
|
-
};
|
|
222
|
-
stdout?.on('resize', onResize);
|
|
223
|
-
return () => { stdout?.off('resize', onResize); };
|
|
224
|
-
}, [stdout]);
|
|
225
|
-
|
|
226
|
-
// Dynamic viewport
|
|
227
|
-
const VIEW_W = Number(process.env.CLI_VIEW_W || Math.min(terminalSize.columns, 96));
|
|
228
|
-
const VIEW_H = Number(process.env.CLI_VIEW_H || terminalSize.rows);
|
|
229
|
-
|
|
230
|
-
const [apiUrl, setApiUrl] = useState<string | null>(process.env.BASE_API_URL || null);
|
|
231
|
-
const [stopIfLast, setStopIfLast] = useState<null | (() => Promise<void>)>(null);
|
|
232
|
-
const [bootErr, setBootErr] = useState<string | null>(null);
|
|
233
|
-
const { exit } = useApp();
|
|
234
|
-
|
|
235
|
-
// Theme (Global Hook)
|
|
236
|
-
const [theme, setTheme] = useTheme();
|
|
237
|
-
|
|
238
|
-
// Language
|
|
239
|
-
const [lang, setLang] = useState<Lang>('en');
|
|
240
|
-
|
|
241
|
-
// Config ready probe (avoid Logo early read)
|
|
242
|
-
const [configReady, setConfigReady] = useState(false);
|
|
243
|
-
|
|
244
|
-
useEffect(() => {
|
|
245
|
-
if (configReady) {
|
|
246
|
-
try {
|
|
247
|
-
const cfg = getGlobalConfig();
|
|
248
|
-
if (cfg.language && (cfg.language === 'en' || cfg.language === 'zh')) {
|
|
249
|
-
setLang(cfg.language as Lang);
|
|
250
|
-
}
|
|
251
|
-
} catch (e) {
|
|
252
|
-
// Silently fail if config has issues
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}, [configReady]);
|
|
256
|
-
|
|
257
|
-
const t = i18nMap[lang].menu;
|
|
258
|
-
|
|
259
|
-
// Top time
|
|
260
|
-
const [nowStr, setNowStr] = useState<string>(new Date().toLocaleString('en-US', { hour12: false }));
|
|
261
|
-
useEffect(() => {
|
|
262
|
-
const id = setInterval(() => setNowStr(new Date().toLocaleString('en-US', { hour12: false })), 1000);
|
|
263
|
-
return () => clearInterval(id);
|
|
264
|
-
}, []);
|
|
265
|
-
|
|
266
|
-
// Main Menu
|
|
267
|
-
const [page, setPage] = useState<MenuKey | null>(null);
|
|
268
|
-
const menuItems = useMemo(() => menuKeys.map(key => ({ label: t[key], value: key })), [t]);
|
|
269
|
-
const [navIndex, setNavIndex] = useState(0);
|
|
270
|
-
|
|
271
|
-
// New Session
|
|
272
|
-
const [newSessionId, setNewSessionId] = useState<string | null>(null);
|
|
273
|
-
const [creating, setCreating] = useState(false);
|
|
274
|
-
const [createErr, setCreateErr] = useState<string | null>(null);
|
|
275
|
-
|
|
276
|
-
// History
|
|
277
|
-
const [loadingHist, setLoadingHist] = useState(false);
|
|
278
|
-
const [historyList, setHistoryList] = useState<any[]>([]);
|
|
279
|
-
const [historyCursor, setHistoryCursor] = useState<string | null>(null);
|
|
280
|
-
const [historyHasMore, setHistoryHasMore] = useState<boolean>(false);
|
|
281
|
-
const [histErr, setHistErr] = useState<string | null>(null);
|
|
282
|
-
const [historyMenuStage, setHistoryMenuStage] = useState<'list'|'window'|'deleteConfirm'>('list');
|
|
283
|
-
const [selectedHistory, setSelectedHistory] = useState<any|null>(null);
|
|
284
|
-
|
|
285
|
-
// History Messages
|
|
286
|
-
const [sessionMessages, setSessionMessages] = useState<MessageEntry[]>([]);
|
|
287
|
-
const [loadingMsgs, setLoadingMsgs] = useState(false);
|
|
288
|
-
const [msgsErr, setMsgsErr] = useState<string | null>(null);
|
|
289
|
-
const [msgsPage, setMsgsPage] = useState(0);
|
|
290
|
-
|
|
291
|
-
// Mark Persistence
|
|
292
|
-
const [markedSessionIds, setMarkedSessionIds] = useState<Set<string>>(new Set());
|
|
293
|
-
|
|
294
|
-
// Settings page scroll offset
|
|
295
|
-
const [settingsOffset, setSettingsOffset] = useState(0);
|
|
296
|
-
const [settingData, setSettingData] = useState<any>(null);
|
|
297
|
-
const [loadingSetting, setLoadingSetting] = useState(false);
|
|
298
|
-
const [setErr, setSetErr] = useState<string | null>(null);
|
|
299
|
-
|
|
300
|
-
// Top toolbar state
|
|
301
|
-
const [animEnabled, setAnimEnabled] = useState(true);
|
|
302
|
-
const [tipsEnabled, setTipsEnabled] = useState(true);
|
|
303
|
-
|
|
304
|
-
// Help overlay
|
|
305
|
-
const [showHelp, setShowHelp] = useState(false);
|
|
306
|
-
|
|
307
|
-
// Keyboard navigation for lists
|
|
308
|
-
const [
|
|
309
|
-
|
|
310
|
-
// Quick Resume (R)
|
|
311
|
-
const [quickResumeRequested, setQuickResumeRequested] = useState(false);
|
|
312
|
-
|
|
313
|
-
// Compute viewport
|
|
314
|
-
const TOP_H = page === null ? TOP_H_HOME : TOP_H_COMPACT;
|
|
315
|
-
const MID_H = Math.max(5, VIEW_H - TOP_H - BOTTOM_H - (page === null ? 0 : 2));
|
|
316
|
-
const MSGS_PAGE_SIZE = Math.max(1, MID_H - 2);
|
|
317
|
-
const [expandMsgs, setExpandMsgs] = useState(false);
|
|
318
|
-
|
|
319
|
-
// Boot/Reuse singleton local server (with retry)
|
|
320
|
-
useEffect(() => {
|
|
321
|
-
let mounted = true;
|
|
322
|
-
(async () => {
|
|
323
|
-
if (apiUrl) return;
|
|
324
|
-
const entry = path.resolve(import.meta.dir, '../server/index.ts');
|
|
325
|
-
const MAX_RETRIES = 3;
|
|
326
|
-
const RETRY_DELAYS = [0, 2000, 5000]; // 0s, 2s, 5s
|
|
327
|
-
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
328
|
-
if (!mounted) return;
|
|
329
|
-
if (attempt > 0) {
|
|
330
|
-
setBootErr(`Attempt ${attempt} failed, retrying in ${RETRY_DELAYS[attempt] / 1000}s...`);
|
|
331
|
-
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
|
|
332
|
-
}
|
|
333
|
-
if (!mounted) return;
|
|
334
|
-
try {
|
|
335
|
-
const handle = await ensureSingletonLocalServer({ serverEntry: entry });
|
|
336
|
-
if (!mounted) { await handle.stopIfLast(); return; }
|
|
337
|
-
setApiUrl(handle.baseUrl);
|
|
338
|
-
setStopIfLast(() => handle.stopIfLast);
|
|
339
|
-
setBootErr(null);
|
|
340
|
-
return; // Success, exit retry
|
|
341
|
-
} catch (e: any) {
|
|
342
|
-
if (attempt === MAX_RETRIES - 1) {
|
|
343
|
-
setBootErr(e.message || 'Local server failed to start');
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
})();
|
|
348
|
-
return () => { mounted = false; if (stopIfLast) stopIfLast(); };
|
|
349
|
-
}, []);
|
|
350
|
-
useEffect(() => {
|
|
351
|
-
let cancelled = false;
|
|
352
|
-
const probe = () => {
|
|
353
|
-
try {
|
|
354
|
-
getGlobalConfig();
|
|
355
|
-
if (!cancelled) setConfigReady(true);
|
|
356
|
-
} catch {
|
|
357
|
-
if (!cancelled) setTimeout(probe, 60);
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
probe();
|
|
361
|
-
return () => { cancelled = true; };
|
|
362
|
-
}, []);
|
|
363
|
-
|
|
364
|
-
// Init marks
|
|
365
|
-
useEffect(() => {
|
|
366
|
-
setMarkedSessionIds(loadMarkedSessionIds());
|
|
367
|
-
}, []);
|
|
368
|
-
|
|
369
|
-
// Page switch reset
|
|
370
|
-
useEffect(() => {
|
|
371
|
-
if (page === 'newSession') {
|
|
372
|
-
setNewSessionId(null);
|
|
373
|
-
setCreating(false);
|
|
374
|
-
setCreateErr(null);
|
|
375
|
-
}
|
|
376
|
-
if (page !== 'settings') {
|
|
377
|
-
setSettingsOffset(0);
|
|
378
|
-
}
|
|
379
|
-
// Close help overlay
|
|
380
|
-
setShowHelp(false);
|
|
381
|
-
}, [page]);
|
|
382
|
-
|
|
383
|
-
// History page entry reset
|
|
384
|
-
useEffect(() => {
|
|
385
|
-
if (page === 'history') {
|
|
386
|
-
setHistoryMenuStage('list');
|
|
387
|
-
setSelectedHistory(null);
|
|
388
|
-
setHistoryCursor(null);
|
|
389
|
-
setSessionMessages([]);
|
|
390
|
-
setMsgsErr(null);
|
|
391
|
-
setMsgsPage(0);
|
|
392
|
-
setExpandMsgs(false);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
cfg
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
cfg
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if (
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if (
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (input === '
|
|
663
|
-
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
const
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
case '
|
|
817
|
-
|
|
818
|
-
break;
|
|
819
|
-
case '
|
|
820
|
-
|
|
821
|
-
break;
|
|
822
|
-
case '
|
|
823
|
-
setHistoryMenuStage('
|
|
824
|
-
break;
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
})
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
setHistoryMenuStage('
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
<Text color="
|
|
915
|
-
<Text
|
|
916
|
-
<Text color="cyan">
|
|
917
|
-
<Text color="cyan"
|
|
918
|
-
<Text> </Text>
|
|
919
|
-
<
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
<
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
<
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
<
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
if (
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
1131
|
-
const
|
|
1132
|
-
const
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
<
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
return (
|
|
1149
|
-
<Box width={VIEW_W} height={MID_H} flexDirection="column"
|
|
1150
|
-
<
|
|
1151
|
-
|
|
1152
|
-
<
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
<
|
|
1156
|
-
|
|
1157
|
-
</
|
|
1158
|
-
</Box>
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
<Panel width={VIEW_W} height={MID_H}
|
|
1212
|
-
{renderCenter()}
|
|
1213
|
-
</Panel>
|
|
1214
|
-
)
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
);
|
|
1236
|
-
};
|
|
1237
|
-
|
|
1238
|
-
export default CliMenuManager;
|
|
1
|
+
//@C:M ID=M.CM.CliMenuManager;K=M;V=1.5;P=module;D=CLI;M=cli;S=main
|
|
2
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
5
|
+
import SelectInput from 'ink-select-input';
|
|
6
|
+
import ProviderPanel from '../cli/ProviderPanel.tsx';
|
|
7
|
+
import { LogoV2 } from '../components/LogoV2/LogoV2.tsx';
|
|
8
|
+
import { CondensedLogo } from '../components/LogoV2/CondensedLogo.tsx';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import { ensureSingletonLocalServer } from '../server/ensureSingletonLocalServer.ts';
|
|
13
|
+
// New: Common UI elements and top toolbar
|
|
14
|
+
import { TopBar, BottomBar, Panel, Hint, Kbd, SecondaryMenu, StateDisplay, ScrollBar, truncate, safePadEnd } from '../manager/CliMenuUi.tsx';
|
|
15
|
+
import { WelcomeV2 } from '../components/LogoV2/WelcomeV2.tsx';
|
|
16
|
+
import { TopToolbar } from '../manager/TopToolbar.tsx';
|
|
17
|
+
|
|
18
|
+
// Theme switching (Hook)
|
|
19
|
+
import { useTheme } from '../components/design-system/ThemeProvider.js';
|
|
20
|
+
// Markdown rendering (Pure function, no AppStateProvider context dependency)
|
|
21
|
+
import { applyMarkdown } from '../utils/markdown.js';
|
|
22
|
+
import { Ansi } from '../ink/Ansi.js';
|
|
23
|
+
|
|
24
|
+
// Config related (using available interfaces)
|
|
25
|
+
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.ts';
|
|
26
|
+
|
|
27
|
+
// markedSessions stored in ~/.claude-cli/ fixed directory, regardless of cwd
|
|
28
|
+
const MARKED_FILE = path.join(os.homedir(), '.claude-cli', 'markedSessions.json');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Determine if in "official" mode (no custom provider active).
|
|
32
|
+
* Logic matches ConversationService.shouldMarkManagedOAuth().
|
|
33
|
+
*/
|
|
34
|
+
function isOfficialMode(): boolean {
|
|
35
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
36
|
+
const settingsPath = path.join(configDir, 'bingo', 'settings.json');
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(settingsPath, 'utf-8');
|
|
39
|
+
const parsed = JSON.parse(raw) as { env?: Record<string, string> };
|
|
40
|
+
const env = parsed.env ?? {};
|
|
41
|
+
const hasProviderEnv = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']
|
|
42
|
+
.some(key => typeof env[key] === 'string' && env[key]!.trim().length > 0);
|
|
43
|
+
return !hasProviderEnv;
|
|
44
|
+
} catch {
|
|
45
|
+
return true; // Cannot read settings.json -> Treat as official mode
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build spawn env for child process.
|
|
51
|
+
* In official mode, inject CLAUDE_CODE_ENTRYPOINT=claude-desktop + CLAUDE_CODE_OAUTH_TOKEN,
|
|
52
|
+
* so new/resumed bingocode windows can use OAuth directly.
|
|
53
|
+
*/
|
|
54
|
+
async function buildSpawnEnv(): Promise<NodeJS.ProcessEnv> {
|
|
55
|
+
const base = { ...process.env };
|
|
56
|
+
if (!isOfficialMode()) return base;
|
|
57
|
+
|
|
58
|
+
// Official mode: mark as managed-OAuth and inject OAuth token
|
|
59
|
+
base.CLAUDE_CODE_ENTRYPOINT = 'claude-desktop';
|
|
60
|
+
try {
|
|
61
|
+
const { hahaOAuthService } = await import('../server/services/hahaOAuthService.js');
|
|
62
|
+
const token = await hahaOAuthService.ensureFreshAccessToken();
|
|
63
|
+
if (token) {
|
|
64
|
+
base.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
65
|
+
} else {
|
|
66
|
+
// No valid token -> don't inject, use normal login flow
|
|
67
|
+
delete base.CLAUDE_CODE_OAUTH_TOKEN;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
delete base.CLAUDE_CODE_OAUTH_TOKEN;
|
|
71
|
+
}
|
|
72
|
+
return base;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Top height: Home fits LogoV2 + Toolbar, other pages more compact
|
|
76
|
+
const TOP_H_HOME = Number(process.env.CLI_TOP_H_HOME || 9);
|
|
77
|
+
const TOP_H_COMPACT = Number(process.env.CLI_TOP_H_COMPACT || 6);
|
|
78
|
+
// Bottom bar height
|
|
79
|
+
const BOTTOM_H = Number(process.env.CLI_BOTTOM_H || 3);
|
|
80
|
+
|
|
81
|
+
const i18nMap = {
|
|
82
|
+
zh: {
|
|
83
|
+
menu: {
|
|
84
|
+
newSession: 'New Session',
|
|
85
|
+
history: 'History',
|
|
86
|
+
provider: 'API Config',
|
|
87
|
+
settings: 'Settings',
|
|
88
|
+
about: 'About',
|
|
89
|
+
exit: 'Exit',
|
|
90
|
+
},
|
|
91
|
+
about: 'Bingo CLI - Version Info & About',
|
|
92
|
+
aboutContent: [
|
|
93
|
+
'Bingo is an AI assistant terminal client.',
|
|
94
|
+
'Author: leanchy (Email: leanchy07@outlook.com)',
|
|
95
|
+
'Github: github.com/leanchy/bingo-claude-code-offline-installer',
|
|
96
|
+
'1. API Config: Press "P" or select "API Config" to set up your keys.',
|
|
97
|
+
'2. Model Slots: Configure specific models in the Provider panel.',
|
|
98
|
+
'3. Background Service: Bingo runs a local server to manage sessions.',
|
|
99
|
+
'4. Start Chat: Run `bingocode` or `claude` in any terminal to start.',
|
|
100
|
+
].join('\n'),
|
|
101
|
+
mark: '→ Mark Session',
|
|
102
|
+
unmark: '→ Unmark Session',
|
|
103
|
+
tipsSimple: 'L Lang | ESC Back | ←→ Menu | ↩ Enter | ? Help',
|
|
104
|
+
noData: 'No data',
|
|
105
|
+
emptyHistory: 'Nothing here yet. Start a new session?',
|
|
106
|
+
deleting: 'Delete this session? (Irreversible)',
|
|
107
|
+
historyHint: 'Enter to open · j next · k first · q back',
|
|
108
|
+
helpTitle: 'Shortcuts',
|
|
109
|
+
},
|
|
110
|
+
en: {
|
|
111
|
+
menu: {
|
|
112
|
+
newSession: 'New Session',
|
|
113
|
+
history: 'Session History',
|
|
114
|
+
provider: 'API Config',
|
|
115
|
+
settings: 'Settings',
|
|
116
|
+
about: 'About',
|
|
117
|
+
exit: 'Exit',
|
|
118
|
+
},
|
|
119
|
+
about: 'Bingo CLI Terminal - Version Info & About',
|
|
120
|
+
aboutContent: [
|
|
121
|
+
'Bingo is an AI assistant terminal client.',
|
|
122
|
+
'Author: leanchy (Email: leanchy07@outlook.com)',
|
|
123
|
+
'Github: github.com/leanchy/bingo-claude-code-offline-installer',
|
|
124
|
+
'1. API Config: Press "P" or select "API Config" to set up your keys.',
|
|
125
|
+
'2. Model Slots: Configure specific models in the Provider panel.',
|
|
126
|
+
'3. Background Service: Bingo runs a local server to manage sessions.',
|
|
127
|
+
'4. Start Chat: Run `bingocode` or `claude` in any terminal to start.',
|
|
128
|
+
].join('\n'),
|
|
129
|
+
mark: '→ Mark Session',
|
|
130
|
+
unmark: '→ Unmark Session',
|
|
131
|
+
tipsSimple: 'L Lang | ESC Back | ←→ Menu | ↩ Enter | ? Help',
|
|
132
|
+
noData: 'No data',
|
|
133
|
+
emptyHistory: 'Nothing here yet. Start a new session?',
|
|
134
|
+
deleting: 'Delete this session? (Irreversible)',
|
|
135
|
+
historyHint: 'Enter to open · j next · k first · q back',
|
|
136
|
+
helpTitle: 'Shortcuts',
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const menuKeys = [
|
|
141
|
+
'newSession', 'history', 'provider', 'settings', 'about', 'exit'
|
|
142
|
+
] as const;
|
|
143
|
+
type MenuKey = typeof menuKeys[number];
|
|
144
|
+
type Lang = keyof typeof i18nMap;
|
|
145
|
+
|
|
146
|
+
//@C:F ID=F.CM.loadMarkedSessionIds;K=F;V=1.0;P=load marked ids;D=CLI;M=cli;S=init;In=;Out=Set<string>
|
|
147
|
+
function loadMarkedSessionIds(): Set<string> {
|
|
148
|
+
try {
|
|
149
|
+
const arr = JSON.parse(fs.readFileSync(MARKED_FILE, 'utf-8'));
|
|
150
|
+
return new Set(typeof arr === 'object' && Array.isArray(arr) ? arr : []);
|
|
151
|
+
} catch {
|
|
152
|
+
return new Set();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
//@C:F ID=F.CM.saveMarkedSessionIds;K=F;V=1.1;P=save marked ids;D=CLI;M=cli;S=persist;In=Set<string>;Out=void
|
|
157
|
+
function saveMarkedSessionIds(set: Set<string>) {
|
|
158
|
+
try {
|
|
159
|
+
const dir = path.dirname(MARKED_FILE);
|
|
160
|
+
if (!fs.existsSync(dir)) {
|
|
161
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
}
|
|
163
|
+
fs.writeFileSync(MARKED_FILE, JSON.stringify([...set]), 'utf-8');
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error('[saveMarkedSessionIds] Save failed:', err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Message Entry (Aligned with backend MessageEntry)
|
|
170
|
+
type MessageEntry = {
|
|
171
|
+
id: string;
|
|
172
|
+
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result';
|
|
173
|
+
content: unknown; // string 或 ContentBlock[]
|
|
174
|
+
timestamp: string;
|
|
175
|
+
model?: string;
|
|
176
|
+
parentUuid?: string;
|
|
177
|
+
parentToolUseId?: string;
|
|
178
|
+
isSidechain?: boolean;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Extract plain text from MessageEntry.content */
|
|
182
|
+
function extractTextFromContent(content: unknown): string {
|
|
183
|
+
if (typeof content === 'string') return content;
|
|
184
|
+
if (Array.isArray(content)) {
|
|
185
|
+
return content
|
|
186
|
+
.map((block: any) => {
|
|
187
|
+
if (block.type === 'text' && typeof block.text === 'string') return block.text;
|
|
188
|
+
if (block.type === 'tool_use') return `[Tool: ${block.name || 'unknown'}]`;
|
|
189
|
+
if (block.type === 'tool_result') {
|
|
190
|
+
if (typeof block.content === 'string') return block.content;
|
|
191
|
+
if (Array.isArray(block.content)) {
|
|
192
|
+
return block.content
|
|
193
|
+
.filter((b: any) => b.type === 'text')
|
|
194
|
+
.map((b: any) => b.text)
|
|
195
|
+
.join('\n');
|
|
196
|
+
}
|
|
197
|
+
return '[Tool Result]';
|
|
198
|
+
}
|
|
199
|
+
return '';
|
|
200
|
+
})
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.join('\n');
|
|
203
|
+
}
|
|
204
|
+
return String(content ?? '');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
//@C:F ID=F.CM.CliMenuManager;K=F;V=1.5;P=CLI Main Menu;D=CLI;M=cli;S=main;In=;Out=JSX.Element
|
|
208
|
+
export const CliMenuManager: React.FC = () => {
|
|
209
|
+
const { stdout } = useStdout();
|
|
210
|
+
const [terminalSize, setTerminalSize] = useState({
|
|
211
|
+
columns: stdout?.columns || 80,
|
|
212
|
+
rows: stdout?.rows || 24
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const onResize = () => {
|
|
217
|
+
setTerminalSize({
|
|
218
|
+
columns: stdout?.columns || 80,
|
|
219
|
+
rows: stdout?.rows || 24
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
stdout?.on('resize', onResize);
|
|
223
|
+
return () => { stdout?.off('resize', onResize); };
|
|
224
|
+
}, [stdout]);
|
|
225
|
+
|
|
226
|
+
// Dynamic viewport
|
|
227
|
+
const VIEW_W = Number(process.env.CLI_VIEW_W || Math.min(terminalSize.columns, 96));
|
|
228
|
+
const VIEW_H = Number(process.env.CLI_VIEW_H || terminalSize.rows);
|
|
229
|
+
|
|
230
|
+
const [apiUrl, setApiUrl] = useState<string | null>(process.env.BASE_API_URL || null);
|
|
231
|
+
const [stopIfLast, setStopIfLast] = useState<null | (() => Promise<void>)>(null);
|
|
232
|
+
const [bootErr, setBootErr] = useState<string | null>(null);
|
|
233
|
+
const { exit } = useApp();
|
|
234
|
+
|
|
235
|
+
// Theme (Global Hook)
|
|
236
|
+
const [theme, setTheme] = useTheme();
|
|
237
|
+
|
|
238
|
+
// Language
|
|
239
|
+
const [lang, setLang] = useState<Lang>('en');
|
|
240
|
+
|
|
241
|
+
// Config ready probe (avoid Logo early read)
|
|
242
|
+
const [configReady, setConfigReady] = useState(false);
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (configReady) {
|
|
246
|
+
try {
|
|
247
|
+
const cfg = getGlobalConfig();
|
|
248
|
+
if (cfg.language && (cfg.language === 'en' || cfg.language === 'zh')) {
|
|
249
|
+
setLang(cfg.language as Lang);
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {
|
|
252
|
+
// Silently fail if config has issues
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}, [configReady]);
|
|
256
|
+
|
|
257
|
+
const t = i18nMap[lang].menu;
|
|
258
|
+
|
|
259
|
+
// Top time
|
|
260
|
+
const [nowStr, setNowStr] = useState<string>(new Date().toLocaleString('en-US', { hour12: false }));
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
const id = setInterval(() => setNowStr(new Date().toLocaleString('en-US', { hour12: false })), 1000);
|
|
263
|
+
return () => clearInterval(id);
|
|
264
|
+
}, []);
|
|
265
|
+
|
|
266
|
+
// Main Menu
|
|
267
|
+
const [page, setPage] = useState<MenuKey | null>(null);
|
|
268
|
+
const menuItems = useMemo(() => menuKeys.map(key => ({ label: t[key], value: key })), [t]);
|
|
269
|
+
const [navIndex, setNavIndex] = useState(0);
|
|
270
|
+
|
|
271
|
+
// New Session
|
|
272
|
+
const [newSessionId, setNewSessionId] = useState<string | null>(null);
|
|
273
|
+
const [creating, setCreating] = useState(false);
|
|
274
|
+
const [createErr, setCreateErr] = useState<string | null>(null);
|
|
275
|
+
|
|
276
|
+
// History
|
|
277
|
+
const [loadingHist, setLoadingHist] = useState(false);
|
|
278
|
+
const [historyList, setHistoryList] = useState<any[]>([]);
|
|
279
|
+
const [historyCursor, setHistoryCursor] = useState<string | null>(null);
|
|
280
|
+
const [historyHasMore, setHistoryHasMore] = useState<boolean>(false);
|
|
281
|
+
const [histErr, setHistErr] = useState<string | null>(null);
|
|
282
|
+
const [historyMenuStage, setHistoryMenuStage] = useState<'list'|'window'|'deleteConfirm'>('list');
|
|
283
|
+
const [selectedHistory, setSelectedHistory] = useState<any|null>(null);
|
|
284
|
+
|
|
285
|
+
// History Messages
|
|
286
|
+
const [sessionMessages, setSessionMessages] = useState<MessageEntry[]>([]);
|
|
287
|
+
const [loadingMsgs, setLoadingMsgs] = useState(false);
|
|
288
|
+
const [msgsErr, setMsgsErr] = useState<string | null>(null);
|
|
289
|
+
const [msgsPage, setMsgsPage] = useState(0);
|
|
290
|
+
|
|
291
|
+
// Mark Persistence
|
|
292
|
+
const [markedSessionIds, setMarkedSessionIds] = useState<Set<string>>(new Set());
|
|
293
|
+
|
|
294
|
+
// Settings page scroll offset
|
|
295
|
+
const [settingsOffset, setSettingsOffset] = useState(0);
|
|
296
|
+
const [settingData, setSettingData] = useState<any>(null);
|
|
297
|
+
const [loadingSetting, setLoadingSetting] = useState(false);
|
|
298
|
+
const [setErr, setSetErr] = useState<string | null>(null);
|
|
299
|
+
|
|
300
|
+
// Top toolbar state
|
|
301
|
+
const [animEnabled, setAnimEnabled] = useState(true);
|
|
302
|
+
const [tipsEnabled, setTipsEnabled] = useState(true);
|
|
303
|
+
|
|
304
|
+
// Help overlay
|
|
305
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
306
|
+
|
|
307
|
+
// Keyboard navigation for lists
|
|
308
|
+
const [listOffset, setListOffset] = useState(0);
|
|
309
|
+
|
|
310
|
+
// Quick Resume (R)
|
|
311
|
+
const [quickResumeRequested, setQuickResumeRequested] = useState(false);
|
|
312
|
+
|
|
313
|
+
// Compute viewport
|
|
314
|
+
const TOP_H = page === null ? TOP_H_HOME : TOP_H_COMPACT;
|
|
315
|
+
const MID_H = Math.max(5, VIEW_H - TOP_H - BOTTOM_H - (page === null ? 0 : 2));
|
|
316
|
+
const MSGS_PAGE_SIZE = Math.max(1, MID_H - 2);
|
|
317
|
+
const [expandMsgs, setExpandMsgs] = useState(false);
|
|
318
|
+
|
|
319
|
+
// Boot/Reuse singleton local server (with retry)
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
let mounted = true;
|
|
322
|
+
(async () => {
|
|
323
|
+
if (apiUrl) return;
|
|
324
|
+
const entry = path.resolve(import.meta.dir, '../server/index.ts');
|
|
325
|
+
const MAX_RETRIES = 3;
|
|
326
|
+
const RETRY_DELAYS = [0, 2000, 5000]; // 0s, 2s, 5s
|
|
327
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
328
|
+
if (!mounted) return;
|
|
329
|
+
if (attempt > 0) {
|
|
330
|
+
setBootErr(`Attempt ${attempt} failed, retrying in ${RETRY_DELAYS[attempt] / 1000}s...`);
|
|
331
|
+
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
|
|
332
|
+
}
|
|
333
|
+
if (!mounted) return;
|
|
334
|
+
try {
|
|
335
|
+
const handle = await ensureSingletonLocalServer({ serverEntry: entry });
|
|
336
|
+
if (!mounted) { await handle.stopIfLast(); return; }
|
|
337
|
+
setApiUrl(handle.baseUrl);
|
|
338
|
+
setStopIfLast(() => handle.stopIfLast);
|
|
339
|
+
setBootErr(null);
|
|
340
|
+
return; // Success, exit retry
|
|
341
|
+
} catch (e: any) {
|
|
342
|
+
if (attempt === MAX_RETRIES - 1) {
|
|
343
|
+
setBootErr(e.message || 'Local server failed to start');
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
})();
|
|
348
|
+
return () => { mounted = false; if (stopIfLast) stopIfLast(); };
|
|
349
|
+
}, []);
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
let cancelled = false;
|
|
352
|
+
const probe = () => {
|
|
353
|
+
try {
|
|
354
|
+
getGlobalConfig();
|
|
355
|
+
if (!cancelled) setConfigReady(true);
|
|
356
|
+
} catch {
|
|
357
|
+
if (!cancelled) setTimeout(probe, 60);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
probe();
|
|
361
|
+
return () => { cancelled = true; };
|
|
362
|
+
}, []);
|
|
363
|
+
|
|
364
|
+
// Init marks
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
setMarkedSessionIds(loadMarkedSessionIds());
|
|
367
|
+
}, []);
|
|
368
|
+
|
|
369
|
+
// Page switch reset
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
if (page === 'newSession') {
|
|
372
|
+
setNewSessionId(null);
|
|
373
|
+
setCreating(false);
|
|
374
|
+
setCreateErr(null);
|
|
375
|
+
}
|
|
376
|
+
if (page !== 'settings') {
|
|
377
|
+
setSettingsOffset(0);
|
|
378
|
+
}
|
|
379
|
+
// Close help overlay
|
|
380
|
+
setShowHelp(false);
|
|
381
|
+
}, [page]);
|
|
382
|
+
|
|
383
|
+
// History page entry reset
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (page === 'history') {
|
|
386
|
+
setHistoryMenuStage('list');
|
|
387
|
+
setSelectedHistory(null);
|
|
388
|
+
setHistoryCursor(null);
|
|
389
|
+
setSessionMessages([]);
|
|
390
|
+
setMsgsErr(null);
|
|
391
|
+
setMsgsPage(0);
|
|
392
|
+
setExpandMsgs(false);
|
|
393
|
+
}
|
|
394
|
+
}, [page]);
|
|
395
|
+
|
|
396
|
+
// Create Session
|
|
397
|
+
const onCreateSession = async () => {
|
|
398
|
+
setCreating(true); setCreateErr(null);
|
|
399
|
+
try {
|
|
400
|
+
const fsReq = require('fs');
|
|
401
|
+
const pathReq = require('path');
|
|
402
|
+
const { spawn } = require('child_process');
|
|
403
|
+
// Use import.meta.dir for pkg root
|
|
404
|
+
const pkgPath = pathReq.resolve(import.meta.dir, '../../package.json');
|
|
405
|
+
const pkgJson = JSON.parse(fsReq.readFileSync(pkgPath, 'utf-8'));
|
|
406
|
+
const bins = pkgJson.bin || {};
|
|
407
|
+
const isWin = process.platform === 'win32';
|
|
408
|
+
const binName = isWin
|
|
409
|
+
? (bins['claude-haha'] ? 'claude-haha' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]))
|
|
410
|
+
: (bins['claude-linux'] ? 'claude-linux' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]));
|
|
411
|
+
const spawnCmd = isWin ? 'cmd' : 'sh';
|
|
412
|
+
// Windows calls global bingocode directly
|
|
413
|
+
const spawnArgs = isWin ? ['/c', 'start', 'cmd', '/k', 'bingocode'] : ['-c', `${binName}`];
|
|
414
|
+
const spawnEnv = await buildSpawnEnv();
|
|
415
|
+
spawn(spawnCmd, spawnArgs, {
|
|
416
|
+
cwd: process.env.CALLER_DIR || process.cwd(),
|
|
417
|
+
env: spawnEnv,
|
|
418
|
+
detached: true,
|
|
419
|
+
stdio: 'ignore'
|
|
420
|
+
}).unref();
|
|
421
|
+
setNewSessionId('Started: ' + binName);
|
|
422
|
+
} catch(e: any) {
|
|
423
|
+
setCreateErr(e.message || 'Failed to create');
|
|
424
|
+
} finally {
|
|
425
|
+
setCreating(false);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// Paged loading for history
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
if (page === 'history' && historyMenuStage === 'list') {
|
|
432
|
+
setLoadingHist(true); setHistErr(null);
|
|
433
|
+
(async () => {
|
|
434
|
+
try {
|
|
435
|
+
let url = apiUrl + '/api/sessions';
|
|
436
|
+
if (historyCursor) url += `?cursor=${historyCursor}`;
|
|
437
|
+
const res = await axios.get(url);
|
|
438
|
+
const pageData = res.data;
|
|
439
|
+
setHistoryList(pageData?.sessions || []);
|
|
440
|
+
if (historyCursor === null) {
|
|
441
|
+
setHistoryCursor(pageData?.first_id || null);
|
|
442
|
+
}
|
|
443
|
+
setHistoryHasMore(!!pageData?.has_more);
|
|
444
|
+
} catch (e: any) {
|
|
445
|
+
setHistErr(e.message || 'Failed to fetch history');
|
|
446
|
+
} finally {
|
|
447
|
+
setLoadingHist(false);
|
|
448
|
+
}
|
|
449
|
+
})();
|
|
450
|
+
}
|
|
451
|
+
if (page !== 'history') {
|
|
452
|
+
setLoadingHist(false);
|
|
453
|
+
setHistErr(null);
|
|
454
|
+
setHistoryList([]);
|
|
455
|
+
}
|
|
456
|
+
}, [page, historyCursor, historyMenuStage, apiUrl]);
|
|
457
|
+
|
|
458
|
+
// 快速恢复:当按下 R 并已加载历史列表后,自动进入第一个会话窗口
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
if (page === 'history' && historyMenuStage === 'list' && quickResumeRequested && historyList.length) {
|
|
461
|
+
const session = historyList[0];
|
|
462
|
+
if (session) {
|
|
463
|
+
setSelectedHistory(session);
|
|
464
|
+
setHistoryMenuStage('window');
|
|
465
|
+
setQuickResumeRequested(false);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}, [page, historyMenuStage, quickResumeRequested, historyList]);
|
|
469
|
+
|
|
470
|
+
// 会话消息获取
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
if (page === 'history' && historyMenuStage === 'window' && selectedHistory && apiUrl) {
|
|
473
|
+
let cancelled = false;
|
|
474
|
+
setLoadingMsgs(true);
|
|
475
|
+
setMsgsErr(null);
|
|
476
|
+
setSessionMessages([]);
|
|
477
|
+
setMsgsPage(0);
|
|
478
|
+
(async () => {
|
|
479
|
+
try {
|
|
480
|
+
const resp = await axios.get(`${apiUrl}/api/sessions/${selectedHistory.id}/messages`);
|
|
481
|
+
if (!cancelled) {
|
|
482
|
+
const msgs: MessageEntry[] = resp.data?.messages ?? [];
|
|
483
|
+
setSessionMessages(msgs);
|
|
484
|
+
}
|
|
485
|
+
} catch (e: any) {
|
|
486
|
+
if (!cancelled) setMsgsErr(e.message || 'Failed to load messages');
|
|
487
|
+
} finally {
|
|
488
|
+
if (!cancelled) setLoadingMsgs(false);
|
|
489
|
+
}
|
|
490
|
+
})();
|
|
491
|
+
return () => { cancelled = true; };
|
|
492
|
+
} else {
|
|
493
|
+
setSessionMessages([]);
|
|
494
|
+
setLoadingMsgs(false);
|
|
495
|
+
setMsgsErr(null);
|
|
496
|
+
}
|
|
497
|
+
}, [page, historyMenuStage, selectedHistory, apiUrl]);
|
|
498
|
+
|
|
499
|
+
// Settings data
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
if (page === 'settings') {
|
|
502
|
+
setLoadingSetting(true); setSetErr(null);
|
|
503
|
+
(async () => {
|
|
504
|
+
try {
|
|
505
|
+
const data = (await import('../utils/settings/settings')).default;
|
|
506
|
+
setSettingData(data);
|
|
507
|
+
} catch(e: any) {
|
|
508
|
+
setSetErr(e.message||'Failed to load settings');
|
|
509
|
+
} finally { setLoadingSetting(false); }
|
|
510
|
+
})();
|
|
511
|
+
} else {
|
|
512
|
+
setSettingData(null);
|
|
513
|
+
setLoadingSetting(false);
|
|
514
|
+
setSetErr(null);
|
|
515
|
+
}
|
|
516
|
+
}, [page]);
|
|
517
|
+
|
|
518
|
+
// Keyboard interactions
|
|
519
|
+
useInput((input, key) => {
|
|
520
|
+
// Language toggle
|
|
521
|
+
if (input === 'l' || input === 'L') {
|
|
522
|
+
const nextLang = lang === 'zh' ? 'en' : 'zh';
|
|
523
|
+
setLang(nextLang);
|
|
524
|
+
try {
|
|
525
|
+
const cfg = getGlobalConfig();
|
|
526
|
+
cfg.language = nextLang;
|
|
527
|
+
saveGlobalConfig(cfg);
|
|
528
|
+
} catch {}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Theme toggle (G)
|
|
533
|
+
if ((input === 'g' || input === 'G')) {
|
|
534
|
+
const order = ['light', 'dark', 'highContrast'] as const;
|
|
535
|
+
const curr = String(theme || 'light');
|
|
536
|
+
const idx = Math.max(0, order.indexOf(curr as any));
|
|
537
|
+
const next = order[(idx + 1) % order.length];
|
|
538
|
+
setTheme(next as any);
|
|
539
|
+
try {
|
|
540
|
+
const cfg = getGlobalConfig();
|
|
541
|
+
cfg.theme = next as any;
|
|
542
|
+
saveGlobalConfig(cfg);
|
|
543
|
+
} catch {}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Top animation toggle (O)
|
|
548
|
+
if (input === 'o' || input === 'O') {
|
|
549
|
+
setAnimEnabled(v => !v);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// Top Tips toggle (T)
|
|
553
|
+
if (input === 't' || input === 'T') {
|
|
554
|
+
setTipsEnabled(v => !v);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Help overlay (?)
|
|
559
|
+
if (input === '?') {
|
|
560
|
+
setShowHelp(v => !v);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ESC to back or close help
|
|
565
|
+
if (key.escape) {
|
|
566
|
+
if (showHelp) { setShowHelp(false); return; }
|
|
567
|
+
if (page === 'provider') return; // Handled internally
|
|
568
|
+
setPage(null);
|
|
569
|
+
setHistoryMenuStage('list');
|
|
570
|
+
setSelectedHistory(null);
|
|
571
|
+
setHistoryCursor(null);
|
|
572
|
+
setSessionMessages([]);
|
|
573
|
+
setMsgsPage(0);
|
|
574
|
+
setSettingsOffset(0);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Quick entries: N New, R Resume, P Provider
|
|
579
|
+
if (input === 'n' || input === 'N') {
|
|
580
|
+
setPage('newSession');
|
|
581
|
+
onCreateSession();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (input === 'r' || input === 'R') {
|
|
585
|
+
setPage('history');
|
|
586
|
+
setQuickResumeRequested(true);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (input === 'p' || input === 'P') {
|
|
590
|
+
setPage('provider');
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Main menu navigation
|
|
595
|
+
if (!showHelp && key.leftArrow && page === null) {
|
|
596
|
+
setNavIndex(i => (i - 1 + menuItems.length) % menuItems.length);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (!showHelp && key.rightArrow && page === null) {
|
|
600
|
+
setNavIndex(i => (i + 1) % menuItems.length);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (!showHelp && key.return && page === null) {
|
|
604
|
+
const keyVal = menuItems[navIndex].value as MenuKey;
|
|
605
|
+
setPage(keyVal);
|
|
606
|
+
if (keyVal === 'newSession') onCreateSession();
|
|
607
|
+
if (keyVal === 'exit') exit();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// History shortcuts
|
|
612
|
+
if (!showHelp && page === 'history') {
|
|
613
|
+
if (historyMenuStage === 'list') {
|
|
614
|
+
const HIST_VISIBLE = MID_H - 2;
|
|
615
|
+
if (key.downArrow || input === 'j' || input === '\u001b[B') {
|
|
616
|
+
// Internal SelectInput handles cursor, we just need to track offset for ScrollBar
|
|
617
|
+
setListOffset(o => Math.min(o + 1, Math.max(0, groupedHistoryItems.length - HIST_VISIBLE)));
|
|
618
|
+
}
|
|
619
|
+
if (key.upArrow || input === 'k' || input === '\u001b[A') {
|
|
620
|
+
setListOffset(o => Math.max(0, o - 1));
|
|
621
|
+
}
|
|
622
|
+
if (input === 'q') {
|
|
623
|
+
setPage(null);
|
|
624
|
+
setHistoryMenuStage('list');
|
|
625
|
+
setSelectedHistory(null);
|
|
626
|
+
setHistoryCursor(null);
|
|
627
|
+
setListOffset(0);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (input === 'j' && historyHasMore) {
|
|
631
|
+
setHistoryCursor(historyList[historyList.length - 1]?.id || null);
|
|
632
|
+
setListOffset(0);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (input === 'k') {
|
|
636
|
+
setHistoryCursor(null);
|
|
637
|
+
setListOffset(0);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
} else if (historyMenuStage === 'window') {
|
|
641
|
+
if ((input === 'm' || input === 'M') && selectedHistory) {
|
|
642
|
+
handleHistoryMenuAction('__toggle_mark');
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if ((input === 'c' || input === 'C') && selectedHistory) {
|
|
646
|
+
handleHistoryMenuAction('__continue');
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if ((input === 'd' || input === 'D') && selectedHistory) {
|
|
650
|
+
handleHistoryMenuAction('__delete');
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (input === 'q') {
|
|
654
|
+
handleHistoryMenuAction('__back');
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
// Message scrolling
|
|
658
|
+
if (key.upArrow || input === 'k') {
|
|
659
|
+
setMsgsPage(p => Math.max(0, p - 1));
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (key.downArrow || input === 'j') {
|
|
663
|
+
setMsgsPage(p => p + 1);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
} else if (historyMenuStage === 'deleteConfirm') {
|
|
668
|
+
if (input === 'q') {
|
|
669
|
+
handleHistoryMenuAction('__cancel_delete');
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Settings scrolling
|
|
676
|
+
if (!showHelp && page === 'settings' && settingData && typeof settingData === 'object') {
|
|
677
|
+
const total = Object.keys(settingData).length;
|
|
678
|
+
const visible = Math.max(1, MID_H - 1);
|
|
679
|
+
if (key.downArrow || input === 'j') {
|
|
680
|
+
setSettingsOffset(o => Math.min(Math.max(0, total - visible), o + 1));
|
|
681
|
+
}
|
|
682
|
+
if (key.upArrow || input === 'k') {
|
|
683
|
+
setSettingsOffset(o => Math.max(0, o - 1));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}, [menuItems, page, historyMenuStage, historyList, historyHasMore, navIndex, sessionMessages, settingData, MID_H, MSGS_PAGE_SIZE, showHelp, theme]);
|
|
687
|
+
|
|
688
|
+
function cleanText(text: string): string {
|
|
689
|
+
return String(text ?? '').replace(/[\n\r]+/g, ' ').replace(/\u001b\[[0-9;]*m/g, '').trim();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function clampTextLines(text: string, maxWidth: number, maxLines: number) {
|
|
693
|
+
const cleaned = cleanText(text);
|
|
694
|
+
const out: string[] = [];
|
|
695
|
+
if (cleaned.length <= maxWidth) {
|
|
696
|
+
out.push(cleaned);
|
|
697
|
+
} else {
|
|
698
|
+
out.push(cleaned.slice(0, maxWidth - 1) + '…');
|
|
699
|
+
}
|
|
700
|
+
return out.join('\n');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function makeHistoryLabel(item: any, width: number, isMarked: boolean) {
|
|
704
|
+
const star = isMarked ? '★ ' : '';
|
|
705
|
+
const ts = String(item.createdAt || '').slice(0, 16).replace('T', ' ');
|
|
706
|
+
const cnt = String(item.messageCount ?? 0).padStart(3, ' ');
|
|
707
|
+
// Reserved width for: prefix(star+time) + spacer(2) + suffix(1+cnt)
|
|
708
|
+
// Star is width 2, ts is width 16, spacer is 2, cnt is 3, padding is 1. Total = 24
|
|
709
|
+
const reserved = 24;
|
|
710
|
+
const titleMax = Math.max(8, width - reserved);
|
|
711
|
+
const title = safePadEnd(truncate(String(item.title || ''), titleMax), titleMax);
|
|
712
|
+
return `${star}${ts} ${title} ${cnt}`;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// 新增:会话恢复(供快捷键和右侧菜单复用)
|
|
716
|
+
// workDir: 会话原始工作目录,用于跨文件夹恢复(确保新进程能找到 session 文件)
|
|
717
|
+
async function resumeSession(sessionId: string, workDir?: string | null) {
|
|
718
|
+
try {
|
|
719
|
+
const fsReq = require('fs');
|
|
720
|
+
const pathReq = require('path');
|
|
721
|
+
const { spawn } = require('child_process');
|
|
722
|
+
// 用 import.meta.dir 定位包根,避免 process.cwd() 指向用户目录
|
|
723
|
+
const pkgPath = pathReq.resolve(import.meta.dir, '../../package.json');
|
|
724
|
+
const pkgJson = JSON.parse(fsReq.readFileSync(pkgPath, 'utf-8'));
|
|
725
|
+
const bins = pkgJson.bin || {};
|
|
726
|
+
const isWin = process.platform === 'win32';
|
|
727
|
+
const binName = isWin
|
|
728
|
+
? (bins['claude-haha'] ? 'claude-haha' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]))
|
|
729
|
+
: (bins['claude-linux'] ? 'claude-linux' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]));
|
|
730
|
+
const spawnCmd = isWin ? 'cmd' : 'sh';
|
|
731
|
+
// Windows 直接调全局 bingocode 命令,不用 bun 前缀
|
|
732
|
+
const spawnArgs = isWin
|
|
733
|
+
? ['/c', 'start', 'cmd', '/k', `bingocode --resume ${sessionId}`]
|
|
734
|
+
: ['-c', `${binName} --resume ${sessionId}`];
|
|
735
|
+
const spawnEnv = await buildSpawnEnv();
|
|
736
|
+
spawn(spawnCmd, spawnArgs, {
|
|
737
|
+
cwd: workDir || process.env.CALLER_DIR || process.cwd(),
|
|
738
|
+
env: spawnEnv,
|
|
739
|
+
detached: true,
|
|
740
|
+
stdio: 'ignore'
|
|
741
|
+
}).unref();
|
|
742
|
+
} catch {}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
// 历史分组展示
|
|
747
|
+
const groupedHistoryItems = useMemo(() => {
|
|
748
|
+
if (!historyList || !Array.isArray(historyList)) return [];
|
|
749
|
+
const now = new Date();
|
|
750
|
+
const today: any[] = [];
|
|
751
|
+
const week: any[] = [];
|
|
752
|
+
const earlier: any[] = [];
|
|
753
|
+
const marked: any[] = [];
|
|
754
|
+
|
|
755
|
+
for (const item of historyList) {
|
|
756
|
+
if (markedSessionIds.has(item.id)) {
|
|
757
|
+
marked.push(item);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
const dt = new Date(item.createdAt);
|
|
761
|
+
const isToday =
|
|
762
|
+
dt.getFullYear() === now.getFullYear() &&
|
|
763
|
+
dt.getMonth() === now.getMonth() &&
|
|
764
|
+
dt.getDate() === now.getDate();
|
|
765
|
+
const weekStart = new Date(now);
|
|
766
|
+
weekStart.setDate(now.getDate() - ((now.getDay() + 6) % 7));
|
|
767
|
+
weekStart.setHours(0, 0, 0, 0);
|
|
768
|
+
if (isToday) today.push(item);
|
|
769
|
+
else if (dt >= weekStart) week.push(item);
|
|
770
|
+
else earlier.push(item);
|
|
771
|
+
}
|
|
772
|
+
function groupToItems(group: any[], groupTitle: string) {
|
|
773
|
+
if (group.length === 0) return [];
|
|
774
|
+
return [
|
|
775
|
+
{ label: groupTitle, value: `__group_${groupTitle}`, isGroup: true },
|
|
776
|
+
...group.map(item => {
|
|
777
|
+
const isMarked = markedSessionIds.has(item.id);
|
|
778
|
+
return {
|
|
779
|
+
label: makeHistoryLabel(item, Math.max(20, VIEW_W - 8), isMarked),
|
|
780
|
+
value: item.id,
|
|
781
|
+
color: isMarked ? 'yellow' : undefined,
|
|
782
|
+
};
|
|
783
|
+
})
|
|
784
|
+
];
|
|
785
|
+
}
|
|
786
|
+
const items = [
|
|
787
|
+
...groupToItems(marked, '—— Marked ——'),
|
|
788
|
+
...groupToItems(today, '—— Today ——'),
|
|
789
|
+
...groupToItems(week, '—— This Week ——'),
|
|
790
|
+
...groupToItems(earlier, '—— Earlier ——'),
|
|
791
|
+
];
|
|
792
|
+
return items;
|
|
793
|
+
}, [historyList, markedSessionIds]);
|
|
794
|
+
|
|
795
|
+
// Toggle Mark
|
|
796
|
+
const toggleMarkSession = (sessionId: string) => {
|
|
797
|
+
setMarkedSessionIds(prev => {
|
|
798
|
+
const next = new Set(prev);
|
|
799
|
+
if (next.has(sessionId)) next.delete(sessionId);
|
|
800
|
+
else next.add(sessionId);
|
|
801
|
+
saveMarkedSessionIds(next);
|
|
802
|
+
return next;
|
|
803
|
+
});
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const handleHistoryMenuAction = (action: string) => {
|
|
807
|
+
if (action === '__back') {
|
|
808
|
+
setHistoryMenuStage('list');
|
|
809
|
+
setSelectedHistory(null);
|
|
810
|
+
setMsgsPage(0);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (!selectedHistory) return;
|
|
814
|
+
|
|
815
|
+
switch (action) {
|
|
816
|
+
case '__toggle_mark':
|
|
817
|
+
toggleMarkSession(selectedHistory.id);
|
|
818
|
+
break;
|
|
819
|
+
case '__continue':
|
|
820
|
+
resumeSession(selectedHistory.id, selectedHistory.workDir);
|
|
821
|
+
break;
|
|
822
|
+
case '__delete':
|
|
823
|
+
setHistoryMenuStage('deleteConfirm');
|
|
824
|
+
break;
|
|
825
|
+
case '__confirm_delete':
|
|
826
|
+
handleDeleteSession(selectedHistory.id);
|
|
827
|
+
break;
|
|
828
|
+
case '__cancel_delete':
|
|
829
|
+
setHistoryMenuStage('window');
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
// Refresh history
|
|
836
|
+
const refreshHistoryList = () => {
|
|
837
|
+
setLoadingHist(true); setHistErr(null);
|
|
838
|
+
let url = apiUrl + '/api/sessions';
|
|
839
|
+
axios.get(url).then(res => {
|
|
840
|
+
const pageData = res.data;
|
|
841
|
+
setHistoryList(pageData?.sessions || []);
|
|
842
|
+
setHistoryCursor(pageData?.first_id || null);
|
|
843
|
+
setHistoryHasMore(!!pageData?.has_more);
|
|
844
|
+
}).catch(e => {
|
|
845
|
+
setHistErr(e.message || 'Failed to fetch history');
|
|
846
|
+
}).finally(() => setLoadingHist(false));
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
// Delete Session
|
|
850
|
+
const handleDeleteSession = (sessionId: string) => {
|
|
851
|
+
const url = apiUrl.replace(/\/+$/, '') + '/api/sessions/' + sessionId;
|
|
852
|
+
axios.delete(url)
|
|
853
|
+
.catch(e => {})
|
|
854
|
+
.finally(() => {
|
|
855
|
+
setHistoryMenuStage('list');
|
|
856
|
+
setSelectedHistory(null);
|
|
857
|
+
setHistoryCursor(null);
|
|
858
|
+
refreshHistoryList();
|
|
859
|
+
});
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
// Secondary menu (bottom bar right)
|
|
863
|
+
const secondaryMenu: SecondaryMenu = useMemo(() => {
|
|
864
|
+
if (page === 'history' && historyMenuStage === 'window' && selectedHistory) {
|
|
865
|
+
const isMarked = markedSessionIds.has(selectedHistory.id);
|
|
866
|
+
const markLabel = isMarked ? i18nMap[lang].unmark : i18nMap[lang].mark;
|
|
867
|
+
return {
|
|
868
|
+
title: 'Session Actions',
|
|
869
|
+
items: [
|
|
870
|
+
{ label: markLabel, value: '__toggle_mark' },
|
|
871
|
+
{ label: '→ Continue session', value: '__continue' },
|
|
872
|
+
{ label: '→ Delete session', value: '__delete' },
|
|
873
|
+
{ label: '← Back to list', value: '__back' },
|
|
874
|
+
],
|
|
875
|
+
onSelect: (item: any) => {
|
|
876
|
+
if (item.value === '__back') {
|
|
877
|
+
setHistoryMenuStage('list');
|
|
878
|
+
setSelectedHistory(null);
|
|
879
|
+
setMsgsPage(0);
|
|
880
|
+
} else if (item.value === '__continue') {
|
|
881
|
+
resumeSession(selectedHistory.id, selectedHistory.workDir);
|
|
882
|
+
} else if (item.value === '__delete') {
|
|
883
|
+
setHistoryMenuStage('deleteConfirm');
|
|
884
|
+
} else if (item.value === '__toggle_mark') {
|
|
885
|
+
toggleMarkSession(selectedHistory.id);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (page === 'history' && historyMenuStage === 'deleteConfirm' && selectedHistory) {
|
|
892
|
+
return {
|
|
893
|
+
title: 'Confirm Delete',
|
|
894
|
+
items: [
|
|
895
|
+
{ label: 'Yes, delete', value: '__confirm_delete' },
|
|
896
|
+
{ label: 'No, back', value: '__cancel_delete' },
|
|
897
|
+
],
|
|
898
|
+
onSelect: (item: any) => {
|
|
899
|
+
if (item.value === '__cancel_delete') {
|
|
900
|
+
setHistoryMenuStage('window');
|
|
901
|
+
} else if (item.value === '__confirm_delete') {
|
|
902
|
+
handleDeleteSession(selectedHistory.id);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
return null;
|
|
908
|
+
}, [page, historyMenuStage, selectedHistory, markedSessionIds, lang]);
|
|
909
|
+
|
|
910
|
+
// Help Overlay
|
|
911
|
+
function renderHelpOverlay() {
|
|
912
|
+
return (
|
|
913
|
+
<Box width={VIEW_W} height={MID_H} flexDirection="column">
|
|
914
|
+
<Text color="magenta">{i18nMap[lang].helpTitle}</Text>
|
|
915
|
+
<Text> </Text>
|
|
916
|
+
<Text color="cyan">N</Text><Text> New Session</Text>
|
|
917
|
+
<Text color="cyan">R</Text><Text> Quick Resume</Text>
|
|
918
|
+
<Text color="cyan">P</Text><Text> Open Provider Config</Text>
|
|
919
|
+
<Text color="cyan">G</Text><Text> Toggle Theme (light/dark/highContrast)</Text>
|
|
920
|
+
<Text color="cyan">L</Text><Text> Toggle Language (en/zh)</Text>
|
|
921
|
+
<Text color="cyan">O</Text><Text> Toggle Top Animation</Text>
|
|
922
|
+
<Text color="cyan">T</Text><Text> Toggle Top Tips</Text>
|
|
923
|
+
<Text color="cyan">?</Text><Text> Toggle Help</Text>
|
|
924
|
+
<Text> </Text>
|
|
925
|
+
<Hint>ESC to close · Works anywhere</Hint>
|
|
926
|
+
</Box>
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Center Content
|
|
931
|
+
function renderCenter() {
|
|
932
|
+
if (showHelp) return renderHelpOverlay();
|
|
933
|
+
|
|
934
|
+
// Home: WelcomeV2 (58 cols wide)
|
|
935
|
+
if (page === null) {
|
|
936
|
+
const WELCOME_W = 58;
|
|
937
|
+
const leftPad = Math.max(0, Math.floor((VIEW_W - WELCOME_W) / 2));
|
|
938
|
+
return (
|
|
939
|
+
<Box flexDirection="column" width={VIEW_W} height={MID_H}>
|
|
940
|
+
<Box flexDirection="row" width={VIEW_W} flexGrow={1}>
|
|
941
|
+
<Box width={leftPad} flexShrink={0} />
|
|
942
|
+
<WelcomeV2 />
|
|
943
|
+
</Box>
|
|
944
|
+
{!apiUrl && !bootErr && (
|
|
945
|
+
<StateDisplay type="loading" message="Starting server..." />
|
|
946
|
+
)}
|
|
947
|
+
{bootErr && (
|
|
948
|
+
<StateDisplay type="error" message={`Server boot failed: ${bootErr}`} />
|
|
949
|
+
)}
|
|
950
|
+
</Box>
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// New Session
|
|
955
|
+
if (page === 'newSession') {
|
|
956
|
+
return (
|
|
957
|
+
<Box flexDirection="column" width={VIEW_W} height={MID_H}>
|
|
958
|
+
{creating && <StateDisplay type="loading" message="Creating..." />}
|
|
959
|
+
{createErr && <StateDisplay type="error" message={`Failed to create: ${createErr}`} />}
|
|
960
|
+
{newSessionId && <Box alignItems="center" justifyContent="center" flexGrow={1}><Text color="green">New Session: {newSessionId}</Text></Box>}
|
|
961
|
+
{!creating && !createErr && !newSessionId && <StateDisplay type="empty" message="Entered new session page, waiting for result..." />}
|
|
962
|
+
</Box>
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// History
|
|
967
|
+
if (page === 'history') {
|
|
968
|
+
if (histErr) return <StateDisplay type="error" message={histErr} onRetry={refreshHistoryList} />;
|
|
969
|
+
if (historyMenuStage === 'deleteConfirm' && selectedHistory) {
|
|
970
|
+
const halfH = Math.floor(MID_H / 2);
|
|
971
|
+
const items = [
|
|
972
|
+
{ label: 'Yes, Delete', value: '__confirm_delete' },
|
|
973
|
+
{ label: 'No, Back', value: '__cancel_delete' },
|
|
974
|
+
];
|
|
975
|
+
return (
|
|
976
|
+
<Box width={VIEW_W} height={MID_H} flexDirection="column">
|
|
977
|
+
<Box height={halfH} flexDirection="column" paddingX={1} paddingTop={1}>
|
|
978
|
+
<Text color="red" bold>Confirm Delete?</Text>
|
|
979
|
+
<Text>Title: {selectedHistory.title || 'Untitled'}</Text>
|
|
980
|
+
<Text dimColor>Time: {selectedHistory.createdAt?.replace('T',' ')}</Text>
|
|
981
|
+
<Text dimColor>ID: {selectedHistory.id}</Text>
|
|
982
|
+
</Box>
|
|
983
|
+
<Panel height={MID_H - halfH} borderStyle="round" borderColor="red" paddingX={1}>
|
|
984
|
+
<SelectInput
|
|
985
|
+
items={items}
|
|
986
|
+
onSelect={(item) => handleHistoryMenuAction(String(item.value))}
|
|
987
|
+
/>
|
|
988
|
+
<Hint>Enter Confirm · q Cancel</Hint>
|
|
989
|
+
</Panel>
|
|
990
|
+
</Box>
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
if (!historyList.length && loadingHist) {
|
|
994
|
+
return <StateDisplay type="loading" message="Loading..." />;
|
|
995
|
+
}
|
|
996
|
+
if (!historyList.length) {
|
|
997
|
+
return <StateDisplay type="empty" message={i18nMap[lang].emptyHistory} />;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const ACTIONS_H = 6;
|
|
1001
|
+
const LIST_H = Math.max(2, MID_H - ACTIONS_H - 1);
|
|
1002
|
+
|
|
1003
|
+
if (historyMenuStage === 'window' && selectedHistory) {
|
|
1004
|
+
// Detailed View with Split
|
|
1005
|
+
const isMarked = markedSessionIds.has(selectedHistory.id);
|
|
1006
|
+
const displayMsgs = sessionMessages.filter(
|
|
1007
|
+
m => m.type === 'user' || m.type === 'assistant' || m.type === 'system'
|
|
1008
|
+
);
|
|
1009
|
+
const totalPages = Math.max(1, Math.ceil(displayMsgs.length / MSGS_PAGE_SIZE));
|
|
1010
|
+
const safePage = Math.min(msgsPage, totalPages - 1);
|
|
1011
|
+
const pageStart = safePage * MSGS_PAGE_SIZE;
|
|
1012
|
+
const pageMsgs = displayMsgs.slice(pageStart, pageStart + MSGS_PAGE_SIZE);
|
|
1013
|
+
|
|
1014
|
+
return (
|
|
1015
|
+
<Box width={VIEW_W} height={MID_H} flexDirection="column">
|
|
1016
|
+
{/* Upper Pane: Preview */}
|
|
1017
|
+
<Box height={LIST_H} flexDirection="column" paddingX={1} overflow="hidden">
|
|
1018
|
+
<Box justifyContent="space-between" marginBottom={0}>
|
|
1019
|
+
<Text color={isMarked ? 'yellow' : 'cyan'} bold>
|
|
1020
|
+
{isMarked ? '★ ' : ''}{truncate(selectedHistory.title || 'Untitled', VIEW_W - 24)}
|
|
1021
|
+
</Text>
|
|
1022
|
+
<Text dimColor>{selectedHistory.createdAt?.slice(0,16).replace('T',' ')}</Text>
|
|
1023
|
+
</Box>
|
|
1024
|
+
|
|
1025
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
1026
|
+
{loadingMsgs && <StateDisplay type="loading" message="Loading messages..." />}
|
|
1027
|
+
{msgsErr && <StateDisplay type="error" message={msgsErr} />}
|
|
1028
|
+
{!loadingMsgs && pageMsgs.length === 0 && <StateDisplay type="empty" message="No messages" />}
|
|
1029
|
+
{pageMsgs.map((msg) => {
|
|
1030
|
+
const text = extractTextFromContent(msg.content);
|
|
1031
|
+
const roleLabel = msg.type === 'user' ? 'You' : 'Bot';
|
|
1032
|
+
const roleColor = msg.type === 'user' ? 'green' : 'cyan';
|
|
1033
|
+
return (
|
|
1034
|
+
<Box key={msg.id} marginBottom={0} flexDirection="column" height={1} overflow="hidden">
|
|
1035
|
+
<Text color={roleColor} bold>{roleLabel}: <Text color="white" bold={false}>{clampTextLines(text, VIEW_W - 10, 1)}</Text></Text>
|
|
1036
|
+
</Box>
|
|
1037
|
+
);
|
|
1038
|
+
})}
|
|
1039
|
+
</Box>
|
|
1040
|
+
{totalPages > 1 && (
|
|
1041
|
+
<Box justifyContent="center" height={1}>
|
|
1042
|
+
<Hint>Page {safePage + 1}/{totalPages} (↑↓ to scroll)</Hint>
|
|
1043
|
+
</Box>
|
|
1044
|
+
)}
|
|
1045
|
+
</Box>
|
|
1046
|
+
|
|
1047
|
+
<Box height={1} marginBottom={0}><Text dimColor>{'─'.repeat(VIEW_W - 2)}</Text></Box>
|
|
1048
|
+
|
|
1049
|
+
{/* Lower Pane: Actions */}
|
|
1050
|
+
<Box height={ACTIONS_H} paddingX={1} flexDirection="column" overflow="hidden">
|
|
1051
|
+
<Text color="magenta" bold>Actions</Text>
|
|
1052
|
+
<Box marginTop={0} height={ACTIONS_H - 2} overflow="hidden">
|
|
1053
|
+
<SelectInput
|
|
1054
|
+
items={secondaryMenu?.items || []}
|
|
1055
|
+
onSelect={secondaryMenu?.onSelect}
|
|
1056
|
+
/>
|
|
1057
|
+
</Box>
|
|
1058
|
+
<Hint>ESC Back · ↑↓ Select Action · Q/M/C Shortcut</Hint>
|
|
1059
|
+
</Box>
|
|
1060
|
+
</Box>
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// History List View (Default)
|
|
1065
|
+
const HIST_VISIBLE = MID_H - 2;
|
|
1066
|
+
const start = Math.min(listOffset, Math.max(0, groupedHistoryItems.length - HIST_VISIBLE));
|
|
1067
|
+
const slicedItems = groupedHistoryItems.slice(start, start + HIST_VISIBLE);
|
|
1068
|
+
|
|
1069
|
+
return (
|
|
1070
|
+
<Box width={VIEW_W} height={MID_H} flexDirection="row" position="relative">
|
|
1071
|
+
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
|
1072
|
+
<SelectInput
|
|
1073
|
+
key={`${historyCursor ?? 'first'}:${slicedItems.length}:${start}`}
|
|
1074
|
+
items={slicedItems}
|
|
1075
|
+
onSelect={item => {
|
|
1076
|
+
if (String(item.value).startsWith('__group_')) return;
|
|
1077
|
+
const session = historyList.find(h => h.id === item.value);
|
|
1078
|
+
if (session) {
|
|
1079
|
+
setSelectedHistory(session);
|
|
1080
|
+
setHistoryMenuStage('window');
|
|
1081
|
+
}
|
|
1082
|
+
}}
|
|
1083
|
+
itemComponent={({ isSelected, label }) => {
|
|
1084
|
+
const it = groupedHistoryItems.find(i => i.label === label);
|
|
1085
|
+
const isGroup = it?.isGroup;
|
|
1086
|
+
const color = it?.color;
|
|
1087
|
+
return (
|
|
1088
|
+
<Box height={1} overflow="hidden">
|
|
1089
|
+
<Text wrap="truncate" color={isGroup ? 'gray' : (color ? color : (isSelected ? 'cyan' : undefined))}>
|
|
1090
|
+
{isSelected ? '> ' : ' '}{label}
|
|
1091
|
+
</Text>
|
|
1092
|
+
</Box>
|
|
1093
|
+
)
|
|
1094
|
+
}}
|
|
1095
|
+
/>
|
|
1096
|
+
</Box>
|
|
1097
|
+
<ScrollBar total={groupedHistoryItems.length} offset={start} height={MID_H - 2} />
|
|
1098
|
+
<Box position="absolute" bottom={0} left={1} width={VIEW_W - 4}>
|
|
1099
|
+
<Hint>{i18nMap[lang].historyHint}</Hint>
|
|
1100
|
+
</Box>
|
|
1101
|
+
</Box>
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Provider
|
|
1106
|
+
if (page === 'provider') {
|
|
1107
|
+
if (!apiUrl) {
|
|
1108
|
+
return (
|
|
1109
|
+
<Box width={VIEW_W} height={MID_H} flexDirection="column">
|
|
1110
|
+
<StateDisplay
|
|
1111
|
+
type={bootErr ? "error" : "loading"}
|
|
1112
|
+
message={bootErr ? `Server boot failed: ${bootErr}` : 'Starting server, please wait...'}
|
|
1113
|
+
onRetry={() => process.exit(1)} // Or another way to trigger reboot
|
|
1114
|
+
/>
|
|
1115
|
+
<Text dimColor alignSelf="center">ESC for main menu</Text>
|
|
1116
|
+
</Box>
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
return (
|
|
1120
|
+
<Box width={VIEW_W} height={MID_H} flexDirection="column">
|
|
1121
|
+
<ProviderPanel apiUrl={apiUrl} height={MID_H} onBack={() => setPage(null)} />
|
|
1122
|
+
</Box>
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Settings
|
|
1127
|
+
if (page === 'settings') {
|
|
1128
|
+
if (loadingSetting) return <StateDisplay type="loading" message="Loading settings..." />;
|
|
1129
|
+
if (setErr) return <StateDisplay type="error" message={setErr} />;
|
|
1130
|
+
if (!settingData || typeof settingData !== 'object') return <StateDisplay type="empty" message="No settings found" />;
|
|
1131
|
+
const entries = Object.entries(settingData);
|
|
1132
|
+
const visible = Math.max(1, MID_H - 1);
|
|
1133
|
+
const start = Math.min(settingsOffset, Math.max(0, entries.length - visible));
|
|
1134
|
+
const sliced = entries.slice(start, start + visible);
|
|
1135
|
+
return (
|
|
1136
|
+
<Box width={VIEW_W} height={MID_H} flexDirection="column">
|
|
1137
|
+
<ScrollBar total={entries.length} offset={start} height={visible - 1} />
|
|
1138
|
+
{sliced.map(([k, v]) => <Text key={k}>{k}: {typeof v === 'object' ? JSON.stringify(v) : String(v)}</Text>)}
|
|
1139
|
+
<Hint>
|
|
1140
|
+
↑/k and ↓/j scroll · {start+1}-{Math.min(start+visible, entries.length)}/{entries.length}
|
|
1141
|
+
</Hint>
|
|
1142
|
+
</Box>
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// About
|
|
1147
|
+
if (page === 'about') {
|
|
1148
|
+
return (
|
|
1149
|
+
<Box width={VIEW_W} height={MID_H} flexDirection="column">
|
|
1150
|
+
<Text color="cyan" bold>{i18nMap[lang].about}</Text>
|
|
1151
|
+
<Box marginTop={1} flexDirection="column">
|
|
1152
|
+
<Text>{(i18nMap[lang] as any).aboutContent}</Text>
|
|
1153
|
+
</Box>
|
|
1154
|
+
<Box marginTop={1}>
|
|
1155
|
+
<Hint>
|
|
1156
|
+
API Base: {apiUrl}
|
|
1157
|
+
</Hint>
|
|
1158
|
+
</Box>
|
|
1159
|
+
</Box>
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Exit
|
|
1164
|
+
if (page === 'exit') {
|
|
1165
|
+
exit();
|
|
1166
|
+
return <Box width={VIEW_W} height={MID_H}><Text>Exiting...</Text></Box>;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
return <Box width={VIEW_W} height={MID_H} />;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Exit logic
|
|
1173
|
+
if (terminalSize.columns < 60 || terminalSize.rows < 15) {
|
|
1174
|
+
return (
|
|
1175
|
+
<Box flexDirection="column" padding={2}>
|
|
1176
|
+
<Text color="red">Terminal too small!</Text>
|
|
1177
|
+
<Text>Current: {terminalSize.columns}x{terminalSize.rows}</Text>
|
|
1178
|
+
<Text>Please resize to continue...</Text>
|
|
1179
|
+
</Box>
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Root Render
|
|
1184
|
+
return (
|
|
1185
|
+
<Box flexDirection="column" width={VIEW_W}>
|
|
1186
|
+
{/* Top Welcome / Logo Area + Toolbar */}
|
|
1187
|
+
<TopBar
|
|
1188
|
+
ready={configReady}
|
|
1189
|
+
page={page}
|
|
1190
|
+
width={VIEW_W}
|
|
1191
|
+
height={TOP_H}
|
|
1192
|
+
homeLogo={<LogoV2 />}
|
|
1193
|
+
compactLogo={<CondensedLogo />}
|
|
1194
|
+
ip={apiUrl ? apiUrl.replace(/^https?:\/\//, '') : undefined}
|
|
1195
|
+
toolbar={
|
|
1196
|
+
<TopToolbar
|
|
1197
|
+
ready={configReady}
|
|
1198
|
+
page={page}
|
|
1199
|
+
animEnabled={animEnabled}
|
|
1200
|
+
tipsEnabled={tipsEnabled}
|
|
1201
|
+
/>
|
|
1202
|
+
}
|
|
1203
|
+
/>
|
|
1204
|
+
|
|
1205
|
+
{/* Center Center Area */}
|
|
1206
|
+
{page === null ? (
|
|
1207
|
+
<Panel width={VIEW_W} height={MID_H} noBorder paddingX={0} paddingY={0} marginY={0}>
|
|
1208
|
+
{renderCenter()}
|
|
1209
|
+
</Panel>
|
|
1210
|
+
) : (
|
|
1211
|
+
<Panel width={VIEW_W} height={MID_H} borderStyle="single" paddingX={1} paddingY={0} marginY={1}>
|
|
1212
|
+
{renderCenter()}
|
|
1213
|
+
</Panel>
|
|
1214
|
+
)}
|
|
1215
|
+
|
|
1216
|
+
{/* Bottom Menu & Secondary Menu */}
|
|
1217
|
+
<BottomBar
|
|
1218
|
+
width={VIEW_W}
|
|
1219
|
+
height={BOTTOM_H}
|
|
1220
|
+
menuItems={menuItems}
|
|
1221
|
+
page={page}
|
|
1222
|
+
navIndex={navIndex}
|
|
1223
|
+
tips={i18nMap[lang].tipsSimple}
|
|
1224
|
+
secondaryMenu={
|
|
1225
|
+
page === 'history' && (historyMenuStage === 'window' || historyMenuStage === 'deleteConfirm')
|
|
1226
|
+
? null
|
|
1227
|
+
: secondaryMenu
|
|
1228
|
+
}
|
|
1229
|
+
/>
|
|
1230
|
+
</Box>
|
|
1231
|
+
);
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
export default CliMenuManager;
|