bingocode 1.1.109 → 1.1.111

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.
@@ -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 || 10);
77
- const TOP_H_COMPACT = Number(process.env.CLI_TOP_H_COMPACT || 7);
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
- '1. API Config: Press "P" or select "API Config" to set up your keys.',
95
- '2. Model Slots: Configure specific models in the Provider panel.',
96
- '3. Background Service: Bingo runs a local server to manage sessions.',
97
- '4. Start Chat: Run `bingocode` or `claude` in any terminal to start.',
98
- ].join('\n'),
99
- author: 'Author: leanchy (Email: leanchy07@outlook.com)',
100
- github: 'Github: github.com/leanchy/bingo-claude-code-offline-installer',
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
- '1. API Config: Press "P" or select "API Config" to set up your keys.',
123
- '2. Model Slots: Configure specific models in the Provider panel.',
124
- '3. Background Service: Bingo runs a local server to manage sessions.',
125
- '4. Start Chat: Run `bingocode` or `claude` in any terminal to start.',
126
- ].join('\n'),
127
- author: 'Author: leanchy (Email: leanchy07@outlook.com)',
128
- github: 'Github: github.com/leanchy/bingo-claude-code-offline-installer',
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 [historyHighlightIndex, setHistoryHighlightIndex] = 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
- setHistoryHighlightIndex(0);
394
- }
395
- }, [page]);
396
-
397
- // Create Session
398
- const onCreateSession = async () => {
399
- setCreating(true); setCreateErr(null);
400
- try {
401
- const fsReq = require('fs');
402
- const pathReq = require('path');
403
- const { spawn } = require('child_process');
404
- // Use import.meta.dir for pkg root
405
- const pkgPath = pathReq.resolve(import.meta.dir, '../../package.json');
406
- const pkgJson = JSON.parse(fsReq.readFileSync(pkgPath, 'utf-8'));
407
- const bins = pkgJson.bin || {};
408
- const isWin = process.platform === 'win32';
409
- const binName = isWin
410
- ? (bins['claude-haha'] ? 'claude-haha' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]))
411
- : (bins['claude-linux'] ? 'claude-linux' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]));
412
- const spawnCmd = isWin ? 'cmd' : 'sh';
413
- // Windows calls global bingocode directly
414
- const spawnArgs = isWin ? ['/c', 'start', 'cmd', '/k', 'bingocode'] : ['-c', `${binName}`];
415
- const spawnEnv = await buildSpawnEnv();
416
- spawn(spawnCmd, spawnArgs, {
417
- cwd: process.env.CALLER_DIR || process.cwd(),
418
- env: spawnEnv,
419
- detached: true,
420
- stdio: 'ignore'
421
- }).unref();
422
- setNewSessionId('Started: ' + binName);
423
- } catch(e: any) {
424
- setCreateErr(e.message || 'Failed to create');
425
- } finally {
426
- setCreating(false);
427
- }
428
- };
429
-
430
- // Paged loading for history
431
- useEffect(() => {
432
- if (page === 'history' && historyMenuStage === 'list') {
433
- setLoadingHist(true); setHistErr(null);
434
- (async () => {
435
- try {
436
- let url = apiUrl + '/api/sessions';
437
- if (historyCursor) url += `?cursor=${historyCursor}`;
438
- const res = await axios.get(url);
439
- const pageData = res.data;
440
- setHistoryList(pageData?.sessions || []);
441
- if (historyCursor === null) {
442
- setHistoryCursor(pageData?.first_id || null);
443
- }
444
- setHistoryHasMore(!!pageData?.has_more);
445
- } catch (e: any) {
446
- setHistErr(e.message || 'Failed to fetch history');
447
- } finally {
448
- setLoadingHist(false);
449
- }
450
- })();
451
- }
452
- if (page !== 'history') {
453
- setLoadingHist(false);
454
- setHistErr(null);
455
- setHistoryList([]);
456
- }
457
- }, [page, historyCursor, historyMenuStage, apiUrl]);
458
-
459
- // 快速恢复:当按下 R 并已加载历史列表后,自动进入第一个会话窗口
460
- useEffect(() => {
461
- if (page === 'history' && historyMenuStage === 'list' && quickResumeRequested && historyList.length) {
462
- const session = historyList[0];
463
- if (session) {
464
- setSelectedHistory(session);
465
- setHistoryMenuStage('window');
466
- setQuickResumeRequested(false);
467
- }
468
- }
469
- }, [page, historyMenuStage, quickResumeRequested, historyList]);
470
-
471
- // 会话消息获取
472
- useEffect(() => {
473
- if (page === 'history' && historyMenuStage === 'window' && selectedHistory && apiUrl) {
474
- let cancelled = false;
475
- setLoadingMsgs(true);
476
- setMsgsErr(null);
477
- setSessionMessages([]);
478
- setMsgsPage(0);
479
- (async () => {
480
- try {
481
- const resp = await axios.get(`${apiUrl}/api/sessions/${selectedHistory.id}/messages`);
482
- if (!cancelled) {
483
- const msgs: MessageEntry[] = resp.data?.messages ?? [];
484
- setSessionMessages(msgs);
485
- }
486
- } catch (e: any) {
487
- if (!cancelled) setMsgsErr(e.message || 'Failed to load messages');
488
- } finally {
489
- if (!cancelled) setLoadingMsgs(false);
490
- }
491
- })();
492
- return () => { cancelled = true; };
493
- } else {
494
- setSessionMessages([]);
495
- setLoadingMsgs(false);
496
- setMsgsErr(null);
497
- }
498
- }, [page, historyMenuStage, selectedHistory, apiUrl]);
499
-
500
- // Settings data
501
- useEffect(() => {
502
- if (page === 'settings') {
503
- setLoadingSetting(true); setSetErr(null);
504
- (async () => {
505
- try {
506
- const data = (await import('../utils/settings/settings')).default;
507
- setSettingData(data);
508
- } catch(e: any) {
509
- setSetErr(e.message||'Failed to load settings');
510
- } finally { setLoadingSetting(false); }
511
- })();
512
- } else {
513
- setSettingData(null);
514
- setLoadingSetting(false);
515
- setSetErr(null);
516
- }
517
- }, [page]);
518
-
519
- // Keyboard interactions
520
- useInput((input, key) => {
521
- // Language toggle
522
- if (input === 'l' || input === 'L') {
523
- const nextLang = lang === 'zh' ? 'en' : 'zh';
524
- setLang(nextLang);
525
- try {
526
- const cfg = getGlobalConfig();
527
- cfg.language = nextLang;
528
- saveGlobalConfig(cfg);
529
- } catch {}
530
- return;
531
- }
532
-
533
- // Theme toggle (G)
534
- if ((input === 'g' || input === 'G')) {
535
- const order = ['light', 'dark', 'highContrast'] as const;
536
- const curr = String(theme || 'light');
537
- const idx = Math.max(0, order.indexOf(curr as any));
538
- const next = order[(idx + 1) % order.length];
539
- setTheme(next as any);
540
- try {
541
- const cfg = getGlobalConfig();
542
- cfg.theme = next as any;
543
- saveGlobalConfig(cfg);
544
- } catch {}
545
- return;
546
- }
547
-
548
- // Top animation toggle (O)
549
- if (input === 'o' || input === 'O') {
550
- setAnimEnabled(v => !v);
551
- return;
552
- }
553
- // Top Tips toggle (T)
554
- if (input === 't' || input === 'T') {
555
- setTipsEnabled(v => !v);
556
- return;
557
- }
558
-
559
- // Help overlay (?)
560
- if (input === '?') {
561
- setShowHelp(v => !v);
562
- return;
563
- }
564
-
565
- // ESC to back or close help
566
- if (key.escape) {
567
- if (showHelp) { setShowHelp(false); return; }
568
- if (page === 'provider') return; // Handled internally
569
- setPage(null);
570
- setHistoryMenuStage('list');
571
- setSelectedHistory(null);
572
- setHistoryCursor(null);
573
- setSessionMessages([]);
574
- setMsgsPage(0);
575
- setHistoryHighlightIndex(0);
576
- setSettingsOffset(0);
577
- return;
578
- }
579
-
580
- // Quick entries: N New, R Resume, P Provider
581
- if (input === 'n' || input === 'N') {
582
- setPage('newSession');
583
- onCreateSession();
584
- return;
585
- }
586
- if (input === 'r' || input === 'R') {
587
- setPage('history');
588
- setQuickResumeRequested(true);
589
- return;
590
- }
591
- if (input === 'p' || input === 'P') {
592
- setPage('provider');
593
- return;
594
- }
595
-
596
- // Main menu navigation
597
- if (!showHelp && key.leftArrow && page === null) {
598
- setNavIndex(i => (i - 1 + menuItems.length) % menuItems.length);
599
- return;
600
- }
601
- if (!showHelp && key.rightArrow && page === null) {
602
- setNavIndex(i => (i + 1) % menuItems.length);
603
- return;
604
- }
605
- if (!showHelp && key.return && page === null) {
606
- const keyVal = menuItems[navIndex].value as MenuKey;
607
- setPage(keyVal);
608
- if (keyVal === 'newSession') onCreateSession();
609
- if (keyVal === 'exit') exit();
610
- return;
611
- }
612
-
613
- // History shortcuts
614
- if (!showHelp && page === 'history') {
615
- if (historyMenuStage === 'list') {
616
- if (input === 'q') {
617
- setPage(null);
618
- setHistoryMenuStage('list');
619
- setSelectedHistory(null);
620
- setHistoryCursor(null);
621
- setHistoryHighlightIndex(0);
622
- return;
623
- }
624
- if (input === 'j' && historyHasMore) {
625
- setHistoryCursor(historyList[historyList.length - 1]?.id || null);
626
- setHistoryHighlightIndex(0);
627
- return;
628
- }
629
- if (input === 'k') {
630
- setHistoryCursor(null);
631
- setHistoryHighlightIndex(0);
632
- return;
633
- }
634
- } else if (historyMenuStage === 'window') {
635
- if ((input === 'm' || input === 'M') && selectedHistory) {
636
- handleHistoryMenuAction('__toggle_mark');
637
- return;
638
- }
639
- if ((input === 'c' || input === 'C') && selectedHistory) {
640
- handleHistoryMenuAction('__continue');
641
- return;
642
- }
643
- if ((input === 'd' || input === 'D') && selectedHistory) {
644
- handleHistoryMenuAction('__delete');
645
- return;
646
- }
647
- if (input === 'q') {
648
- handleHistoryMenuAction('__back');
649
- return;
650
- }
651
- // Message scrolling
652
- if (key.upArrow || input === 'k') {
653
- setMsgsPage(p => Math.max(0, p - 1));
654
- return;
655
- }
656
- if (key.downArrow || input === 'j') {
657
- setMsgsPage(p => p + 1);
658
- return;
659
- }
660
-
661
- } else if (historyMenuStage === 'deleteConfirm') {
662
- if (input === 'q') {
663
- handleHistoryMenuAction('__cancel_delete');
664
- return;
665
- }
666
- }
667
- }
668
-
669
- // Settings scrolling
670
- if (!showHelp && page === 'settings' && settingData && typeof settingData === 'object') {
671
- const total = Object.keys(settingData).length;
672
- const visible = Math.max(1, MID_H - 1);
673
- if (key.downArrow || input === 'j') {
674
- setSettingsOffset(o => Math.min(Math.max(0, total - visible), o + 1));
675
- }
676
- if (key.upArrow || input === 'k') {
677
- setSettingsOffset(o => Math.max(0, o - 1));
678
- }
679
- }
680
- }, [menuItems, page, historyMenuStage, historyList, historyHasMore, navIndex, sessionMessages, settingData, MID_H, MSGS_PAGE_SIZE, showHelp, theme]);
681
-
682
- function cleanText(text: string): string {
683
- return String(text ?? '').replace(/[\n\r]+/g, ' ').replace(/\u001b\[[0-9;]*m/g, '').trim();
684
- }
685
-
686
- function clampTextLines(text: string, maxWidth: number, maxLines: number) {
687
- const cleaned = cleanText(text);
688
- const out: string[] = [];
689
- if (cleaned.length <= maxWidth) {
690
- out.push(cleaned);
691
- } else {
692
- out.push(cleaned.slice(0, maxWidth - 1) + '…');
693
- }
694
- return out.join('\n');
695
- }
696
-
697
- function makeHistoryLabel(item: any, width: number, isMarked: boolean) {
698
- const star = isMarked ? '' : '';
699
- const ts = String(item.createdAt || '').slice(0, 16).replace('T', ' ');
700
- const cnt = String(item.messageCount ?? 0).padStart(3, ' ');
701
- // Reserved width for: prefix(star+time) + spacer(2) + suffix(1+cnt)
702
- // Star is width 2, ts is width 16, spacer is 2, cnt is 3, padding is 1. Total = 24
703
- const reserved = 24;
704
- const titleMax = Math.max(8, width - reserved);
705
- const title = safePadEnd(truncate(String(item.title || ''), titleMax), titleMax);
706
- return `${star}${ts} ${title} ${cnt}`;
707
- }
708
-
709
- // 新增:会话恢复(供快捷键和右侧菜单复用)
710
- // workDir: 会话原始工作目录,用于跨文件夹恢复(确保新进程能找到 session 文件)
711
- async function resumeSession(sessionId: string, workDir?: string | null) {
712
- try {
713
- const fsReq = require('fs');
714
- const pathReq = require('path');
715
- const { spawn } = require('child_process');
716
- // import.meta.dir 定位包根,避免 process.cwd() 指向用户目录
717
- const pkgPath = pathReq.resolve(import.meta.dir, '../../package.json');
718
- const pkgJson = JSON.parse(fsReq.readFileSync(pkgPath, 'utf-8'));
719
- const bins = pkgJson.bin || {};
720
- const isWin = process.platform === 'win32';
721
- const binName = isWin
722
- ? (bins['claude-haha'] ? 'claude-haha' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]))
723
- : (bins['claude-linux'] ? 'claude-linux' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]));
724
- const spawnCmd = isWin ? 'cmd' : 'sh';
725
- // Windows 直接调全局 bingocode 命令,不用 bun 前缀
726
- const spawnArgs = isWin
727
- ? ['/c', 'start', 'cmd', '/k', `bingocode --resume ${sessionId}`]
728
- : ['-c', `${binName} --resume ${sessionId}`];
729
- const spawnEnv = await buildSpawnEnv();
730
- spawn(spawnCmd, spawnArgs, {
731
- cwd: workDir || process.env.CALLER_DIR || process.cwd(),
732
- env: spawnEnv,
733
- detached: true,
734
- stdio: 'ignore'
735
- }).unref();
736
- } catch {}
737
- }
738
-
739
-
740
- // 历史分组展示
741
- const groupedHistoryItems = useMemo(() => {
742
- if (!historyList || !Array.isArray(historyList)) return [];
743
- const now = new Date();
744
- const today: any[] = [];
745
- const week: any[] = [];
746
- const earlier: any[] = [];
747
- const marked: any[] = [];
748
-
749
- for (const item of historyList) {
750
- if (markedSessionIds.has(item.id)) {
751
- marked.push(item);
752
- continue;
753
- }
754
- const dt = new Date(item.createdAt);
755
- const isToday =
756
- dt.getFullYear() === now.getFullYear() &&
757
- dt.getMonth() === now.getMonth() &&
758
- dt.getDate() === now.getDate();
759
- const weekStart = new Date(now);
760
- weekStart.setDate(now.getDate() - ((now.getDay() + 6) % 7));
761
- weekStart.setHours(0, 0, 0, 0);
762
- if (isToday) today.push(item);
763
- else if (dt >= weekStart) week.push(item);
764
- else earlier.push(item);
765
- }
766
- function groupToItems(group: any[], groupTitle: string) {
767
- if (group.length === 0) return [];
768
- return [
769
- { label: groupTitle, value: `__group_${groupTitle}`, isGroup: true },
770
- ...group.map(item => {
771
- const isMarked = markedSessionIds.has(item.id);
772
- return {
773
- label: makeHistoryLabel(item, Math.max(20, VIEW_W - 8), isMarked),
774
- value: item.id,
775
- color: isMarked ? 'yellow' : undefined,
776
- };
777
- })
778
- ];
779
- }
780
- const items = [
781
- ...groupToItems(marked, '—— Marked ——'),
782
- ...groupToItems(today, '—— Today ——'),
783
- ...groupToItems(week, '—— This Week ——'),
784
- ...groupToItems(earlier, '—— Earlier ——'),
785
- ];
786
- return items;
787
- }, [historyList, markedSessionIds, VIEW_W]);
788
-
789
- // Toggle Mark
790
- const toggleMarkSession = (sessionId: string) => {
791
- setMarkedSessionIds(prev => {
792
- const next = new Set(prev);
793
- if (next.has(sessionId)) next.delete(sessionId);
794
- else next.add(sessionId);
795
- saveMarkedSessionIds(next);
796
- return next;
797
- });
798
- };
799
-
800
- const handleHistoryMenuAction = (action: string) => {
801
- if (action === '__back') {
802
- setHistoryMenuStage('list');
803
- setSelectedHistory(null);
804
- setMsgsPage(0);
805
- return;
806
- }
807
- if (!selectedHistory) return;
808
-
809
- switch (action) {
810
- case '__toggle_mark':
811
- toggleMarkSession(selectedHistory.id);
812
- break;
813
- case '__continue':
814
- resumeSession(selectedHistory.id, selectedHistory.workDir);
815
- break;
816
- case '__delete':
817
- setHistoryMenuStage('deleteConfirm');
818
- break;
819
- case '__confirm_delete':
820
- handleDeleteSession(selectedHistory.id);
821
- break;
822
- case '__cancel_delete':
823
- setHistoryMenuStage('window');
824
- break;
825
- }
826
- };
827
-
828
-
829
- // Refresh history
830
- const refreshHistoryList = () => {
831
- setLoadingHist(true); setHistErr(null);
832
- let url = apiUrl + '/api/sessions';
833
- axios.get(url).then(res => {
834
- const pageData = res.data;
835
- setHistoryList(pageData?.sessions || []);
836
- setHistoryCursor(pageData?.first_id || null);
837
- setHistoryHasMore(!!pageData?.has_more);
838
- }).catch(e => {
839
- setHistErr(e.message || 'Failed to fetch history');
840
- }).finally(() => setLoadingHist(false));
841
- };
842
-
843
- // Delete Session
844
- const handleDeleteSession = (sessionId: string) => {
845
- const url = apiUrl.replace(/\/+$/, '') + '/api/sessions/' + sessionId;
846
- axios.delete(url)
847
- .catch(e => {})
848
- .finally(() => {
849
- setHistoryMenuStage('list');
850
- setSelectedHistory(null);
851
- setHistoryCursor(null);
852
- refreshHistoryList();
853
- });
854
- };
855
-
856
- // Secondary menu (bottom bar right)
857
- const secondaryMenu: SecondaryMenu = useMemo(() => {
858
- if (page === 'history' && historyMenuStage === 'window' && selectedHistory) {
859
- const isMarked = markedSessionIds.has(selectedHistory.id);
860
- const markLabel = isMarked ? i18nMap[lang].unmark : i18nMap[lang].mark;
861
- return {
862
- title: 'Session Actions',
863
- items: [
864
- { label: markLabel, value: '__toggle_mark' },
865
- { label: '→ Continue session', value: '__continue' },
866
- { label: '→ Delete session', value: '__delete' },
867
- { label: '← Back to list', value: '__back' },
868
- ],
869
- onSelect: (item: any) => {
870
- if (item.value === '__back') {
871
- setHistoryMenuStage('list');
872
- setSelectedHistory(null);
873
- setMsgsPage(0);
874
- } else if (item.value === '__continue') {
875
- resumeSession(selectedHistory.id, selectedHistory.workDir);
876
- } else if (item.value === '__delete') {
877
- setHistoryMenuStage('deleteConfirm');
878
- } else if (item.value === '__toggle_mark') {
879
- toggleMarkSession(selectedHistory.id);
880
- }
881
- }
882
- };
883
- }
884
-
885
- if (page === 'history' && historyMenuStage === 'deleteConfirm' && selectedHistory) {
886
- return {
887
- title: 'Confirm Delete',
888
- items: [
889
- { label: 'Yes, delete', value: '__confirm_delete' },
890
- { label: 'No, back', value: '__cancel_delete' },
891
- ],
892
- onSelect: (item: any) => {
893
- if (item.value === '__cancel_delete') {
894
- setHistoryMenuStage('window');
895
- } else if (item.value === '__confirm_delete') {
896
- handleDeleteSession(selectedHistory.id);
897
- }
898
- }
899
- };
900
- }
901
- return null;
902
- }, [page, historyMenuStage, selectedHistory, markedSessionIds, lang]);
903
-
904
- // Help Overlay
905
- function renderHelpOverlay() {
906
- return (
907
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
908
- <Text color="magenta">{i18nMap[lang].helpTitle}</Text>
909
- <Text> </Text>
910
- <Text color="cyan">N</Text><Text> New Session</Text>
911
- <Text color="cyan">R</Text><Text> Quick Resume</Text>
912
- <Text color="cyan">P</Text><Text> Open Provider Config</Text>
913
- <Text color="cyan">G</Text><Text> Toggle Theme (light/dark/highContrast)</Text>
914
- <Text color="cyan">L</Text><Text> Toggle Language (en/zh)</Text>
915
- <Text color="cyan">O</Text><Text> Toggle Top Animation</Text>
916
- <Text color="cyan">T</Text><Text> Toggle Top Tips</Text>
917
- <Text color="cyan">?</Text><Text> Toggle Help</Text>
918
- <Text> </Text>
919
- <Hint>ESC to close · Works anywhere</Hint>
920
- </Box>
921
- );
922
- }
923
-
924
- // Center Content
925
- function renderCenter() {
926
- if (showHelp) return renderHelpOverlay();
927
-
928
- // Home: WelcomeV2 (58 cols wide)
929
- if (page === null) {
930
- return (
931
- <Box flexDirection="column" width={VIEW_W} height={MID_H}>
932
- <Box flexDirection="row" width={VIEW_W} flexGrow={1}>
933
- <Box paddingX={2}>
934
- <WelcomeV2 />
935
- </Box>
936
- <Box flexGrow={1} flexDirection="column" alignItems="flex-end" paddingRight={4}>
937
- <Text dimColor>(version information)</Text>
938
- </Box>
939
- </Box>
940
- {!apiUrl && !bootErr && (
941
- <StateDisplay type="loading" message="Starting server..." />
942
- )}
943
- {bootErr && (
944
- <StateDisplay type="error" message={`Server boot failed: ${bootErr}`} />
945
- )}
946
- </Box>
947
- );
948
- }
949
-
950
- // New Session
951
- if (page === 'newSession') {
952
- return (
953
- <Box flexDirection="column" width={VIEW_W} height={MID_H}>
954
- {creating && <StateDisplay type="loading" message="Creating..." />}
955
- {createErr && <StateDisplay type="error" message={`Failed to create: ${createErr}`} />}
956
- {newSessionId && <Box alignItems="center" justifyContent="center" flexGrow={1}><Text color="green">New Session: {newSessionId}</Text></Box>}
957
- {!creating && !createErr && !newSessionId && <StateDisplay type="empty" message="Entered new session page, waiting for result..." />}
958
- </Box>
959
- );
960
- }
961
-
962
- // History
963
- if (page === 'history') {
964
- if (histErr) return <StateDisplay type="error" message={histErr} onRetry={refreshHistoryList} />;
965
- if (historyMenuStage === 'deleteConfirm' && selectedHistory) {
966
- const halfH = Math.floor(MID_H / 2);
967
- const items = [
968
- { label: 'Yes, Delete', value: '__confirm_delete' },
969
- { label: 'No, Back', value: '__cancel_delete' },
970
- ];
971
- return (
972
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
973
- <Box height={halfH} flexDirection="column" paddingX={1} paddingTop={1}>
974
- <Text color="red" bold>Confirm Delete?</Text>
975
- <Text>Title: {selectedHistory.title || 'Untitled'}</Text>
976
- <Text dimColor>Time: {selectedHistory.createdAt?.replace('T',' ')}</Text>
977
- <Text dimColor>ID: {selectedHistory.id}</Text>
978
- </Box>
979
- <Panel height={MID_H - halfH} borderStyle="round" borderColor="red" paddingX={1}>
980
- <SelectInput
981
- items={items}
982
- onSelect={(item) => handleHistoryMenuAction(String(item.value))}
983
- />
984
- <Hint>Enter Confirm · q Cancel</Hint>
985
- </Panel>
986
- </Box>
987
- );
988
- }
989
- if (!historyList.length && loadingHist) {
990
- return <StateDisplay type="loading" message="Loading..." />;
991
- }
992
- if (!historyList.length) {
993
- return <StateDisplay type="empty" message={i18nMap[lang].emptyHistory} />;
994
- }
995
-
996
- const ACTIONS_H = 6;
997
- const LIST_H = Math.max(2, MID_H - ACTIONS_H - 1);
998
-
999
- if (historyMenuStage === 'window' && selectedHistory) {
1000
- // Detailed View with Split
1001
- const isMarked = markedSessionIds.has(selectedHistory.id);
1002
- const displayMsgs = sessionMessages.filter(
1003
- m => m.type === 'user' || m.type === 'assistant' || m.type === 'system'
1004
- );
1005
- const totalPages = Math.max(1, Math.ceil(displayMsgs.length / MSGS_PAGE_SIZE));
1006
- const safePage = Math.min(msgsPage, totalPages - 1);
1007
- const pageStart = safePage * MSGS_PAGE_SIZE;
1008
- const pageMsgs = displayMsgs.slice(pageStart, pageStart + MSGS_PAGE_SIZE);
1009
-
1010
- return (
1011
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1012
- {/* Upper Pane: Preview */}
1013
- <Box height={LIST_H} flexDirection="column" paddingX={1} overflow="hidden">
1014
- <Box justifyContent="space-between" marginBottom={0}>
1015
- <Text color={isMarked ? 'yellow' : 'cyan'} bold>
1016
- {isMarked ? '★ ' : ''}{truncate(selectedHistory.title || 'Untitled', VIEW_W - 24)}
1017
- </Text>
1018
- <Text dimColor>{selectedHistory.createdAt?.slice(0,16).replace('T',' ')}</Text>
1019
- </Box>
1020
-
1021
- <Box flexDirection="column" flexGrow={1} overflow="hidden">
1022
- {loadingMsgs && <StateDisplay type="loading" message="Loading messages..." />}
1023
- {msgsErr && <StateDisplay type="error" message={msgsErr} />}
1024
- {!loadingMsgs && pageMsgs.length === 0 && <StateDisplay type="empty" message="No messages" />}
1025
- {pageMsgs.map((msg) => {
1026
- const text = extractTextFromContent(msg.content);
1027
- const roleLabel = msg.type === 'user' ? 'You' : 'Bot';
1028
- const roleColor = msg.type === 'user' ? 'green' : 'cyan';
1029
- return (
1030
- <Box key={msg.id} marginBottom={0} flexDirection="column" height={1} overflow="hidden">
1031
- <Text color={roleColor} bold>{roleLabel}: <Text color="white" bold={false}>{clampTextLines(text, VIEW_W - 10, 1)}</Text></Text>
1032
- </Box>
1033
- );
1034
- })}
1035
- </Box>
1036
- {totalPages > 1 && (
1037
- <Box justifyContent="center" height={1}>
1038
- <Hint>Page {safePage + 1}/{totalPages} (↑↓ to scroll)</Hint>
1039
- </Box>
1040
- )}
1041
- </Box>
1042
-
1043
- <Box height={1} marginBottom={0}><Text dimColor>{'─'.repeat(VIEW_W - 2)}</Text></Box>
1044
-
1045
- {/* Lower Pane: Actions */}
1046
- <Box height={ACTIONS_H} paddingX={1} flexDirection="column" overflow="hidden">
1047
- <Text color="magenta" bold>Actions</Text>
1048
- <Box marginTop={0} height={ACTIONS_H - 2} overflow="hidden">
1049
- <SelectInput
1050
- items={secondaryMenu?.items || []}
1051
- onSelect={secondaryMenu?.onSelect}
1052
- />
1053
- </Box>
1054
- <Hint>ESC Back · ↑↓ Select Action · Q/M/C Shortcut</Hint>
1055
- </Box>
1056
- </Box>
1057
- );
1058
- }
1059
-
1060
- // History List View (Default)
1061
- const HIST_VISIBLE = MID_H - 2;
1062
-
1063
- return (
1064
- <Box width={VIEW_W} height={MID_H} flexDirection="row" position="relative">
1065
- <Box flexDirection="column" flexGrow={1} paddingX={1}>
1066
- <SelectInput
1067
- items={groupedHistoryItems}
1068
- limit={HIST_VISIBLE}
1069
- initialIndex={historyHighlightIndex}
1070
- onHighlight={item => {
1071
- const idx = groupedHistoryItems.findIndex(i => i.value === item.value);
1072
- if (idx !== -1) setHistoryHighlightIndex(idx);
1073
- }}
1074
- onSelect={item => {
1075
- if (String(item.value).startsWith('__group_')) return;
1076
- const session = historyList.find(h => h.id === item.value);
1077
- if (session) {
1078
- setSelectedHistory(session);
1079
- setHistoryMenuStage('window');
1080
- }
1081
- }}
1082
- itemComponent={({ isSelected, label }) => {
1083
- const it = groupedHistoryItems.find(i => i.label === label);
1084
- const isGroup = it?.isGroup;
1085
- const color = it?.color;
1086
- return (
1087
- <Box height={1} overflow="hidden">
1088
- <Text wrap="truncate" color={isGroup ? 'gray' : (color ? color : (isSelected ? 'cyan' : undefined))}>
1089
- {isSelected ? '> ' : ' '}{label}
1090
- </Text>
1091
- </Box>
1092
- )
1093
- }}
1094
- />
1095
- </Box>
1096
- <ScrollBar total={groupedHistoryItems.length} offset={historyHighlightIndex} height={MID_H - 2} />
1097
- <Box position="absolute" bottom={0} left={1} width={VIEW_W - 4}>
1098
- <Hint>{i18nMap[lang].historyHint}</Hint>
1099
- </Box>
1100
- </Box>
1101
- );
1102
- }
1103
-
1104
- // Provider
1105
- if (page === 'provider') {
1106
- if (!apiUrl) {
1107
- return (
1108
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1109
- <StateDisplay
1110
- type={bootErr ? "error" : "loading"}
1111
- message={bootErr ? `Server boot failed: ${bootErr}` : 'Starting server, please wait...'}
1112
- onRetry={() => process.exit(1)} // Or another way to trigger reboot
1113
- />
1114
- <Text dimColor alignSelf="center">ESC for main menu</Text>
1115
- </Box>
1116
- );
1117
- }
1118
- return (
1119
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1120
- <ProviderPanel apiUrl={apiUrl} height={MID_H} onBack={() => setPage(null)} />
1121
- </Box>
1122
- );
1123
- }
1124
-
1125
- // Settings
1126
- if (page === 'settings') {
1127
- if (loadingSetting) return <StateDisplay type="loading" message="Loading settings..." />;
1128
- if (setErr) return <StateDisplay type="error" message={setErr} />;
1129
- if (!settingData || typeof settingData !== 'object') return <StateDisplay type="empty" message="No settings found" />;
1130
- const entries = Object.entries(settingData);
1131
- const visible = Math.max(1, MID_H - 1);
1132
- const start = Math.min(settingsOffset, Math.max(0, entries.length - visible));
1133
- const sliced = entries.slice(start, start + visible);
1134
- return (
1135
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1136
- <ScrollBar total={entries.length} offset={start} height={visible - 1} />
1137
- {sliced.map(([k, v]) => <Text key={k}>{k}: {typeof v === 'object' ? JSON.stringify(v) : String(v)}</Text>)}
1138
- <Hint>
1139
- ↑/k and ↓/j scroll · {start+1}-{Math.min(start+visible, entries.length)}/{entries.length}
1140
- </Hint>
1141
- </Box>
1142
- );
1143
- }
1144
-
1145
- // About
1146
- if (page === 'about') {
1147
- const i18n = i18nMap[lang] as any;
1148
- return (
1149
- <Box width={VIEW_W} height={MID_H} flexDirection="column" paddingX={1}>
1150
- <Box flexGrow={1} flexDirection="column">
1151
- <Text color="cyan" bold>{i18n.about}</Text>
1152
- <Box marginTop={1} flexDirection="column">
1153
- <Text>{i18n.aboutContent}</Text>
1154
- </Box>
1155
- <Box marginTop={1}>
1156
- <Hint>API Base: {apiUrl}</Hint>
1157
- </Box>
1158
- </Box>
1159
- <Box paddingX={1} flexDirection="column" marginTop={1}>
1160
- <Text dimColor>{i18n.author}</Text>
1161
- <Text dimColor>{i18n.github}</Text>
1162
- </Box>
1163
- </Box>
1164
- );
1165
- }
1166
-
1167
- // Exit
1168
- if (page === 'exit') {
1169
- exit();
1170
- return <Box width={VIEW_W} height={MID_H}><Text>Exiting...</Text></Box>;
1171
- }
1172
-
1173
- return <Box width={VIEW_W} height={MID_H} />;
1174
- }
1175
-
1176
- // Exit logic
1177
- if (terminalSize.columns < 60 || terminalSize.rows < 15) {
1178
- return (
1179
- <Box flexDirection="column" padding={2}>
1180
- <Text color="red">Terminal too small!</Text>
1181
- <Text>Current: {terminalSize.columns}x{terminalSize.rows}</Text>
1182
- <Text>Please resize to continue...</Text>
1183
- </Box>
1184
- );
1185
- }
1186
-
1187
- // Root Render
1188
- return (
1189
- <Box flexDirection="column" width={VIEW_W}>
1190
- {/* Top Welcome / Logo Area + Toolbar */}
1191
- <TopBar
1192
- ready={configReady}
1193
- page={page}
1194
- width={VIEW_W}
1195
- height={TOP_H}
1196
- homeLogo={<LogoV2 />}
1197
- compactLogo={<CondensedLogo />}
1198
- ip={apiUrl ? apiUrl.replace(/^https?:\/\//, '').replace(':',' ') : undefined}
1199
- toolbar={
1200
- <TopToolbar
1201
- ready={configReady}
1202
- page={page}
1203
- animEnabled={animEnabled}
1204
- tipsEnabled={tipsEnabled}
1205
- />
1206
- }
1207
- />
1208
-
1209
- {/* Center Center Area */}
1210
- {page === null ? (
1211
- <Panel width={VIEW_W} height={MID_H} noBorder paddingX={0} paddingY={0} marginY={0}>
1212
- {renderCenter()}
1213
- </Panel>
1214
- ) : (
1215
- <Panel width={VIEW_W} height={MID_H} borderStyle="single" paddingX={1} paddingY={0} marginY={1}>
1216
- {renderCenter()}
1217
- </Panel>
1218
- )}
1219
-
1220
- {/* Bottom Menu & Secondary Menu */}
1221
- <BottomBar
1222
- width={VIEW_W}
1223
- height={BOTTOM_H}
1224
- menuItems={menuItems}
1225
- page={page}
1226
- navIndex={navIndex}
1227
- tips={i18nMap[lang].tipsSimple}
1228
- secondaryMenu={
1229
- page === 'history' && (historyMenuStage === 'window' || historyMenuStage === 'deleteConfirm')
1230
- ? null
1231
- : secondaryMenu
1232
- }
1233
- />
1234
- </Box>
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;