bingocode 1.1.200-beta.2 → 1.1.200-beta.21

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,1584 +1,1590 @@
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
- * Get the path to ~/.claude/bingo/settings.json (offline persistence for language
32
- * and auto-mode settings, same file used by provider service). This ensures
33
- * these settings survive across full restarts.
34
- */
35
- function getBingoSettingsPath(): string {
36
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
37
- return path.join(configDir, 'bingo', 'settings.json');
38
- }
39
-
40
- function readBingoSettings(): Record<string, unknown> {
41
- try {
42
- const raw = fs.readFileSync(getBingoSettingsPath(), 'utf-8');
43
- return JSON.parse(raw) as Record<string, unknown>;
44
- } catch {
45
- return {};
46
- }
47
- }
48
-
49
- function writeBingoSettings(updates: Record<string, unknown>): void {
50
- const p = getBingoSettingsPath();
51
- const dir = path.dirname(p);
52
- if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); }
53
-
54
- let current: Record<string, unknown> = {};
55
- try {
56
- if (fs.existsSync(p)) {
57
- const raw = fs.readFileSync(p, 'utf-8');
58
- current = JSON.parse(raw) as Record<string, unknown>;
59
- }
60
- } catch {}
61
-
62
- const merged = { ...current, ...updates };
63
-
64
- // atomic write via temp + rename
65
- const tmp = `${p}.tmp.${Date.now()}`;
66
- fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
67
- fs.renameSync(tmp, p);
68
- }
69
-
70
- // write yml
71
- function readGlobalClaudeConfig(): Record<string, unknown> {
72
- const configPath = path.join(os.homedir(), '.claude.json');
73
- try {
74
- const raw = fs.readFileSync(configPath, 'utf-8');
75
- return JSON.parse(raw) as Record<string, unknown>;
76
- } catch {
77
- return {};
78
- }
79
- }
80
-
81
- // write merge config directly to ~/.claude.json (atomic write)
82
- function writeGlobalClaudeConfig(updates: Record<string, unknown>): void {
83
- const configPath = path.join(os.homedir(), '.claude.json');
84
- const dir = path.dirname(configPath);
85
- if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); }
86
-
87
- let current: Record<string, unknown> = {};
88
- try {
89
- if (fs.existsSync(configPath)) {
90
- const raw = fs.readFileSync(configPath, 'utf-8');
91
- current = JSON.parse(raw) as Record<string, unknown>;
92
- }
93
- } catch {}
94
-
95
- const merged = { ...current, ...updates };
96
-
97
- // atomic write via temp + rename
98
- const tmp = `${configPath}.tmp.${Date.now()}`;
99
- fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
100
- fs.renameSync(tmp, configPath);
101
- }
102
-
103
- function readClaudeSettings(): Record<string, unknown> {
104
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
105
- const settingsPath = path.join(configDir, 'settings.json');
106
- try {
107
- const raw = fs.readFileSync(settingsPath, 'utf-8');
108
- return JSON.parse(raw) as Record<string, unknown>;
109
- } catch {
110
- return {};
111
- }
112
- }
113
-
114
- function writeClaudeSettings(updates: Record<string, unknown>): void {
115
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
116
- const settingsPath = path.join(configDir, 'settings.json');
117
- const dir = path.dirname(settingsPath);
118
- if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); }
119
-
120
- let current: Record<string, unknown> = {};
121
- try {
122
- if (fs.existsSync(settingsPath)) {
123
- const raw = fs.readFileSync(settingsPath, 'utf-8');
124
- current = JSON.parse(raw) as Record<string, unknown>;
125
- }
126
- } catch {}
127
-
128
- const merged = { ...current, ...updates };
129
-
130
- // atomic write via temp + rename
131
- const tmp = `${settingsPath}.tmp.${Date.now()}`;
132
- fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
133
- fs.renameSync(tmp, settingsPath);
134
- }
135
-
136
- /**
137
- * Determine if in "official" mode (no custom provider active).
138
- * Logic matches ConversationService.shouldMarkManagedOAuth().
139
- */
140
- function isOfficialMode(): boolean {
141
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
142
- const settingsPath = path.join(configDir, 'bingo', 'settings.json');
143
- try {
144
- const raw = fs.readFileSync(settingsPath, 'utf-8');
145
- const parsed = JSON.parse(raw) as { env?: Record<string, string> };
146
- const env = parsed.env ?? {};
147
- const hasProviderEnv = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']
148
- .some(key => typeof env[key] === 'string' && env[key]!.trim().length > 0);
149
- return !hasProviderEnv;
150
- } catch {
151
- return true; // Cannot read settings.json -> Treat as official mode
152
- }
153
- }
154
-
155
- /**
156
- * Build spawn env for child process.
157
- * In official mode, inject CLAUDE_CODE_ENTRYPOINT=claude-desktop + CLAUDE_CODE_OAUTH_TOKEN,
158
- * so new/resumed bingocode windows can use OAuth directly.
159
- */
160
- async function buildSpawnEnv(): Promise<NodeJS.ProcessEnv> {
161
- const base = { ...process.env };
162
- if (!isOfficialMode()) return base;
163
-
164
- // Official mode: mark as managed-OAuth and inject OAuth token
165
- base.CLAUDE_CODE_ENTRYPOINT = 'claude-desktop';
166
- try {
167
- const { hahaOAuthService } = await import('../server/services/hahaOAuthService.js');
168
- const token = await hahaOAuthService.ensureFreshAccessToken();
169
- if (token) {
170
- base.CLAUDE_CODE_OAUTH_TOKEN = token;
171
- } else {
172
- // No valid token -> don't inject, use normal login flow
173
- delete base.CLAUDE_CODE_OAUTH_TOKEN;
174
- }
175
- } catch {
176
- delete base.CLAUDE_CODE_OAUTH_TOKEN;
177
- }
178
- return base;
179
- }
180
-
181
- // Top height: Home = Clawd(3 rows) + border(2) = 5; Compact = 1 row + border(2) = 3
182
- const TOP_H_HOME = Number(process.env.CLI_TOP_H_HOME || 5);
183
- const TOP_H_COMPACT = Number(process.env.CLI_TOP_H_COMPACT || 3);
184
- // Bottom bar height
185
- const BOTTOM_H = Number(process.env.CLI_BOTTOM_H || 3);
186
-
187
- const LANG_OPTIONS = [
188
- { label: 'English', value: 'en' as const },
189
- { label: '中文', value: 'zh' as const },
190
- { label: '日本語', value: 'ja' as const },
191
- ];
192
-
193
- const i18nMap = {
194
- zh: {
195
- menu: {
196
- newSession: '新建会话',
197
- history: '会话历史',
198
- provider: 'API 配置',
199
- settings: '设置',
200
- about: '关于',
201
- exit: '退出',
202
- },
203
- about: 'Bingo CLI 终端 - 版本信息与关于',
204
- aboutContent: [
205
- 'Bingo 是一款 AI 助手终端客户端。',
206
- '1. API 配置:按 "P" 或选择「API 配置」来设置你的密钥。',
207
- '2. 模型槽:在 Provider 面板中配置各模型。',
208
- '3. 后台服务:Bingo 会运行一个本地服务器来管理会话。',
209
- '4. 开始聊天:在任意终端中运行 `bingocode` 或 `claude`。',
210
- ].join('\n'),
211
- aboutFooter: '作者: leanchy (leanchy07@outlook.com) · github.com/leanchy/claude-code-bingo',
212
- mark: ' 标记会话',
213
- unmark: '→ 取消标记',
214
- tipsSimple: 'L 语言 | ESC 返回 | ←→ 菜单 | ↩ 确认 | ? 帮助',
215
- noData: '暂无数据',
216
- emptyHistory: '还没有会话,要新建一个吗?',
217
- deleting: '确定删除此会话?(不可恢复)',
218
- historyHint: '↩ 打开 · j 下一页 · k 首页 · q 返回',
219
- helpTitle: '快捷键',
220
- // Settings page
221
- settingsTitle: '设置',
222
- langLabel: '语言',
223
- langPickerTitle: '选择语言',
224
- settingsHint: '↑/k ↓/j 滚动 · ↩ 切换 · ESC 返回',
225
- langOptions: LANG_OPTIONS,
226
- autoModeLabel: 'Auto Mode',
227
- autoModeOn: '已开启',
228
- autoModeOff: '已关闭',
229
- bypassPermsLabel: 'Bypass',
230
- bypassPermsOn: '已开启',
231
- bypassPermsOff: '已关闭',
232
- vscodeLabel: 'Connect to VS Code',
233
- vscodeOn: '已连接',
234
- vscodeOff: '未连接',
235
- },
236
- en: {
237
- menu: {
238
- newSession: 'New Session',
239
- history: 'Session History',
240
- provider: 'API Config',
241
- settings: 'Settings',
242
- about: 'About',
243
- exit: 'Exit',
244
- },
245
- about: 'Bingo CLI Terminal - Version Info & About',
246
- aboutContent: [
247
- 'Bingo is an AI assistant terminal client.',
248
- '1. API Config: Press "P" or select "API Config" to set up your keys.',
249
- '2. Model Slots: Configure specific models in the Provider panel.',
250
- '3. Background Service: Bingo runs a local server to manage sessions.',
251
- '4. Start Chat: Run `bingocode` or `claude` in any terminal to start.',
252
- ].join('\n'),
253
- aboutFooter: 'Author: leanchy (leanchy07@outlook.com) · github.com/leanchy/claude-code-bingo',
254
- mark: ' Mark Session',
255
- unmark: '→ Unmark Session',
256
- tipsSimple: 'L Lang | ESC Back | ←→ Menu | ↩ Enter | ? Help',
257
- noData: 'No data',
258
- emptyHistory: 'Nothing here yet. Start a new session?',
259
- deleting: 'Delete this session? (Irreversible)',
260
- historyHint: 'Enter to open · j next · k first · q back',
261
- helpTitle: 'Shortcuts',
262
- // Settings page
263
- settingsTitle: 'Settings',
264
- langLabel: 'Language',
265
- langPickerTitle: 'Select Language',
266
- settingsHint: '↑/k ↓/j scroll · ↩ toggle · ESC back',
267
- langOptions: LANG_OPTIONS,
268
- autoModeLabel: 'Auto Mode',
269
- autoModeOn: 'Enabled',
270
- autoModeOff: 'Disabled',
271
- bypassPermsLabel: 'Bypass',
272
- bypassPermsOn: 'Enabled',
273
- bypassPermsOff: 'Disabled',
274
- vscodeLabel: 'Connect to VS Code',
275
- vscodeOn: 'Connected',
276
- vscodeOff: 'Disconnected',
277
- },
278
- ja: {
279
- menu: {
280
- newSession: '新規セッション',
281
- history: 'セッション履歴',
282
- provider: 'API設定',
283
- settings: '設定',
284
- about: 'について',
285
- exit: '終了',
286
- },
287
- about: 'Bingo CLI ターミナル - バージョン情報',
288
- aboutContent: [
289
- 'BingoはAIアシスタントのターミナルクライアントです。',
290
- '1. API設定: "P"キーまたは「API設定」を選択してキーを設定。',
291
- '2. モデルスロット: Providerパネルで各モデルを設定。',
292
- '3. バックグラウンドサービス: セッション管理用ローカルサーバーを起動。',
293
- '4. チャット開始: 任意のターミナルで `bingocode` または `claude` を実行。',
294
- ].join('\n'),
295
- aboutFooter: '作者: leanchy (leanchy07@outlook.com) · github.com/leanchy/claude-code-bingo',
296
- mark: ' セッションをマーク',
297
- unmark: '→ マークを解除',
298
- tipsSimple: 'L 言語 | ESC 戻る | ←→ メニュー | ↩ 決定 | ? ヘルプ',
299
- noData: 'データなし',
300
- emptyHistory: 'まだセッションがありません。新規作成しますか?',
301
- deleting: 'このセッションを削除しますか?(元に戻せません)',
302
- historyHint: '↩ 開く · j 次へ · k 最初へ · q 戻る',
303
- helpTitle: 'ショートカット',
304
- // Settings page
305
- settingsTitle: '設定',
306
- langLabel: '言語',
307
- langPickerTitle: '言語を選択',
308
- settingsHint: '↑/k ↓/j スクロール · ↩ 切替 · ESC 戻る',
309
- langOptions: LANG_OPTIONS,
310
- autoModeLabel: 'Auto Mode',
311
- autoModeOn: '有効',
312
- autoModeOff: '無効',
313
- bypassPermsLabel: 'Bypass',
314
- bypassPermsOn: '有効',
315
- bypassPermsOff: '無効',
316
- vscodeLabel: 'Connect to VS Code',
317
- vscodeOn: '接続済',
318
- vscodeOff: '未接続',
319
- },
320
- };
321
-
322
- const menuKeys = [
323
- 'newSession', 'history', 'provider', 'settings', 'about', 'exit'
324
- ] as const;
325
- type MenuKey = typeof menuKeys[number];
326
- type Lang = keyof typeof i18nMap;
327
-
328
- //@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>
329
- function loadMarkedSessionIds(): Set<string> {
330
- try {
331
- const arr = JSON.parse(fs.readFileSync(MARKED_FILE, 'utf-8'));
332
- return new Set(typeof arr === 'object' && Array.isArray(arr) ? arr : []);
333
- } catch {
334
- return new Set();
335
- }
336
- }
337
-
338
- //@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
339
- function saveMarkedSessionIds(set: Set<string>) {
340
- try {
341
- const dir = path.dirname(MARKED_FILE);
342
- if (!fs.existsSync(dir)) {
343
- fs.mkdirSync(dir, { recursive: true });
344
- }
345
- fs.writeFileSync(MARKED_FILE, JSON.stringify([...set]), 'utf-8');
346
- } catch (err) {
347
- console.error('[saveMarkedSessionIds] Save failed:', err);
348
- }
349
- }
350
-
351
- // Message Entry (Aligned with backend MessageEntry)
352
- type MessageEntry = {
353
- id: string;
354
- type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result';
355
- content: unknown; // string ContentBlock[]
356
- timestamp: string;
357
- model?: string;
358
- parentUuid?: string;
359
- parentToolUseId?: string;
360
- isSidechain?: boolean;
361
- };
362
-
363
- /** Extract plain text from MessageEntry.content */
364
- function extractTextFromContent(content: unknown): string {
365
- if (typeof content === 'string') return content;
366
- if (Array.isArray(content)) {
367
- return content
368
- .map((block: any) => {
369
- if (block.type === 'text' && typeof block.text === 'string') return block.text;
370
- if (block.type === 'tool_use') return `[Tool: ${block.name || 'unknown'}]`;
371
- if (block.type === 'tool_result') {
372
- if (typeof block.content === 'string') return block.content;
373
- if (Array.isArray(block.content)) {
374
- return block.content
375
- .filter((b: any) => b.type === 'text')
376
- .map((b: any) => b.text)
377
- .join('\n');
378
- }
379
- return '[Tool Result]';
380
- }
381
- return '';
382
- })
383
- .filter(Boolean)
384
- .join('\n');
385
- }
386
- return String(content ?? '');
387
- }
388
-
389
- //@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
390
- export const CliMenuManager: React.FC = () => {
391
- const { stdout } = useStdout();
392
- const [terminalSize, setTerminalSize] = useState({
393
- columns: stdout?.columns || 80,
394
- rows: stdout?.rows || 24
395
- });
396
-
397
- useEffect(() => {
398
- const onResize = () => {
399
- setTerminalSize({
400
- columns: stdout?.columns || 80,
401
- rows: stdout?.rows || 24
402
- });
403
- };
404
- stdout?.on('resize', onResize);
405
- return () => { stdout?.off('resize', onResize); };
406
- }, [stdout]);
407
-
408
- // Dynamic viewport
409
- const VIEW_W = Number(process.env.CLI_VIEW_W || Math.min(terminalSize.columns, 96));
410
- const VIEW_H = Number(process.env.CLI_VIEW_H || terminalSize.rows);
411
-
412
- const [apiUrl, setApiUrl] = useState<string | null>(process.env.BASE_API_URL || null);
413
- const [stopIfLast, setStopIfLast] = useState<null | (() => Promise<void>)>(null);
414
- const [bootErr, setBootErr] = useState<string | null>(null);
415
- const { exit } = useApp();
416
-
417
- // Theme (Global Hook)
418
- const [theme, setTheme] = useTheme();
419
-
420
- // Language
421
- const [lang, setLang] = useState<Lang>('en');
422
-
423
- // Config ready probe (avoid Logo early read)
424
- const [configReady, setConfigReady] = useState(false);
425
-
426
- // Load settings from bingo/settings.json at startup
427
- // (bypasses configReady to avoid stale lock issues)
428
- useEffect(() => {
429
- try {
430
- const bSettings = readBingoSettings();
431
- const bingoLang = bSettings.language as string | undefined;
432
- if (bingoLang && (bingoLang === 'en' || bingoLang === 'zh' || bingoLang === 'ja')) {
433
- setLang(bingoLang as Lang);
434
- }
435
- if (typeof bSettings.autoModeEnabled === 'boolean') {
436
- setAutoModeEnabled(bSettings.autoModeEnabled);
437
- }
438
- if (typeof bSettings.bypassPermsEnabled === 'boolean') {
439
- setBypassPermsEnabled(bSettings.bypassPermsEnabled);
440
- }
441
- if (typeof bSettings.vscodeLinked === 'boolean') {
442
- setVscodeLinked(bSettings.vscodeLinked);
443
- }
444
- } catch {}
445
- }, []);
446
-
447
- useEffect(() => {
448
- if (configReady) {
449
- try {
450
- const cfg = getGlobalConfig();
451
- if (typeof cfg.uiAnimEnabled === 'boolean') setAnimEnabled(cfg.uiAnimEnabled);
452
- if (typeof cfg.uiTipsEnabled === 'boolean') setTipsEnabled(cfg.uiTipsEnabled);
453
- } catch {}
454
- }
455
- }, [configReady]);
456
-
457
- const t = i18nMap[lang].menu;
458
-
459
- // Top time
460
- const [nowStr, setNowStr] = useState<string>(new Date().toLocaleString('en-US', { hour12: false }));
461
- useEffect(() => {
462
- const id = setInterval(() => setNowStr(new Date().toLocaleString('en-US', { hour12: false })), 1000);
463
- return () => clearInterval(id);
464
- }, []);
465
-
466
- // Main Menu
467
- const [page, setPage] = useState<MenuKey | null>(null);
468
- const menuItems = useMemo(() => menuKeys.map(key => ({ label: t[key], value: key })), [t]);
469
- const [navIndex, setNavIndex] = useState(0);
470
-
471
- // New Session
472
- const [newSessionId, setNewSessionId] = useState<string | null>(null);
473
- const [creating, setCreating] = useState(false);
474
- const [createErr, setCreateErr] = useState<string | null>(null);
475
-
476
- // History
477
- const [loadingHist, setLoadingHist] = useState(false);
478
- const [historyList, setHistoryList] = useState<any[]>([]);
479
- const [historyCursor, setHistoryCursor] = useState<string | null>(null);
480
- const [historyHasMore, setHistoryHasMore] = useState<boolean>(false);
481
- const [histErr, setHistErr] = useState<string | null>(null);
482
- const [historyMenuStage, setHistoryMenuStage] = useState<'list'|'window'|'deleteConfirm'>('list');
483
- const [selectedHistory, setSelectedHistory] = useState<any|null>(null);
484
-
485
- // History Messages
486
- const [sessionMessages, setSessionMessages] = useState<MessageEntry[]>([]);
487
- const [loadingMsgs, setLoadingMsgs] = useState(false);
488
- const [msgsErr, setMsgsErr] = useState<string | null>(null);
489
- const [msgsPage, setMsgsPage] = useState(0);
490
-
491
- // Mark Persistence
492
- const [markedSessionIds, setMarkedSessionIds] = useState<Set<string>>(new Set());
493
-
494
- // Settings page scroll offset
495
- const [settingsOffset, setSettingsOffset] = useState(0);
496
- const [settingData, setSettingData] = useState<any>(null);
497
- const [loadingSetting, setLoadingSetting] = useState(false);
498
- const [setErr, setSetErr] = useState<string | null>(null);
499
- const [settingsStage, setSettingsStage] = useState<'list' | 'langPicker'>('list');
500
- const [settingsCursor, setSettingsCursor] = useState(0);
501
- const [autoModeEnabled, setAutoModeEnabled] = useState(false);
502
- const [bypassPermsEnabled, setBypassPermsEnabled] = useState(false);
503
- const [vscodeLinked, setVscodeLinked] = useState(false);
504
-
505
- // Top toolbar state
506
- const [animEnabled, setAnimEnabled] = useState(true);
507
- const [tipsEnabled, setTipsEnabled] = useState(true);
508
-
509
- // Help overlay
510
- const [showHelp, setShowHelp] = useState(false);
511
-
512
- // Keyboard navigation for lists
513
- const [listOffset, setListOffset] = useState(0);
514
-
515
- // Quick Resume (R)
516
- const [quickResumeRequested, setQuickResumeRequested] = useState(false);
517
-
518
- // Compute viewport
519
- const TOP_H = page === null ? TOP_H_HOME : TOP_H_COMPACT;
520
- const MID_H = Math.max(5, VIEW_H - TOP_H - BOTTOM_H - (page === null ? 0 : 2));
521
- const MSGS_PAGE_SIZE = Math.max(1, MID_H - 2);
522
- const [expandMsgs, setExpandMsgs] = useState(false);
523
-
524
- // Boot/Reuse singleton local server (with retry)
525
- useEffect(() => {
526
- let mounted = true;
527
- (async () => {
528
- if (apiUrl) return;
529
- const entry = path.resolve(import.meta.dir, '../server/index.ts');
530
- const MAX_RETRIES = 3;
531
- const RETRY_DELAYS = [0, 2000, 5000]; // 0s, 2s, 5s
532
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
533
- if (!mounted) return;
534
- if (attempt > 0) {
535
- setBootErr(`Attempt ${attempt} failed, retrying in ${RETRY_DELAYS[attempt] / 1000}s...`);
536
- await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
537
- }
538
- if (!mounted) return;
539
- try {
540
- const handle = await ensureSingletonLocalServer({ serverEntry: entry });
541
- if (!mounted) { await handle.stopIfLast(); return; }
542
- setApiUrl(handle.baseUrl);
543
- setStopIfLast(() => handle.stopIfLast);
544
- setBootErr(null);
545
- return; // Success, exit retry
546
- } catch (e: any) {
547
- if (attempt === MAX_RETRIES - 1) {
548
- setBootErr(e.message || 'Local server failed to start');
549
- }
550
- }
551
- }
552
- })();
553
- return () => { mounted = false; if (stopIfLast) stopIfLast(); };
554
- }, []);
555
- useEffect(() => {
556
- let cancelled = false;
557
- const probe = () => {
558
- try {
559
- getGlobalConfig();
560
- if (!cancelled) setConfigReady(true);
561
- } catch {
562
- if (!cancelled) setTimeout(probe, 60);
563
- }
564
- };
565
- probe();
566
- return () => { cancelled = true; };
567
- }, []);
568
-
569
- // Init marks
570
- useEffect(() => {
571
- setMarkedSessionIds(loadMarkedSessionIds());
572
- }, []);
573
-
574
- // Page switch reset
575
- useEffect(() => {
576
- if (page === 'newSession') {
577
- setNewSessionId(null);
578
- setCreating(false);
579
- setCreateErr(null);
580
- }
581
- if (page !== 'settings') {
582
- setSettingsOffset(0);
583
- setSettingsStage('list');
584
- setSettingsCursor(0);
585
- }
586
- // Close help overlay
587
- setShowHelp(false);
588
- }, [page]);
589
-
590
- // History page entry reset
591
- useEffect(() => {
592
- if (page === 'history') {
593
- setHistoryMenuStage('list');
594
- setSelectedHistory(null);
595
- setHistoryCursor(null);
596
- setSessionMessages([]);
597
- setMsgsErr(null);
598
- setMsgsPage(0);
599
- setExpandMsgs(false);
600
- }
601
- }, [page]);
602
-
603
- // Create Session
604
- const onCreateSession = async () => {
605
- setCreating(true); setCreateErr(null);
606
- try {
607
- const fsReq = require('fs');
608
- const pathReq = require('path');
609
- const { spawn } = require('child_process');
610
- // Use import.meta.dir for pkg root
611
- const pkgPath = pathReq.resolve(import.meta.dir, '../../package.json');
612
- const pkgJson = JSON.parse(fsReq.readFileSync(pkgPath, 'utf-8'));
613
- const bins = pkgJson.bin || {};
614
- const isWin = process.platform === 'win32';
615
- const binName = isWin
616
- ? (bins['claude-haha'] ? 'claude-haha' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]))
617
- : (bins['claude-linux'] ? 'claude-linux' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]));
618
- const spawnCmd = isWin ? 'cmd' : 'sh';
619
- // Windows calls global bingocode directly
620
- const spawnArgs = isWin ? ['/c', 'start', 'cmd', '/k', 'bingocode'] : ['-c', `${binName}`];
621
- const spawnEnv = await buildSpawnEnv();
622
- spawn(spawnCmd, spawnArgs, {
623
- cwd: process.env.CALLER_DIR || process.cwd(),
624
- env: spawnEnv,
625
- detached: true,
626
- stdio: 'ignore'
627
- }).unref();
628
- setNewSessionId('Started: ' + binName);
629
- } catch(e: any) {
630
- setCreateErr(e.message || 'Failed to create');
631
- } finally {
632
- setCreating(false);
633
- }
634
- };
635
-
636
- // Paged loading for history
637
- useEffect(() => {
638
- if (page === 'history' && historyMenuStage === 'list') {
639
- setLoadingHist(true); setHistErr(null);
640
- (async () => {
641
- try {
642
- let url = apiUrl + '/api/sessions';
643
- if (historyCursor) url += `?cursor=${historyCursor}`;
644
- const res = await axios.get(url);
645
- const pageData = res.data;
646
- setHistoryList(pageData?.sessions || []);
647
- if (historyCursor === null) {
648
- setHistoryCursor(pageData?.first_id || null);
649
- }
650
- setHistoryHasMore(!!pageData?.has_more);
651
- } catch (e: any) {
652
- setHistErr(e.message || 'Failed to fetch history');
653
- } finally {
654
- setLoadingHist(false);
655
- }
656
- })();
657
- }
658
- if (page !== 'history') {
659
- setLoadingHist(false);
660
- setHistErr(null);
661
- setHistoryList([]);
662
- }
663
- }, [page, historyCursor, historyMenuStage, apiUrl]);
664
-
665
- // 快速恢复:当按下 R 并已加载历史列表后,自动进入第一个会话窗口
666
- useEffect(() => {
667
- if (page === 'history' && historyMenuStage === 'list' && quickResumeRequested && historyList.length) {
668
- const session = historyList[0];
669
- if (session) {
670
- setSelectedHistory(session);
671
- setHistoryMenuStage('window');
672
- setQuickResumeRequested(false);
673
- }
674
- }
675
- }, [page, historyMenuStage, quickResumeRequested, historyList]);
676
-
677
- // 会话消息获取
678
- useEffect(() => {
679
- if (page === 'history' && historyMenuStage === 'window' && selectedHistory && apiUrl) {
680
- let cancelled = false;
681
- setLoadingMsgs(true);
682
- setMsgsErr(null);
683
- setSessionMessages([]);
684
- setMsgsPage(0);
685
- (async () => {
686
- try {
687
- const resp = await axios.get(`${apiUrl}/api/sessions/${selectedHistory.id}/messages`);
688
- if (!cancelled) {
689
- const msgs: MessageEntry[] = resp.data?.messages ?? [];
690
- setSessionMessages(msgs);
691
- }
692
- } catch (e: any) {
693
- if (!cancelled) setMsgsErr(e.message || 'Failed to load messages');
694
- } finally {
695
- if (!cancelled) setLoadingMsgs(false);
696
- }
697
- })();
698
- return () => { cancelled = true; };
699
- } else {
700
- setSessionMessages([]);
701
- setLoadingMsgs(false);
702
- setMsgsErr(null);
703
- }
704
- }, [page, historyMenuStage, selectedHistory, apiUrl]);
705
-
706
- // Settings data
707
- useEffect(() => {
708
- if (page === 'settings') {
709
- setLoadingSetting(true); setSetErr(null);
710
- (async () => {
711
- try {
712
- const data = (await import('../utils/settings/settings')).default;
713
- setSettingData(data);
714
- } catch(e: any) {
715
- setSetErr(e.message||'Failed to load settings');
716
- } finally { setLoadingSetting(false); }
717
- })();
718
- } else {
719
- setSettingData(null);
720
- setLoadingSetting(false);
721
- setSetErr(null);
722
- }
723
- }, [page]);
724
-
725
- // Keyboard interactions
726
- useInput((input, key) => {
727
- // Language toggle (en → zh → ja → en)
728
- if (input === 'l' || input === 'L') {
729
- const langOrder: Lang[] = ['en', 'zh', 'ja'];
730
- const nextLang = langOrder[(langOrder.indexOf(lang) + 1) % langOrder.length];
731
- setLang(nextLang);
732
- try { writeBingoSettings({ language: nextLang }); } catch {}
733
- return;
734
- }
735
-
736
- // Theme toggle (G)
737
- if ((input === 'g' || input === 'G')) {
738
- const order = ['light', 'dark', 'highContrast'] as const;
739
- const curr = String(theme || 'light');
740
- const idx = Math.max(0, order.indexOf(curr as any));
741
- const next = order[(idx + 1) % order.length];
742
- setTheme(next as any);
743
- try { saveGlobalConfig(current => ({ ...current, theme: next as any })); } catch {}
744
- return;
745
- }
746
-
747
- // Top animation toggle (O)
748
- if (input === 'o' || input === 'O') {
749
- setAnimEnabled(v => {
750
- const next = !v;
751
- try { saveGlobalConfig(current => ({ ...current, uiAnimEnabled: next })); } catch {}
752
- return next;
753
- });
754
- return;
755
- }
756
- // Top Tips toggle (T)
757
- if (input === 't' || input === 'T') {
758
- setTipsEnabled(v => {
759
- const next = !v;
760
- try { saveGlobalConfig(current => ({ ...current, uiTipsEnabled: next })); } catch {}
761
- return next;
762
- });
763
- return;
764
- }
765
-
766
- // Help overlay (?)
767
- if (input === '?') {
768
- setShowHelp(v => !v);
769
- return;
770
- }
771
-
772
- // ESC to back or close help
773
- if (key.escape) {
774
- if (showHelp) { setShowHelp(false); return; }
775
- if (page === 'provider') return; // Handled internally
776
- // Settings: langPicker → back to list; list → back to main menu
777
- if (page === 'settings') {
778
- if (settingsStage === 'langPicker') { setSettingsStage('list'); return; }
779
- }
780
- setPage(null);
781
- setHistoryMenuStage('list');
782
- setSelectedHistory(null);
783
- setHistoryCursor(null);
784
- setSessionMessages([]);
785
- setMsgsPage(0);
786
- setSettingsOffset(0);
787
- return;
788
- }
789
-
790
- // Quick entries: N New, R Resume, P Provider
791
- if (input === 'n' || input === 'N') {
792
- setPage('newSession');
793
- onCreateSession();
794
- return;
795
- }
796
- if (input === 'r' || input === 'R') {
797
- setPage('history');
798
- setQuickResumeRequested(true);
799
- return;
800
- }
801
- if (input === 'p' || input === 'P') {
802
- setPage('provider');
803
- return;
804
- }
805
-
806
- // Main menu navigation
807
- if (!showHelp && key.leftArrow && page === null) {
808
- setNavIndex(i => (i - 1 + menuItems.length) % menuItems.length);
809
- return;
810
- }
811
- if (!showHelp && key.rightArrow && page === null) {
812
- setNavIndex(i => (i + 1) % menuItems.length);
813
- return;
814
- }
815
- if (!showHelp && key.return && page === null) {
816
- const keyVal = menuItems[navIndex].value as MenuKey;
817
- setPage(keyVal);
818
- if (keyVal === 'newSession') onCreateSession();
819
- if (keyVal === 'exit') exit();
820
- return;
821
- }
822
-
823
- // History shortcuts
824
- if (!showHelp && page === 'history') {
825
- if (historyMenuStage === 'list') {
826
- const HIST_VISIBLE = MID_H - 2;
827
- if (key.downArrow || input === 'j' || input === '\u001b[B') {
828
- // Internal SelectInput handles cursor, we just need to track offset for ScrollBar
829
- setListOffset(o => Math.min(o + 1, Math.max(0, groupedHistoryItems.length - HIST_VISIBLE)));
830
- }
831
- if (key.upArrow || input === 'k' || input === '\u001b[A') {
832
- setListOffset(o => Math.max(0, o - 1));
833
- }
834
- if (input === 'q') {
835
- setPage(null);
836
- setHistoryMenuStage('list');
837
- setSelectedHistory(null);
838
- setHistoryCursor(null);
839
- setListOffset(0);
840
- return;
841
- }
842
- if (input === 'j' && historyHasMore) {
843
- setHistoryCursor(historyList[historyList.length - 1]?.id || null);
844
- setListOffset(0);
845
- return;
846
- }
847
- if (input === 'k') {
848
- setHistoryCursor(null);
849
- setListOffset(0);
850
- return;
851
- }
852
- } else if (historyMenuStage === 'window') {
853
- if ((input === 'm' || input === 'M') && selectedHistory) {
854
- handleHistoryMenuAction('__toggle_mark');
855
- return;
856
- }
857
- if ((input === 'c' || input === 'C') && selectedHistory) {
858
- handleHistoryMenuAction('__continue');
859
- return;
860
- }
861
- if ((input === 'd' || input === 'D') && selectedHistory) {
862
- handleHistoryMenuAction('__delete');
863
- return;
864
- }
865
- if (input === 'q') {
866
- handleHistoryMenuAction('__back');
867
- return;
868
- }
869
- // Message scrolling
870
- if (key.upArrow || input === 'k') {
871
- setMsgsPage(p => Math.max(0, p - 1));
872
- return;
873
- }
874
- if (key.downArrow || input === 'j') {
875
- setMsgsPage(p => p + 1);
876
- return;
877
- }
878
-
879
- } else if (historyMenuStage === 'deleteConfirm') {
880
- if (input === 'q') {
881
- handleHistoryMenuAction('__cancel_delete');
882
- return;
883
- }
884
- }
885
- }
886
-
887
- // Settings interactions
888
- if (!showHelp && page === 'settings') {
889
- if (settingsStage === 'list') {
890
- // +1 for the fixed Language row prepended before settingData entries
891
- const totalRows = 4 + (settingData && typeof settingData === 'object' ? Object.keys(settingData).length : 0);
892
- const visible = Math.max(1, MID_H - 2);
893
- if (key.downArrow || input === 'j') {
894
- setSettingsCursor(c => Math.min(totalRows - 1, c + 1));
895
- setSettingsOffset(o => Math.min(Math.max(0, totalRows - visible), o + 1));
896
- }
897
- if (key.upArrow || input === 'k') {
898
- setSettingsCursor(c => Math.max(0, c - 1));
899
- setSettingsOffset(o => Math.max(0, o - 1));
900
- }
901
- if (key.return) {
902
- // Row 0 is the interactive Language row
903
- if (settingsCursor === 0) {
904
- setSettingsStage('langPicker');
905
- } else if (settingsCursor === 1) {
906
- // Row 1: toggle Auto Mode
907
- setAutoModeEnabled(prev => {
908
- const next = !prev;
909
- try {
910
- writeBingoSettings({ autoModeEnabled: next });
911
- const gcfg = readGlobalClaudeConfig();
912
- gcfg.cachedGrowthBookFeatures = {
913
- ...(gcfg.cachedGrowthBookFeatures as Record<string, unknown>),
914
- tengu_auto_mode_config: next
915
- ? { enabled: 'enabled', allowModels: ['*'] }
916
- : { enabled: 'disabled' },
917
- };
918
- writeGlobalClaudeConfig(gcfg);
919
- } catch {
920
- return prev; // write failed — keep old state
921
- }
922
- return next;
923
- });
924
- } else if (settingsCursor === 2) {
925
- // Row 2: toggle Bypass Permissions
926
- setBypassPermsEnabled(prev => {
927
- const next = !prev;
928
- try {
929
- writeBingoSettings({ bypassPermsEnabled: next });
930
- const safeSettings = next
931
- ? { permissions: { defaultMode: 'bypassPermissions', skipDangerousModePermissionPrompt: true } }
932
- : { permissions: { defaultMode: 'default' } };
933
- writeClaudeSettings(safeSettings);
934
- } catch {
935
- return prev; // write failed keep old state
936
- }
937
- return next;
938
- });
939
- } else if (settingsCursor === 3) {
940
- // Row 3: toggle VS Code link
941
- const port = (process.env.BINGO_PORT ? parseInt(process.env.BINGO_PORT) : 3456) || 3456;
942
- const apiBase = process.env.BASE_API_URL || `http://127.0.0.1:${port}`;
943
- setVscodeLinked(prev => {
944
- const next = !prev;
945
- try {
946
- writeBingoSettings({ vscodeLinked: next });
947
- } catch {
948
- return prev;
949
- }
950
- const http = require('http');
951
- if (next) {
952
- http.request(`${apiBase}/api/providers/link-vscode`, { method: 'POST' })
953
- .on('error', () => {}).end();
954
- } else {
955
- http.request(`${apiBase}/api/providers/link-vscode`, { method: 'DELETE' })
956
- .on('error', () => {}).end();
957
- }
958
- return next;
959
- });
960
- }
961
- }
962
- }
963
- // langPicker stage: ESC handled above; selection via SelectInput onSelect
964
- }
965
- }, [menuItems, page, historyMenuStage, historyList, historyHasMore, navIndex, sessionMessages, settingData, MID_H, MSGS_PAGE_SIZE, showHelp, theme, settingsStage, settingsCursor, autoModeEnabled, bypassPermsEnabled, vscodeLinked]);
966
-
967
- function cleanText(text: string): string {
968
- return String(text ?? '').replace(/[\n\r]+/g, ' ').replace(/\u001b\[[0-9;]*m/g, '').trim();
969
- }
970
-
971
- function clampTextLines(text: string, maxWidth: number, maxLines: number) {
972
- const cleaned = cleanText(text);
973
- const out: string[] = [];
974
- if (cleaned.length <= maxWidth) {
975
- out.push(cleaned);
976
- } else {
977
- out.push(cleaned.slice(0, maxWidth - 1) + '…');
978
- }
979
- return out.join('\n');
980
- }
981
-
982
- function makeHistoryLabel(item: any, width: number, isMarked: boolean) {
983
- const star = isMarked ? '★ ' : '';
984
- const ts = String(item.createdAt || '').slice(0, 16).replace('T', ' ');
985
- const cnt = String(item.messageCount ?? 0).padStart(3, ' ');
986
- // Reserved width for: prefix(star+time) + spacer(2) + suffix(1+cnt)
987
- // Star is width 2, ts is width 16, spacer is 2, cnt is 3, padding is 1. Total = 24
988
- const reserved = 24;
989
- const titleMax = Math.max(8, width - reserved);
990
- const title = safePadEnd(truncate(String(item.title || ''), titleMax), titleMax);
991
- return `${star}${ts} ${title} ${cnt}`;
992
- }
993
-
994
- // 新增:会话恢复(供快捷键和右侧菜单复用)
995
- // workDir: 会话原始工作目录,用于跨文件夹恢复(确保新进程能找到 session 文件)
996
- async function resumeSession(sessionId: string, workDir?: string | null) {
997
- try {
998
- const fsReq = require('fs');
999
- const pathReq = require('path');
1000
- const { spawn } = require('child_process');
1001
- // import.meta.dir 定位包根,避免 process.cwd() 指向用户目录
1002
- const pkgPath = pathReq.resolve(import.meta.dir, '../../package.json');
1003
- const pkgJson = JSON.parse(fsReq.readFileSync(pkgPath, 'utf-8'));
1004
- const bins = pkgJson.bin || {};
1005
- const isWin = process.platform === 'win32';
1006
- const binName = isWin
1007
- ? (bins['claude-haha'] ? 'claude-haha' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]))
1008
- : (bins['claude-linux'] ? 'claude-linux' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]));
1009
- const spawnCmd = isWin ? 'cmd' : 'sh';
1010
- // Windows 直接调全局 bingocode 命令,不用 bun 前缀
1011
- const spawnArgs = isWin
1012
- ? ['/c', 'start', 'cmd', '/k', `bingocode --resume ${sessionId}`]
1013
- : ['-c', `${binName} --resume ${sessionId}`];
1014
- const spawnEnv = await buildSpawnEnv();
1015
- spawn(spawnCmd, spawnArgs, {
1016
- cwd: workDir || process.env.CALLER_DIR || process.cwd(),
1017
- env: spawnEnv,
1018
- detached: true,
1019
- stdio: 'ignore'
1020
- }).unref();
1021
- } catch {}
1022
- }
1023
-
1024
-
1025
- // 历史分组展示
1026
- const groupedHistoryItems = useMemo(() => {
1027
- if (!historyList || !Array.isArray(historyList)) return [];
1028
- const now = new Date();
1029
- const today: any[] = [];
1030
- const week: any[] = [];
1031
- const earlier: any[] = [];
1032
- const marked: any[] = [];
1033
-
1034
- for (const item of historyList) {
1035
- if (markedSessionIds.has(item.id)) {
1036
- marked.push(item);
1037
- continue;
1038
- }
1039
- const dt = new Date(item.createdAt);
1040
- const isToday =
1041
- dt.getFullYear() === now.getFullYear() &&
1042
- dt.getMonth() === now.getMonth() &&
1043
- dt.getDate() === now.getDate();
1044
- const weekStart = new Date(now);
1045
- weekStart.setDate(now.getDate() - ((now.getDay() + 6) % 7));
1046
- weekStart.setHours(0, 0, 0, 0);
1047
- if (isToday) today.push(item);
1048
- else if (dt >= weekStart) week.push(item);
1049
- else earlier.push(item);
1050
- }
1051
- function groupToItems(group: any[], groupTitle: string) {
1052
- if (group.length === 0) return [];
1053
- return [
1054
- { label: groupTitle, value: `__group_${groupTitle}`, isGroup: true },
1055
- ...group.map(item => {
1056
- const isMarked = markedSessionIds.has(item.id);
1057
- return {
1058
- label: makeHistoryLabel(item, Math.max(20, VIEW_W - 8), isMarked),
1059
- value: item.id,
1060
- color: isMarked ? 'yellow' : undefined,
1061
- };
1062
- })
1063
- ];
1064
- }
1065
- const items = [
1066
- ...groupToItems(marked, '—— Marked ——'),
1067
- ...groupToItems(today, '—— Today ——'),
1068
- ...groupToItems(week, '—— This Week ——'),
1069
- ...groupToItems(earlier, '—— Earlier ——'),
1070
- ];
1071
- return items;
1072
- }, [historyList, markedSessionIds]);
1073
-
1074
- // Toggle Mark
1075
- const toggleMarkSession = (sessionId: string) => {
1076
- setMarkedSessionIds(prev => {
1077
- const next = new Set(prev);
1078
- if (next.has(sessionId)) next.delete(sessionId);
1079
- else next.add(sessionId);
1080
- saveMarkedSessionIds(next);
1081
- return next;
1082
- });
1083
- };
1084
-
1085
- const handleHistoryMenuAction = (action: string) => {
1086
- if (action === '__back') {
1087
- setHistoryMenuStage('list');
1088
- setSelectedHistory(null);
1089
- setMsgsPage(0);
1090
- return;
1091
- }
1092
- if (!selectedHistory) return;
1093
-
1094
- switch (action) {
1095
- case '__toggle_mark':
1096
- toggleMarkSession(selectedHistory.id);
1097
- break;
1098
- case '__continue':
1099
- resumeSession(selectedHistory.id, selectedHistory.workDir);
1100
- break;
1101
- case '__delete':
1102
- setHistoryMenuStage('deleteConfirm');
1103
- break;
1104
- case '__confirm_delete':
1105
- handleDeleteSession(selectedHistory.id);
1106
- break;
1107
- case '__cancel_delete':
1108
- setHistoryMenuStage('window');
1109
- break;
1110
- }
1111
- };
1112
-
1113
-
1114
- // Refresh history
1115
- const refreshHistoryList = () => {
1116
- setLoadingHist(true); setHistErr(null);
1117
- let url = apiUrl + '/api/sessions';
1118
- axios.get(url).then(res => {
1119
- const pageData = res.data;
1120
- setHistoryList(pageData?.sessions || []);
1121
- setHistoryCursor(pageData?.first_id || null);
1122
- setHistoryHasMore(!!pageData?.has_more);
1123
- }).catch(e => {
1124
- setHistErr(e.message || 'Failed to fetch history');
1125
- }).finally(() => setLoadingHist(false));
1126
- };
1127
-
1128
- // Delete Session
1129
- const handleDeleteSession = (sessionId: string) => {
1130
- const url = apiUrl.replace(/\/+$/, '') + '/api/sessions/' + sessionId;
1131
- axios.delete(url)
1132
- .catch(e => {})
1133
- .finally(() => {
1134
- setHistoryMenuStage('list');
1135
- setSelectedHistory(null);
1136
- setHistoryCursor(null);
1137
- refreshHistoryList();
1138
- });
1139
- };
1140
-
1141
- // Secondary menu (bottom bar right)
1142
- const secondaryMenu: SecondaryMenu = useMemo(() => {
1143
- if (page === 'history' && historyMenuStage === 'window' && selectedHistory) {
1144
- const isMarked = markedSessionIds.has(selectedHistory.id);
1145
- const markLabel = isMarked ? i18nMap[lang].unmark : i18nMap[lang].mark;
1146
- return {
1147
- title: 'Session Actions',
1148
- items: [
1149
- { label: markLabel, value: '__toggle_mark' },
1150
- { label: '→ Continue session', value: '__continue' },
1151
- { label: '→ Delete session', value: '__delete' },
1152
- { label: ' Back to list', value: '__back' },
1153
- ],
1154
- onSelect: (item: any) => {
1155
- if (item.value === '__back') {
1156
- setHistoryMenuStage('list');
1157
- setSelectedHistory(null);
1158
- setMsgsPage(0);
1159
- } else if (item.value === '__continue') {
1160
- resumeSession(selectedHistory.id, selectedHistory.workDir);
1161
- } else if (item.value === '__delete') {
1162
- setHistoryMenuStage('deleteConfirm');
1163
- } else if (item.value === '__toggle_mark') {
1164
- toggleMarkSession(selectedHistory.id);
1165
- }
1166
- }
1167
- };
1168
- }
1169
-
1170
- if (page === 'history' && historyMenuStage === 'deleteConfirm' && selectedHistory) {
1171
- return {
1172
- title: 'Confirm Delete',
1173
- items: [
1174
- { label: 'Yes, delete', value: '__confirm_delete' },
1175
- { label: 'No, back', value: '__cancel_delete' },
1176
- ],
1177
- onSelect: (item: any) => {
1178
- if (item.value === '__cancel_delete') {
1179
- setHistoryMenuStage('window');
1180
- } else if (item.value === '__confirm_delete') {
1181
- handleDeleteSession(selectedHistory.id);
1182
- }
1183
- }
1184
- };
1185
- }
1186
- return null;
1187
- }, [page, historyMenuStage, selectedHistory, markedSessionIds, lang]);
1188
-
1189
- // Help Overlay
1190
- function renderHelpOverlay() {
1191
- return (
1192
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1193
- <Text color="magenta">{i18nMap[lang].helpTitle}</Text>
1194
- <Text> </Text>
1195
- <Text color="cyan">N</Text><Text> New Session</Text>
1196
- <Text color="cyan">R</Text><Text> Quick Resume</Text>
1197
- <Text color="cyan">P</Text><Text> Open Provider Config</Text>
1198
- <Text color="cyan">G</Text><Text> Toggle Theme (light/dark/highContrast)</Text>
1199
- <Text color="cyan">L</Text><Text> Toggle Language (en → zh → ja)</Text>
1200
- <Text color="cyan">O</Text><Text> Toggle Top Animation</Text>
1201
- <Text color="cyan">T</Text><Text> Toggle Top Tips</Text>
1202
- <Text color="cyan">?</Text><Text> Toggle Help</Text>
1203
- <Text> </Text>
1204
- <Hint>ESC to close · Works anywhere</Hint>
1205
- </Box>
1206
- );
1207
- }
1208
-
1209
- // Center Content
1210
- function renderCenter() {
1211
- if (showHelp) return renderHelpOverlay();
1212
-
1213
- // Home: WelcomeV2 (58 cols wide)
1214
- if (page === null) {
1215
- const WELCOME_W = 58;
1216
- const leftPad = Math.max(0, Math.floor((VIEW_W - WELCOME_W) / 2));
1217
- return (
1218
- <Box flexDirection="column" width={VIEW_W} height={MID_H}>
1219
- <Box flexDirection="row" width={VIEW_W} flexGrow={1}>
1220
- <Box width={leftPad} flexShrink={0} />
1221
- <WelcomeV2 />
1222
- </Box>
1223
- {!apiUrl && !bootErr && (
1224
- <StateDisplay type="loading" message="Starting server..." />
1225
- )}
1226
- {bootErr && (
1227
- <StateDisplay type="error" message={`Server boot failed: ${bootErr}`} />
1228
- )}
1229
- </Box>
1230
- );
1231
- }
1232
-
1233
- // New Session
1234
- if (page === 'newSession') {
1235
- return (
1236
- <Box flexDirection="column" width={VIEW_W} height={MID_H}>
1237
- {creating && <StateDisplay type="loading" message="Creating..." />}
1238
- {createErr && <StateDisplay type="error" message={`Failed to create: ${createErr}`} />}
1239
- {newSessionId && <Box alignItems="center" justifyContent="center" flexGrow={1}><Text color="green">New Session: {newSessionId}</Text></Box>}
1240
- {!creating && !createErr && !newSessionId && <StateDisplay type="empty" message="Entered new session page, waiting for result..." />}
1241
- </Box>
1242
- );
1243
- }
1244
-
1245
- // History
1246
- if (page === 'history') {
1247
- if (histErr) return <StateDisplay type="error" message={histErr} onRetry={refreshHistoryList} />;
1248
- if (historyMenuStage === 'deleteConfirm' && selectedHistory) {
1249
- const halfH = Math.floor(MID_H / 2);
1250
- const items = [
1251
- { label: 'Yes, Delete', value: '__confirm_delete' },
1252
- { label: 'No, Back', value: '__cancel_delete' },
1253
- ];
1254
- return (
1255
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1256
- <Box height={halfH} flexDirection="column" paddingX={1} paddingTop={1}>
1257
- <Text color="red" bold>Confirm Delete?</Text>
1258
- <Text>Title: {selectedHistory.title || 'Untitled'}</Text>
1259
- <Text dimColor>Time: {selectedHistory.createdAt?.replace('T',' ')}</Text>
1260
- <Text dimColor>ID: {selectedHistory.id}</Text>
1261
- </Box>
1262
- <Panel height={MID_H - halfH} borderStyle="round" borderColor="red" paddingX={1}>
1263
- <SelectInput
1264
- items={items}
1265
- onSelect={(item) => handleHistoryMenuAction(String(item.value))}
1266
- />
1267
- <Hint>Enter Confirm · q Cancel</Hint>
1268
- </Panel>
1269
- </Box>
1270
- );
1271
- }
1272
- if (!historyList.length && loadingHist) {
1273
- return <StateDisplay type="loading" message="Loading..." />;
1274
- }
1275
- if (!historyList.length) {
1276
- return <StateDisplay type="empty" message={i18nMap[lang].emptyHistory} />;
1277
- }
1278
-
1279
- const ACTIONS_H = 7; // Actions title(1) + 4 items + hint(1) + padding(1)
1280
- const LIST_H = Math.max(2, MID_H - ACTIONS_H - 1);
1281
-
1282
- if (historyMenuStage === 'window' && selectedHistory) {
1283
- // Detailed View with Split
1284
- const isMarked = markedSessionIds.has(selectedHistory.id);
1285
- const displayMsgs = sessionMessages.filter(
1286
- m => m.type === 'user' || m.type === 'assistant' || m.type === 'system'
1287
- );
1288
- const totalPages = Math.max(1, Math.ceil(displayMsgs.length / MSGS_PAGE_SIZE));
1289
- const safePage = Math.min(msgsPage, totalPages - 1);
1290
- const pageStart = safePage * MSGS_PAGE_SIZE;
1291
- const pageMsgs = displayMsgs.slice(pageStart, pageStart + MSGS_PAGE_SIZE);
1292
-
1293
- return (
1294
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1295
- {/* Upper Pane: Preview */}
1296
- <Box height={LIST_H} flexDirection="column" paddingX={1} overflow="hidden">
1297
- <Box justifyContent="space-between" marginBottom={0}>
1298
- <Text color={isMarked ? 'yellow' : 'cyan'} bold>
1299
- {isMarked ? '★ ' : ''}{truncate(selectedHistory.title || 'Untitled', VIEW_W - 24)}
1300
- </Text>
1301
- <Text dimColor>{selectedHistory.createdAt?.slice(0,16).replace('T',' ')}</Text>
1302
- </Box>
1303
-
1304
- <Box flexDirection="column" flexGrow={1} overflow="hidden">
1305
- {loadingMsgs && <StateDisplay type="loading" message="Loading messages..." />}
1306
- {msgsErr && <StateDisplay type="error" message={msgsErr} />}
1307
- {!loadingMsgs && pageMsgs.length === 0 && <StateDisplay type="empty" message="No messages" />}
1308
- {pageMsgs.map((msg) => {
1309
- const text = extractTextFromContent(msg.content);
1310
- const roleLabel = msg.type === 'user' ? 'You' : 'Bot';
1311
- const roleColor = msg.type === 'user' ? 'green' : 'cyan';
1312
- return (
1313
- <Box key={msg.id} marginBottom={0} flexDirection="column" height={1} overflow="hidden">
1314
- <Text color={roleColor} bold>{roleLabel}: <Text color="white" bold={false}>{clampTextLines(text, VIEW_W - 10, 1)}</Text></Text>
1315
- </Box>
1316
- );
1317
- })}
1318
- </Box>
1319
- {totalPages > 1 && (
1320
- <Box justifyContent="center" height={1}>
1321
- <Hint>Page {safePage + 1}/{totalPages} (↑↓ to scroll)</Hint>
1322
- </Box>
1323
- )}
1324
- </Box>
1325
-
1326
- <Box height={1} marginBottom={0}><Text dimColor>{'─'.repeat(VIEW_W - 4)}</Text></Box>
1327
-
1328
- {/* Lower Pane: Actions */}
1329
- <Box height={ACTIONS_H} paddingX={1} flexDirection="column" overflow="hidden">
1330
- <Text color="magenta" bold>Actions</Text>
1331
- <Box marginTop={0} height={ACTIONS_H - 2} overflow="hidden">
1332
- <SelectInput
1333
- items={secondaryMenu?.items || []}
1334
- onSelect={secondaryMenu?.onSelect}
1335
- />
1336
- </Box>
1337
- <Hint>ESC Back · ↑↓ Select Action · Q/M/C Shortcut</Hint>
1338
- </Box>
1339
- </Box>
1340
- );
1341
- }
1342
-
1343
- // History List View (Default)
1344
- // MID_H - 1 (hint bar at top) - 1 (scrollbar safety) = MID_H - 2 visible items
1345
- const HIST_VISIBLE = MID_H - 2;
1346
- const start = Math.min(listOffset, Math.max(0, groupedHistoryItems.length - HIST_VISIBLE));
1347
- const slicedItems = groupedHistoryItems.slice(start, start + HIST_VISIBLE);
1348
-
1349
- return (
1350
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1351
- {/* Hint bar fixed 1 row at top, never overlaps list */}
1352
- <Box height={1} paddingX={1}>
1353
- <Hint>{i18nMap[lang].historyHint}</Hint>
1354
- </Box>
1355
- {/* List area — takes the rest of the height */}
1356
- <Box flexDirection="row" flexGrow={1} position="relative">
1357
- <Box flexDirection="column" flexGrow={1} paddingX={1}>
1358
- <SelectInput
1359
- key={`${historyCursor ?? 'first'}:${slicedItems.length}:${start}`}
1360
- items={slicedItems}
1361
- onSelect={item => {
1362
- if (String(item.value).startsWith('__group_')) return;
1363
- const session = historyList.find(h => h.id === item.value);
1364
- if (session) {
1365
- setSelectedHistory(session);
1366
- setHistoryMenuStage('window');
1367
- }
1368
- }}
1369
- itemComponent={({ isSelected, label }) => {
1370
- const it = groupedHistoryItems.find(i => i.label === label);
1371
- const isGroup = it?.isGroup;
1372
- const color = it?.color;
1373
- return (
1374
- <Box height={1} overflow="hidden">
1375
- <Text wrap="truncate" color={isGroup ? 'gray' : (color ? color : (isSelected ? 'cyan' : undefined))}>
1376
- {isSelected ? '> ' : ' '}{label}
1377
- </Text>
1378
- </Box>
1379
- )
1380
- }}
1381
- />
1382
- </Box>
1383
- <ScrollBar total={groupedHistoryItems.length} offset={start} height={MID_H - 3} />
1384
- </Box>
1385
- </Box>
1386
- );
1387
- }
1388
-
1389
- // Provider
1390
- if (page === 'provider') {
1391
- if (!apiUrl) {
1392
- return (
1393
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1394
- <StateDisplay
1395
- type={bootErr ? "error" : "loading"}
1396
- message={bootErr ? `Server boot failed: ${bootErr}` : 'Starting server, please wait...'}
1397
- onRetry={() => process.exit(1)} // Or another way to trigger reboot
1398
- />
1399
- <Text dimColor alignSelf="center">ESC for main menu</Text>
1400
- </Box>
1401
- );
1402
- }
1403
- return (
1404
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1405
- <ProviderPanel apiUrl={apiUrl} height={MID_H} onBack={() => setPage(null)} />
1406
- </Box>
1407
- );
1408
- }
1409
-
1410
- // Settings
1411
- if (page === 'settings') {
1412
- if (loadingSetting) return <StateDisplay type="loading" message="Loading settings..." />;
1413
- if (setErr) return <StateDisplay type="error" message={setErr} />;
1414
-
1415
- const tS = i18nMap[lang];
1416
- const currentLangLabel = LANG_OPTIONS.find(o => o.value === lang)?.label ?? lang;
1417
-
1418
- // --- langPicker sub-menu ---
1419
- if (settingsStage === 'langPicker') {
1420
- return (
1421
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1422
- <Box paddingX={1} marginBottom={1}>
1423
- <Text color="magenta" bold>{tS.langPickerTitle}</Text>
1424
- </Box>
1425
- <Box paddingX={2} flexGrow={1} flexDirection="column">
1426
- <SelectInput
1427
- items={tS.langOptions}
1428
- initialIndex={tS.langOptions.findIndex(o => o.value === lang)}
1429
- onSelect={(item: { label: string; value: Lang }) => {
1430
- setLang(item.value);
1431
- try { writeBingoSettings({ language: item.value }); } catch {}
1432
- setSettingsStage('list');
1433
- }}
1434
- />
1435
- </Box>
1436
- <Box paddingX={1}>
1437
- <Hint>↩ confirm · ESC back</Hint>
1438
- </Box>
1439
- </Box>
1440
- );
1441
- }
1442
-
1443
- // --- settings list ---
1444
- type SettingRow = { key: string; label: string; value: string; interactive: boolean };
1445
- const fixedRows: SettingRow[] = [
1446
- { key: '__lang', label: tS.langLabel, value: currentLangLabel, interactive: true },
1447
- { key: '__autoMode', label: tS.autoModeLabel, value: autoModeEnabled ? tS.autoModeOn : tS.autoModeOff, interactive: true },
1448
- { key: '__bypassPerms', label: tS.bypassPermsLabel, value: bypassPermsEnabled ? tS.bypassPermsOn : tS.bypassPermsOff, interactive: true },
1449
- { key: '__vscode', label: tS.vscodeLabel, value: vscodeLinked ? tS.vscodeOn : tS.vscodeOff, interactive: true },
1450
- ];
1451
- const dataEntries = settingData && typeof settingData === 'object' ? Object.entries(settingData) : [];
1452
- const dataRows: SettingRow[] = dataEntries.map(([k, v]) => ({
1453
- key: k,
1454
- label: k,
1455
- value: typeof v === 'object' ? JSON.stringify(v) : String(v),
1456
- interactive: false,
1457
- }));
1458
- const allRows: SettingRow[] = [...fixedRows, ...dataRows];
1459
- const visible = Math.max(1, MID_H - 2);
1460
- const start = Math.min(settingsOffset, Math.max(0, allRows.length - visible));
1461
- const sliced = allRows.slice(start, start + visible);
1462
-
1463
- return (
1464
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1465
- <Box flexDirection="row" position="relative" flexGrow={1}>
1466
- <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
1467
- {sliced.map((row, idx) => {
1468
- const absIdx = start + idx;
1469
- const isCursor = absIdx === settingsCursor;
1470
- const prefix = isCursor ? '>' : ' ';
1471
- const labelColor = isCursor ? 'cyan' : (row.interactive ? 'white' : 'gray');
1472
- const valueColor = row.interactive ? 'green' : undefined;
1473
- return (
1474
- <Box key={row.key} height={1}>
1475
- <Text color={labelColor}>
1476
- {prefix} {row.label}:{' '}
1477
- <Text color={valueColor ?? (isCursor ? 'white' : 'gray')}>
1478
- {row.value}
1479
- {row.interactive ? ' ↩' : ''}
1480
- </Text>
1481
- </Text>
1482
- </Box>
1483
- );
1484
- })}
1485
- </Box>
1486
- <ScrollBar total={allRows.length} offset={start} height={visible - 1} />
1487
- </Box>
1488
- <Box paddingX={1}>
1489
- <Hint>{tS.settingsHint} · {start + 1}-{Math.min(start + visible, allRows.length)}/{allRows.length}</Hint>
1490
- </Box>
1491
- </Box>
1492
- );
1493
- }
1494
-
1495
- // About
1496
- if (page === 'about') {
1497
- return (
1498
- <Box width={VIEW_W} height={MID_H} flexDirection="column">
1499
- <Text color="cyan" bold>{i18nMap[lang].about}</Text>
1500
- <Box marginTop={1} flexDirection="column">
1501
- <Text>{(i18nMap[lang] as any).aboutContent}</Text>
1502
- </Box>
1503
- <Box marginTop={1}>
1504
- <Hint>
1505
- API Base: {apiUrl}
1506
- </Hint>
1507
- </Box>
1508
- <Box marginTop={1}>
1509
- <Text color="gray">{(i18nMap[lang] as any).aboutFooter}</Text>
1510
- </Box>
1511
- </Box>
1512
- );
1513
- }
1514
-
1515
- // Exit
1516
- if (page === 'exit') {
1517
- exit();
1518
- return <Box width={VIEW_W} height={MID_H}><Text>Exiting...</Text></Box>;
1519
- }
1520
-
1521
- return <Box width={VIEW_W} height={MID_H} />;
1522
- }
1523
-
1524
- // Exit logic
1525
- if (terminalSize.columns < 60 || terminalSize.rows < 15) {
1526
- return (
1527
- <Box flexDirection="column" padding={2}>
1528
- <Text color="red">Terminal too small!</Text>
1529
- <Text>Current: {terminalSize.columns}x{terminalSize.rows}</Text>
1530
- <Text>Please resize to continue...</Text>
1531
- </Box>
1532
- );
1533
- }
1534
-
1535
- // Root Render
1536
- return (
1537
- <Box flexDirection="column" width={VIEW_W}>
1538
- {/* Top Welcome / Logo Area + Toolbar */}
1539
- <TopBar
1540
- ready={configReady}
1541
- page={page}
1542
- width={VIEW_W}
1543
- height={TOP_H}
1544
- toolbar={
1545
- <TopToolbar
1546
- ready={configReady}
1547
- page={page}
1548
- animEnabled={animEnabled}
1549
- tipsEnabled={tipsEnabled}
1550
- ip={apiUrl ? apiUrl.replace(/^https?:\/\//, '') : undefined}
1551
- />
1552
- }
1553
- />
1554
-
1555
- {/* Center Center Area */}
1556
- {page === null ? (
1557
- <Panel width={VIEW_W} height={MID_H} noBorder paddingX={0} paddingY={0} marginY={0}>
1558
- {renderCenter()}
1559
- </Panel>
1560
- ) : (
1561
- <Panel width={VIEW_W} height={MID_H} borderStyle="single" paddingX={1} paddingY={0} marginY={1}>
1562
- {renderCenter()}
1563
- </Panel>
1564
- )}
1565
-
1566
- {/* Bottom Menu & Secondary Menu */}
1567
- <BottomBar
1568
- width={VIEW_W}
1569
- height={BOTTOM_H}
1570
- menuItems={menuItems}
1571
- page={page}
1572
- navIndex={navIndex}
1573
- tips={i18nMap[lang].tipsSimple}
1574
- secondaryMenu={
1575
- page === 'history' && (historyMenuStage === 'window' || historyMenuStage === 'deleteConfirm')
1576
- ? null
1577
- : secondaryMenu
1578
- }
1579
- />
1580
- </Box>
1581
- );
1582
- };
1583
-
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
+ import { logError } from '../utils/log.js';
27
+
28
+ // markedSessions stored in ~/.claude-cli/ fixed directory, regardless of cwd
29
+ const MARKED_FILE = path.join(os.homedir(), '.claude-cli', 'markedSessions.json');
30
+
31
+ /**
32
+ * Get the path to ~/.claude/bingo/settings.json (offline persistence for language
33
+ * and auto-mode settings, same file used by provider service). This ensures
34
+ * these settings survive across full restarts.
35
+ */
36
+ function getBingoSettingsPath(): string {
37
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
38
+ return path.join(configDir, 'bingo', 'settings.json');
39
+ }
40
+
41
+ function readBingoSettings(): Record<string, unknown> {
42
+ try {
43
+ const raw = fs.readFileSync(getBingoSettingsPath(), 'utf-8');
44
+ return JSON.parse(raw) as Record<string, unknown>;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function writeBingoSettings(updates: Record<string, unknown>): void {
51
+ const p = getBingoSettingsPath();
52
+ const dir = path.dirname(p);
53
+ if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); }
54
+
55
+ let current: Record<string, unknown> = {};
56
+ try {
57
+ if (fs.existsSync(p)) {
58
+ const raw = fs.readFileSync(p, 'utf-8');
59
+ current = JSON.parse(raw) as Record<string, unknown>;
60
+ }
61
+ } catch {}
62
+
63
+ const merged = { ...current, ...updates };
64
+
65
+ // atomic write via temp + rename
66
+ const tmp = `${p}.tmp.${Date.now()}`;
67
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
68
+ fs.renameSync(tmp, p);
69
+ }
70
+
71
+ // write yml
72
+ function readGlobalClaudeConfig(): Record<string, unknown> {
73
+ const configPath = path.join(os.homedir(), '.claude.json');
74
+ try {
75
+ const raw = fs.readFileSync(configPath, 'utf-8');
76
+ return JSON.parse(raw) as Record<string, unknown>;
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
82
+ // write merge config directly to ~/.claude.json (atomic write)
83
+ function writeGlobalClaudeConfig(updates: Record<string, unknown>): void {
84
+ const configPath = path.join(os.homedir(), '.claude.json');
85
+ const dir = path.dirname(configPath);
86
+ if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); }
87
+
88
+ let current: Record<string, unknown> = {};
89
+ try {
90
+ if (fs.existsSync(configPath)) {
91
+ const raw = fs.readFileSync(configPath, 'utf-8');
92
+ current = JSON.parse(raw) as Record<string, unknown>;
93
+ }
94
+ } catch {}
95
+
96
+ const merged = { ...current, ...updates };
97
+
98
+ // atomic write via temp + rename
99
+ const tmp = `${configPath}.tmp.${Date.now()}`;
100
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
101
+ fs.renameSync(tmp, configPath);
102
+ }
103
+
104
+ function readClaudeSettings(): Record<string, unknown> {
105
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
106
+ const settingsPath = path.join(configDir, 'settings.json');
107
+ try {
108
+ const raw = fs.readFileSync(settingsPath, 'utf-8');
109
+ return JSON.parse(raw) as Record<string, unknown>;
110
+ } catch {
111
+ return {};
112
+ }
113
+ }
114
+
115
+ function writeClaudeSettings(updates: Record<string, unknown>): void {
116
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
117
+ const settingsPath = path.join(configDir, 'settings.json');
118
+ const dir = path.dirname(settingsPath);
119
+ if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); }
120
+
121
+ let current: Record<string, unknown> = {};
122
+ try {
123
+ if (fs.existsSync(settingsPath)) {
124
+ const raw = fs.readFileSync(settingsPath, 'utf-8');
125
+ current = JSON.parse(raw) as Record<string, unknown>;
126
+ }
127
+ } catch {}
128
+
129
+ const merged = { ...current, ...updates };
130
+
131
+ // atomic write via temp + rename
132
+ const tmp = `${settingsPath}.tmp.${Date.now()}`;
133
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
134
+ fs.renameSync(tmp, settingsPath);
135
+ }
136
+
137
+ /**
138
+ * Determine if in "official" mode (no custom provider active).
139
+ * Logic matches ConversationService.shouldMarkManagedOAuth().
140
+ */
141
+ function isOfficialMode(): boolean {
142
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
143
+ const settingsPath = path.join(configDir, 'bingo', 'settings.json');
144
+ try {
145
+ const raw = fs.readFileSync(settingsPath, 'utf-8');
146
+ const parsed = JSON.parse(raw) as { env?: Record<string, string> };
147
+ const env = parsed.env ?? {};
148
+ const hasProviderEnv = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']
149
+ .some(key => typeof env[key] === 'string' && env[key]!.trim().length > 0);
150
+ return !hasProviderEnv;
151
+ } catch {
152
+ return true; // Cannot read settings.json -> Treat as official mode
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Build spawn env for child process.
158
+ * In official mode, inject CLAUDE_CODE_ENTRYPOINT=claude-desktop + CLAUDE_CODE_OAUTH_TOKEN,
159
+ * so new/resumed bingocode windows can use OAuth directly.
160
+ */
161
+ async function buildSpawnEnv(): Promise<NodeJS.ProcessEnv> {
162
+ const base = { ...process.env };
163
+ if (!isOfficialMode()) return base;
164
+
165
+ // Official mode: mark as managed-OAuth and inject OAuth token
166
+ base.CLAUDE_CODE_ENTRYPOINT = 'claude-desktop';
167
+ try {
168
+ const { hahaOAuthService } = await import('../server/services/hahaOAuthService.js');
169
+ const token = await hahaOAuthService.ensureFreshAccessToken();
170
+ if (token) {
171
+ base.CLAUDE_CODE_OAUTH_TOKEN = token;
172
+ } else {
173
+ // No valid token -> don't inject, use normal login flow
174
+ delete base.CLAUDE_CODE_OAUTH_TOKEN;
175
+ }
176
+ } catch {
177
+ delete base.CLAUDE_CODE_OAUTH_TOKEN;
178
+ }
179
+ return base;
180
+ }
181
+
182
+ // Top height: Home = Clawd(3 rows) + border(2) = 5; Compact = 1 row + border(2) = 3
183
+ const TOP_H_HOME = Number(process.env.CLI_TOP_H_HOME || 5);
184
+ const TOP_H_COMPACT = Number(process.env.CLI_TOP_H_COMPACT || 3);
185
+ // Bottom bar height
186
+ const BOTTOM_H = Number(process.env.CLI_BOTTOM_H || 3);
187
+
188
+ const LANG_OPTIONS = [
189
+ { label: 'English', value: 'en' as const },
190
+ { label: '中文', value: 'zh' as const },
191
+ { label: '日本語', value: 'ja' as const },
192
+ ];
193
+
194
+ const i18nMap = {
195
+ zh: {
196
+ menu: {
197
+ newSession: '新建会话',
198
+ history: '会话历史',
199
+ provider: 'API 配置',
200
+ settings: '设置',
201
+ about: '关于',
202
+ exit: '退出',
203
+ },
204
+ about: 'Bingo CLI 终端 - 版本信息与关于',
205
+ aboutContent: [
206
+ 'Bingo 是一款 AI 助手终端客户端。',
207
+ '1. API 配置:按 "P" 或选择「API 配置」来设置你的密钥。',
208
+ '2. 模型槽:在 Provider 面板中配置各模型。',
209
+ '3. 后台服务:Bingo 会运行一个本地服务器来管理会话。',
210
+ '4. 开始聊天:在任意终端中运行 `bingocode` 或 `claude`。',
211
+ ].join('\n'),
212
+ aboutFooter: '作者: leanchy (leanchy07@outlook.com) · github.com/leanchy/claude-code-bingo',
213
+ mark: '→ 标记会话',
214
+ unmark: ' 取消标记',
215
+ tipsSimple: 'L 语言 | ESC 返回 | ←→ 菜单 | ↩ 确认 | ? 帮助',
216
+ noData: '暂无数据',
217
+ emptyHistory: '还没有会话,要新建一个吗?',
218
+ deleting: '确定删除此会话?(不可恢复)',
219
+ historyHint: '↩ 打开 · j 下一页 · k 首页 · q 返回',
220
+ helpTitle: '快捷键',
221
+ // Settings page
222
+ settingsTitle: '设置',
223
+ langLabel: '语言',
224
+ langPickerTitle: '选择语言',
225
+ settingsHint: '↑/k ↓/j 滚动 · ↩ 切换 · ESC 返回',
226
+ langOptions: LANG_OPTIONS,
227
+ autoModeLabel: 'Auto Mode',
228
+ autoModeOn: '已开启',
229
+ autoModeOff: '已关闭',
230
+ bypassPermsLabel: 'Bypass',
231
+ bypassPermsOn: '已开启',
232
+ bypassPermsOff: '已关闭',
233
+ vscodeLabel: 'Connect to VS Code',
234
+ vscodeOn: '已连接',
235
+ vscodeOff: '未连接',
236
+ },
237
+ en: {
238
+ menu: {
239
+ newSession: 'New Session',
240
+ history: 'Session History',
241
+ provider: 'API Config',
242
+ settings: 'Settings',
243
+ about: 'About',
244
+ exit: 'Exit',
245
+ },
246
+ about: 'Bingo CLI Terminal - Version Info & About',
247
+ aboutContent: [
248
+ 'Bingo is an AI assistant terminal client.',
249
+ '1. API Config: Press "P" or select "API Config" to set up your keys.',
250
+ '2. Model Slots: Configure specific models in the Provider panel.',
251
+ '3. Background Service: Bingo runs a local server to manage sessions.',
252
+ '4. Start Chat: Run `bingocode` or `claude` in any terminal to start.',
253
+ ].join('\n'),
254
+ aboutFooter: 'Author: leanchy (leanchy07@outlook.com) · github.com/leanchy/claude-code-bingo',
255
+ mark: '→ Mark Session',
256
+ unmark: ' Unmark Session',
257
+ tipsSimple: 'L Lang | ESC Back | ←→ Menu | ↩ Enter | ? Help',
258
+ noData: 'No data',
259
+ emptyHistory: 'Nothing here yet. Start a new session?',
260
+ deleting: 'Delete this session? (Irreversible)',
261
+ historyHint: 'Enter to open · j next · k first · q back',
262
+ helpTitle: 'Shortcuts',
263
+ // Settings page
264
+ settingsTitle: 'Settings',
265
+ langLabel: 'Language',
266
+ langPickerTitle: 'Select Language',
267
+ settingsHint: '↑/k ↓/j scroll · ↩ toggle · ESC back',
268
+ langOptions: LANG_OPTIONS,
269
+ autoModeLabel: 'Auto Mode',
270
+ autoModeOn: 'Enabled',
271
+ autoModeOff: 'Disabled',
272
+ bypassPermsLabel: 'Bypass',
273
+ bypassPermsOn: 'Enabled',
274
+ bypassPermsOff: 'Disabled',
275
+ vscodeLabel: 'Connect to VS Code',
276
+ vscodeOn: 'Connected',
277
+ vscodeOff: 'Disconnected',
278
+ },
279
+ ja: {
280
+ menu: {
281
+ newSession: '新規セッション',
282
+ history: 'セッション履歴',
283
+ provider: 'API設定',
284
+ settings: '設定',
285
+ about: 'について',
286
+ exit: '終了',
287
+ },
288
+ about: 'Bingo CLI ターミナル - バージョン情報',
289
+ aboutContent: [
290
+ 'BingoはAIアシスタントのターミナルクライアントです。',
291
+ '1. API設定: "P"キーまたは「API設定」を選択してキーを設定。',
292
+ '2. モデルスロット: Providerパネルで各モデルを設定。',
293
+ '3. バックグラウンドサービス: セッション管理用ローカルサーバーを起動。',
294
+ '4. チャット開始: 任意のターミナルで `bingocode` または `claude` を実行。',
295
+ ].join('\n'),
296
+ aboutFooter: '作者: leanchy (leanchy07@outlook.com) · github.com/leanchy/claude-code-bingo',
297
+ mark: '→ セッションをマーク',
298
+ unmark: ' マークを解除',
299
+ tipsSimple: 'L 言語 | ESC 戻る | ←→ メニュー | ↩ 決定 | ? ヘルプ',
300
+ noData: 'データなし',
301
+ emptyHistory: 'まだセッションがありません。新規作成しますか?',
302
+ deleting: 'このセッションを削除しますか?(元に戻せません)',
303
+ historyHint: '↩ 開く · j 次へ · k 最初へ · q 戻る',
304
+ helpTitle: 'ショートカット',
305
+ // Settings page
306
+ settingsTitle: '設定',
307
+ langLabel: '言語',
308
+ langPickerTitle: '言語を選択',
309
+ settingsHint: '↑/k ↓/j スクロール · ↩ 切替 · ESC 戻る',
310
+ langOptions: LANG_OPTIONS,
311
+ autoModeLabel: 'Auto Mode',
312
+ autoModeOn: '有効',
313
+ autoModeOff: '無効',
314
+ bypassPermsLabel: 'Bypass',
315
+ bypassPermsOn: '有効',
316
+ bypassPermsOff: '無効',
317
+ vscodeLabel: 'Connect to VS Code',
318
+ vscodeOn: '接続済',
319
+ vscodeOff: '未接続',
320
+ },
321
+ };
322
+
323
+ const menuKeys = [
324
+ 'newSession', 'history', 'provider', 'settings', 'about', 'exit'
325
+ ] as const;
326
+ type MenuKey = typeof menuKeys[number];
327
+ type Lang = keyof typeof i18nMap;
328
+
329
+ //@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>
330
+ function loadMarkedSessionIds(): Set<string> {
331
+ try {
332
+ const arr = JSON.parse(fs.readFileSync(MARKED_FILE, 'utf-8'));
333
+ return new Set(typeof arr === 'object' && Array.isArray(arr) ? arr : []);
334
+ } catch {
335
+ return new Set();
336
+ }
337
+ }
338
+
339
+ //@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
340
+ function saveMarkedSessionIds(set: Set<string>) {
341
+ try {
342
+ const dir = path.dirname(MARKED_FILE);
343
+ if (!fs.existsSync(dir)) {
344
+ fs.mkdirSync(dir, { recursive: true });
345
+ }
346
+ fs.writeFileSync(MARKED_FILE, JSON.stringify([...set]), 'utf-8');
347
+ } catch (err) {
348
+ console.error('[saveMarkedSessionIds] Save failed:', err);
349
+ }
350
+ }
351
+
352
+ // Message Entry (Aligned with backend MessageEntry)
353
+ type MessageEntry = {
354
+ id: string;
355
+ type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result';
356
+ content: unknown; // string 或 ContentBlock[]
357
+ timestamp: string;
358
+ model?: string;
359
+ parentUuid?: string;
360
+ parentToolUseId?: string;
361
+ isSidechain?: boolean;
362
+ };
363
+
364
+ /** Extract plain text from MessageEntry.content */
365
+ function extractTextFromContent(content: unknown): string {
366
+ if (typeof content === 'string') return content;
367
+ if (Array.isArray(content)) {
368
+ return content
369
+ .map((block: any) => {
370
+ if (block.type === 'text' && typeof block.text === 'string') return block.text;
371
+ if (block.type === 'tool_use') return `[Tool: ${block.name || 'unknown'}]`;
372
+ if (block.type === 'tool_result') {
373
+ if (typeof block.content === 'string') return block.content;
374
+ if (Array.isArray(block.content)) {
375
+ return block.content
376
+ .filter((b: any) => b.type === 'text')
377
+ .map((b: any) => b.text)
378
+ .join('\n');
379
+ }
380
+ return '[Tool Result]';
381
+ }
382
+ return '';
383
+ })
384
+ .filter(Boolean)
385
+ .join('\n');
386
+ }
387
+ return String(content ?? '');
388
+ }
389
+
390
+ //@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
391
+ export const CliMenuManager: React.FC = () => {
392
+ const { stdout } = useStdout();
393
+ const [terminalSize, setTerminalSize] = useState({
394
+ columns: stdout?.columns || 80,
395
+ rows: stdout?.rows || 24
396
+ });
397
+
398
+ useEffect(() => {
399
+ const onResize = () => {
400
+ setTerminalSize({
401
+ columns: stdout?.columns || 80,
402
+ rows: stdout?.rows || 24
403
+ });
404
+ };
405
+ stdout?.on('resize', onResize);
406
+ return () => { stdout?.off('resize', onResize); };
407
+ }, [stdout]);
408
+
409
+ // Dynamic viewport
410
+ const VIEW_W = Number(process.env.CLI_VIEW_W || Math.min(terminalSize.columns, 96));
411
+ const VIEW_H = Number(process.env.CLI_VIEW_H || terminalSize.rows);
412
+
413
+ const [apiUrl, setApiUrl] = useState<string | null>(process.env.BASE_API_URL || null);
414
+ const [stopIfLast, setStopIfLast] = useState<null | (() => Promise<void>)>(null);
415
+ const [bootErr, setBootErr] = useState<string | null>(null);
416
+ const { exit } = useApp();
417
+
418
+ // Theme (Global Hook)
419
+ const [theme, setTheme] = useTheme();
420
+
421
+ // Language
422
+ const [lang, setLang] = useState<Lang>('en');
423
+
424
+ // Config ready probe (avoid Logo early read)
425
+ const [configReady, setConfigReady] = useState(false);
426
+
427
+ // Load settings from bingo/settings.json at startup
428
+ // (bypasses configReady to avoid stale lock issues)
429
+ useEffect(() => {
430
+ try {
431
+ const bSettings = readBingoSettings();
432
+ const bingoLang = bSettings.language as string | undefined;
433
+ if (bingoLang && (bingoLang === 'en' || bingoLang === 'zh' || bingoLang === 'ja')) {
434
+ setLang(bingoLang as Lang);
435
+ }
436
+ if (typeof bSettings.autoModeEnabled === 'boolean') {
437
+ setAutoModeEnabled(bSettings.autoModeEnabled);
438
+ }
439
+ if (typeof bSettings.bypassPermsEnabled === 'boolean') {
440
+ setBypassPermsEnabled(bSettings.bypassPermsEnabled);
441
+ }
442
+ if (typeof bSettings.vscodeLinked === 'boolean') {
443
+ setVscodeLinked(bSettings.vscodeLinked);
444
+ }
445
+ } catch {}
446
+ }, []);
447
+
448
+ useEffect(() => {
449
+ if (configReady) {
450
+ try {
451
+ const cfg = getGlobalConfig();
452
+ if (typeof cfg.uiAnimEnabled === 'boolean') setAnimEnabled(cfg.uiAnimEnabled);
453
+ if (typeof cfg.uiTipsEnabled === 'boolean') setTipsEnabled(cfg.uiTipsEnabled);
454
+ } catch {}
455
+ }
456
+ }, [configReady]);
457
+
458
+ const t = i18nMap[lang].menu;
459
+
460
+ // Top time
461
+ const [nowStr, setNowStr] = useState<string>(new Date().toLocaleString('en-US', { hour12: false }));
462
+ useEffect(() => {
463
+ const id = setInterval(() => setNowStr(new Date().toLocaleString('en-US', { hour12: false })), 1000);
464
+ return () => clearInterval(id);
465
+ }, []);
466
+
467
+ // Main Menu
468
+ const [page, setPage] = useState<MenuKey | null>(null);
469
+ const menuItems = useMemo(() => menuKeys.map(key => ({ label: t[key], value: key })), [t]);
470
+ const [navIndex, setNavIndex] = useState(0);
471
+
472
+ // New Session
473
+ const [newSessionId, setNewSessionId] = useState<string | null>(null);
474
+ const [creating, setCreating] = useState(false);
475
+ const [createErr, setCreateErr] = useState<string | null>(null);
476
+
477
+ // History
478
+ const [loadingHist, setLoadingHist] = useState(false);
479
+ const [historyList, setHistoryList] = useState<any[]>([]);
480
+ const [historyCursor, setHistoryCursor] = useState<string | null>(null);
481
+ const [historyHasMore, setHistoryHasMore] = useState<boolean>(false);
482
+ const [histErr, setHistErr] = useState<string | null>(null);
483
+ const [historyMenuStage, setHistoryMenuStage] = useState<'list'|'window'|'deleteConfirm'>('list');
484
+ const [selectedHistory, setSelectedHistory] = useState<any|null>(null);
485
+
486
+ // History Messages
487
+ const [sessionMessages, setSessionMessages] = useState<MessageEntry[]>([]);
488
+ const [loadingMsgs, setLoadingMsgs] = useState(false);
489
+ const [msgsErr, setMsgsErr] = useState<string | null>(null);
490
+ const [msgsPage, setMsgsPage] = useState(0);
491
+
492
+ // Mark Persistence
493
+ const [markedSessionIds, setMarkedSessionIds] = useState<Set<string>>(new Set());
494
+
495
+ // Settings page scroll offset
496
+ const [settingsOffset, setSettingsOffset] = useState(0);
497
+ const [settingData, setSettingData] = useState<any>(null);
498
+ const [loadingSetting, setLoadingSetting] = useState(false);
499
+ const [setErr, setSetErr] = useState<string | null>(null);
500
+ const [settingsStage, setSettingsStage] = useState<'list' | 'langPicker'>('list');
501
+ const [settingsCursor, setSettingsCursor] = useState(0);
502
+ const [autoModeEnabled, setAutoModeEnabled] = useState(false);
503
+ const [bypassPermsEnabled, setBypassPermsEnabled] = useState(false);
504
+ const [vscodeLinked, setVscodeLinked] = useState(false);
505
+
506
+ const [vscodeLinkErr, setVscodeLinkErr] = useState<string | null>(null);
507
+
508
+ // Auto-clear VSCode link error after 5 seconds
509
+ useEffect(() => {
510
+ if (vscodeLinkErr) {
511
+ const timer = setTimeout(() => setVscodeLinkErr(null), 5000);
512
+ return () => clearTimeout(timer);
513
+ }
514
+ }, [vscodeLinkErr]);
515
+ // Top toolbar state
516
+ const [animEnabled, setAnimEnabled] = useState(true);
517
+ const [tipsEnabled, setTipsEnabled] = useState(true);
518
+
519
+ // Help overlay
520
+ const [showHelp, setShowHelp] = useState(false);
521
+
522
+ // Keyboard navigation for lists
523
+ const [listOffset, setListOffset] = useState(0);
524
+
525
+ // Quick Resume (R)
526
+ const [quickResumeRequested, setQuickResumeRequested] = useState(false);
527
+
528
+ // Compute viewport
529
+ const TOP_H = page === null ? TOP_H_HOME : TOP_H_COMPACT;
530
+ const MID_H = Math.max(5, VIEW_H - TOP_H - BOTTOM_H - (page === null ? 0 : 2));
531
+ const MSGS_PAGE_SIZE = Math.max(1, MID_H - 2);
532
+ const [expandMsgs, setExpandMsgs] = useState(false);
533
+
534
+ // Boot/Reuse singleton local server (with retry)
535
+ useEffect(() => {
536
+ let mounted = true;
537
+ (async () => {
538
+ if (apiUrl) return;
539
+ const entry = path.resolve(import.meta.dir, '../server/index.ts');
540
+ const MAX_RETRIES = 3;
541
+ const RETRY_DELAYS = [0, 2000, 5000]; // 0s, 2s, 5s
542
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
543
+ if (!mounted) return;
544
+ if (attempt > 0) {
545
+ setBootErr(`Attempt ${attempt} failed, retrying in ${RETRY_DELAYS[attempt] / 1000}s...`);
546
+ await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
547
+ }
548
+ if (!mounted) return;
549
+ try {
550
+ const handle = await ensureSingletonLocalServer({ serverEntry: entry });
551
+ if (!mounted) { await handle.stopIfLast(); return; }
552
+ setApiUrl(handle.baseUrl);
553
+ setStopIfLast(() => handle.stopIfLast);
554
+ setBootErr(null);
555
+ return; // Success, exit retry
556
+ } catch (e: any) {
557
+ if (attempt === MAX_RETRIES - 1) {
558
+ setBootErr(e.message || 'Local server failed to start');
559
+ }
560
+ }
561
+ }
562
+ })();
563
+ return () => { mounted = false; if (stopIfLast) stopIfLast(); };
564
+ }, []);
565
+ useEffect(() => {
566
+ let cancelled = false;
567
+ const probe = () => {
568
+ try {
569
+ getGlobalConfig();
570
+ if (!cancelled) setConfigReady(true);
571
+ } catch {
572
+ if (!cancelled) setTimeout(probe, 60);
573
+ }
574
+ };
575
+ probe();
576
+ return () => { cancelled = true; };
577
+ }, []);
578
+
579
+ // Init marks
580
+ useEffect(() => {
581
+ setMarkedSessionIds(loadMarkedSessionIds());
582
+ }, []);
583
+
584
+ // Page switch reset
585
+ useEffect(() => {
586
+ if (page === 'newSession') {
587
+ setNewSessionId(null);
588
+ setCreating(false);
589
+ setCreateErr(null);
590
+ }
591
+ if (page !== 'settings') {
592
+ setSettingsOffset(0);
593
+ setSettingsStage('list');
594
+ setSettingsCursor(0);
595
+ }
596
+ // Close help overlay
597
+ setShowHelp(false);
598
+ }, [page]);
599
+
600
+ // History page entry reset
601
+ useEffect(() => {
602
+ if (page === 'history') {
603
+ setHistoryMenuStage('list');
604
+ setSelectedHistory(null);
605
+ setHistoryCursor(null);
606
+ setSessionMessages([]);
607
+ setMsgsErr(null);
608
+ setMsgsPage(0);
609
+ setExpandMsgs(false);
610
+ }
611
+ }, [page]);
612
+
613
+ // Create Session
614
+ const onCreateSession = async () => {
615
+ setCreating(true); setCreateErr(null);
616
+ try {
617
+ const fsReq = require('fs');
618
+ const pathReq = require('path');
619
+ const { spawn } = require('child_process');
620
+ // Use import.meta.dir for pkg root
621
+ const pkgPath = pathReq.resolve(import.meta.dir, '../../package.json');
622
+ const pkgJson = JSON.parse(fsReq.readFileSync(pkgPath, 'utf-8'));
623
+ const bins = pkgJson.bin || {};
624
+ const isWin = process.platform === 'win32';
625
+ const binName = isWin
626
+ ? (bins['claude-haha'] ? 'claude-haha' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]))
627
+ : (bins['claude-linux'] ? 'claude-linux' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]));
628
+ const spawnCmd = isWin ? 'cmd' : 'sh';
629
+ // Windows calls global bingocode directly
630
+ const spawnArgs = isWin ? ['/c', 'start', 'cmd', '/k', 'bingocode'] : ['-c', `${binName}`];
631
+ const spawnEnv = await buildSpawnEnv();
632
+ spawn(spawnCmd, spawnArgs, {
633
+ cwd: process.env.CALLER_DIR || process.cwd(),
634
+ env: spawnEnv,
635
+ detached: true,
636
+ stdio: 'ignore'
637
+ }).unref();
638
+ setNewSessionId('Started: ' + binName);
639
+ } catch(e: any) {
640
+ setCreateErr(e.message || 'Failed to create');
641
+ } finally {
642
+ setCreating(false);
643
+ }
644
+ };
645
+
646
+ // Paged loading for history
647
+ useEffect(() => {
648
+ if (page === 'history' && historyMenuStage === 'list') {
649
+ setLoadingHist(true); setHistErr(null);
650
+ (async () => {
651
+ try {
652
+ let url = apiUrl + '/api/sessions';
653
+ if (historyCursor) url += `?cursor=${historyCursor}`;
654
+ const res = await axios.get(url);
655
+ const pageData = res.data;
656
+ setHistoryList(pageData?.sessions || []);
657
+ if (historyCursor === null) {
658
+ setHistoryCursor(pageData?.first_id || null);
659
+ }
660
+ setHistoryHasMore(!!pageData?.has_more);
661
+ } catch (e: any) {
662
+ setHistErr(e.message || 'Failed to fetch history');
663
+ } finally {
664
+ setLoadingHist(false);
665
+ }
666
+ })();
667
+ }
668
+ if (page !== 'history') {
669
+ setLoadingHist(false);
670
+ setHistErr(null);
671
+ setHistoryList([]);
672
+ }
673
+ }, [page, historyCursor, historyMenuStage, apiUrl]);
674
+
675
+ // 快速恢复:当按下 R 并已加载历史列表后,自动进入第一个会话窗口
676
+ useEffect(() => {
677
+ if (page === 'history' && historyMenuStage === 'list' && quickResumeRequested && historyList.length) {
678
+ const session = historyList[0];
679
+ if (session) {
680
+ setSelectedHistory(session);
681
+ setHistoryMenuStage('window');
682
+ setQuickResumeRequested(false);
683
+ }
684
+ }
685
+ }, [page, historyMenuStage, quickResumeRequested, historyList]);
686
+
687
+ // 会话消息获取
688
+ useEffect(() => {
689
+ if (page === 'history' && historyMenuStage === 'window' && selectedHistory && apiUrl) {
690
+ let cancelled = false;
691
+ setLoadingMsgs(true);
692
+ setMsgsErr(null);
693
+ setSessionMessages([]);
694
+ setMsgsPage(0);
695
+ (async () => {
696
+ try {
697
+ const resp = await axios.get(`${apiUrl}/api/sessions/${selectedHistory.id}/messages`);
698
+ if (!cancelled) {
699
+ const msgs: MessageEntry[] = resp.data?.messages ?? [];
700
+ setSessionMessages(msgs);
701
+ }
702
+ } catch (e: any) {
703
+ if (!cancelled) setMsgsErr(e.message || 'Failed to load messages');
704
+ } finally {
705
+ if (!cancelled) setLoadingMsgs(false);
706
+ }
707
+ })();
708
+ return () => { cancelled = true; };
709
+ } else {
710
+ setSessionMessages([]);
711
+ setLoadingMsgs(false);
712
+ setMsgsErr(null);
713
+ }
714
+ }, [page, historyMenuStage, selectedHistory, apiUrl]);
715
+
716
+ // Settings data
717
+ useEffect(() => {
718
+ if (page === 'settings') {
719
+ setLoadingSetting(true); setSetErr(null);
720
+ (async () => {
721
+ try {
722
+ const data = (await import('../utils/settings/settings')).default;
723
+ setSettingData(data);
724
+ } catch(e: any) {
725
+ setSetErr(e.message||'Failed to load settings');
726
+ } finally { setLoadingSetting(false); }
727
+ })();
728
+ } else {
729
+ setSettingData(null);
730
+ setLoadingSetting(false);
731
+ setSetErr(null);
732
+ }
733
+ }, [page]);
734
+
735
+ // Keyboard interactions
736
+ useInput((input, key) => {
737
+ // Language toggle (en zh ja en)
738
+ if (input === 'l' || input === 'L') {
739
+ const langOrder: Lang[] = ['en', 'zh', 'ja'];
740
+ const nextLang = langOrder[(langOrder.indexOf(lang) + 1) % langOrder.length];
741
+ setLang(nextLang);
742
+ try { writeBingoSettings({ language: nextLang }); } catch {}
743
+ return;
744
+ }
745
+
746
+ // Theme toggle (G)
747
+ if ((input === 'g' || input === 'G')) {
748
+ const order = ['light', 'dark', 'highContrast'] as const;
749
+ const curr = String(theme || 'light');
750
+ const idx = Math.max(0, order.indexOf(curr as any));
751
+ const next = order[(idx + 1) % order.length];
752
+ setTheme(next as any);
753
+ try { saveGlobalConfig(current => ({ ...current, theme: next as any })); } catch {}
754
+ return;
755
+ }
756
+
757
+ // Top animation toggle (O)
758
+ if (input === 'o' || input === 'O') {
759
+ setAnimEnabled(v => {
760
+ const next = !v;
761
+ try { saveGlobalConfig(current => ({ ...current, uiAnimEnabled: next })); } catch {}
762
+ return next;
763
+ });
764
+ return;
765
+ }
766
+ // Top Tips toggle (T)
767
+ if (input === 't' || input === 'T') {
768
+ setTipsEnabled(v => {
769
+ const next = !v;
770
+ try { saveGlobalConfig(current => ({ ...current, uiTipsEnabled: next })); } catch {}
771
+ return next;
772
+ });
773
+ return;
774
+ }
775
+
776
+ // Help overlay (?)
777
+ if (input === '?') {
778
+ setShowHelp(v => !v);
779
+ return;
780
+ }
781
+
782
+ // ESC to back or close help
783
+ if (key.escape) {
784
+ if (showHelp) { setShowHelp(false); return; }
785
+ if (page === 'provider') return; // Handled internally
786
+ // Settings: langPicker → back to list; list → back to main menu
787
+ if (page === 'settings') {
788
+ if (settingsStage === 'langPicker') { setSettingsStage('list'); return; }
789
+ }
790
+ setPage(null);
791
+ setHistoryMenuStage('list');
792
+ setSelectedHistory(null);
793
+ setHistoryCursor(null);
794
+ setSessionMessages([]);
795
+ setMsgsPage(0);
796
+ setSettingsOffset(0);
797
+ return;
798
+ }
799
+
800
+ // Quick entries: N New, R Resume, P Provider
801
+ if (input === 'n' || input === 'N') {
802
+ setPage('newSession');
803
+ onCreateSession();
804
+ return;
805
+ }
806
+ if (input === 'r' || input === 'R') {
807
+ setPage('history');
808
+ setQuickResumeRequested(true);
809
+ return;
810
+ }
811
+ if (input === 'p' || input === 'P') {
812
+ setPage('provider');
813
+ return;
814
+ }
815
+
816
+ // Main menu navigation
817
+ if (!showHelp && key.leftArrow && page === null) {
818
+ setNavIndex(i => (i - 1 + menuItems.length) % menuItems.length);
819
+ return;
820
+ }
821
+ if (!showHelp && key.rightArrow && page === null) {
822
+ setNavIndex(i => (i + 1) % menuItems.length);
823
+ return;
824
+ }
825
+ if (!showHelp && key.return && page === null) {
826
+ const keyVal = menuItems[navIndex].value as MenuKey;
827
+ setPage(keyVal);
828
+ if (keyVal === 'newSession') onCreateSession();
829
+ if (keyVal === 'exit') exit();
830
+ return;
831
+ }
832
+
833
+ // History shortcuts
834
+ if (!showHelp && page === 'history') {
835
+ if (historyMenuStage === 'list') {
836
+ const HIST_VISIBLE = MID_H - 2;
837
+ if (key.downArrow || input === 'j' || input === '\u001b[B') {
838
+ // Internal SelectInput handles cursor, we just need to track offset for ScrollBar
839
+ setListOffset(o => Math.min(o + 1, Math.max(0, groupedHistoryItems.length - HIST_VISIBLE)));
840
+ }
841
+ if (key.upArrow || input === 'k' || input === '\u001b[A') {
842
+ setListOffset(o => Math.max(0, o - 1));
843
+ }
844
+ if (input === 'q') {
845
+ setPage(null);
846
+ setHistoryMenuStage('list');
847
+ setSelectedHistory(null);
848
+ setHistoryCursor(null);
849
+ setListOffset(0);
850
+ return;
851
+ }
852
+ if (input === 'j' && historyHasMore) {
853
+ setHistoryCursor(historyList[historyList.length - 1]?.id || null);
854
+ setListOffset(0);
855
+ return;
856
+ }
857
+ if (input === 'k') {
858
+ setHistoryCursor(null);
859
+ setListOffset(0);
860
+ return;
861
+ }
862
+ } else if (historyMenuStage === 'window') {
863
+ if ((input === 'm' || input === 'M') && selectedHistory) {
864
+ handleHistoryMenuAction('__toggle_mark');
865
+ return;
866
+ }
867
+ if ((input === 'c' || input === 'C') && selectedHistory) {
868
+ handleHistoryMenuAction('__continue');
869
+ return;
870
+ }
871
+ if ((input === 'd' || input === 'D') && selectedHistory) {
872
+ handleHistoryMenuAction('__delete');
873
+ return;
874
+ }
875
+ if (input === 'q') {
876
+ handleHistoryMenuAction('__back');
877
+ return;
878
+ }
879
+ // Message scrolling
880
+ if (key.upArrow || input === 'k') {
881
+ setMsgsPage(p => Math.max(0, p - 1));
882
+ return;
883
+ }
884
+ if (key.downArrow || input === 'j') {
885
+ setMsgsPage(p => p + 1);
886
+ return;
887
+ }
888
+
889
+ } else if (historyMenuStage === 'deleteConfirm') {
890
+ if (input === 'q') {
891
+ handleHistoryMenuAction('__cancel_delete');
892
+ return;
893
+ }
894
+ }
895
+ }
896
+
897
+ // Settings interactions
898
+ if (!showHelp && page === 'settings') {
899
+ if (settingsStage === 'list') {
900
+ // +1 for the fixed Language row prepended before settingData entries
901
+ const totalRows = 4 + (settingData && typeof settingData === 'object' ? Object.keys(settingData).length : 0);
902
+ const visible = Math.max(1, MID_H - 2);
903
+ if (key.downArrow || input === 'j') {
904
+ setSettingsCursor(c => Math.min(totalRows - 1, c + 1));
905
+ setSettingsOffset(o => Math.min(Math.max(0, totalRows - visible), o + 1));
906
+ }
907
+ if (key.upArrow || input === 'k') {
908
+ setSettingsCursor(c => Math.max(0, c - 1));
909
+ setSettingsOffset(o => Math.max(0, o - 1));
910
+ }
911
+ if (key.return) {
912
+ // Row 0 is the interactive Language row
913
+ if (settingsCursor === 0) {
914
+ setSettingsStage('langPicker');
915
+ } else if (settingsCursor === 1) {
916
+ // Row 1: toggle Auto Mode
917
+ setAutoModeEnabled(prev => {
918
+ const next = !prev;
919
+ try {
920
+ writeBingoSettings({ autoModeEnabled: next });
921
+ const gcfg = readGlobalClaudeConfig();
922
+ gcfg.cachedGrowthBookFeatures = {
923
+ ...(gcfg.cachedGrowthBookFeatures as Record<string, unknown>),
924
+ tengu_auto_mode_config: next
925
+ ? { enabled: 'enabled', allowModels: ['*'] }
926
+ : { enabled: 'disabled' },
927
+ };
928
+ writeGlobalClaudeConfig(gcfg);
929
+ } catch {
930
+ return prev; // write failed — keep old state
931
+ }
932
+ return next;
933
+ });
934
+ } else if (settingsCursor === 2) {
935
+ // Row 2: toggle Bypass Permissions
936
+ setBypassPermsEnabled(prev => {
937
+ const next = !prev;
938
+ try {
939
+ writeBingoSettings({ bypassPermsEnabled: next });
940
+ const safeSettings = next
941
+ ? { permissions: { defaultMode: 'bypassPermissions', skipDangerousModePermissionPrompt: true } }
942
+ : { permissions: { defaultMode: 'default' } };
943
+ writeClaudeSettings(safeSettings);
944
+ } catch {
945
+ return prev; // write failed — keep old state
946
+ }
947
+ return next;
948
+ });
949
+ } else if (settingsCursor === 3) {
950
+ // Row 3: toggle VS Code link -- HTTP first, then state+settings on success
951
+ const port = (process.env.BINGO_PORT ? parseInt(process.env.BINGO_PORT) : 3456) || 3456;
952
+ const apiBase = process.env.BASE_API_URL || `http://127.0.0.1:${port}`;
953
+ const next = !vscodeLinked;
954
+ const method = next ? 'POST' : 'DELETE';
955
+ setVscodeLinkErr(null);
956
+ axios({ method, url: `${apiBase}/api/providers/link-vscode`, timeout: 5000 })
957
+ .then(() => {
958
+ try { writeBingoSettings({ vscodeLinked: next }); } catch { /* ignore write errors */ }
959
+ setVscodeLinked(next);
960
+ })
961
+ .catch((error) => {
962
+ logError(error);
963
+ setVscodeLinkErr(error.message || 'Failed to update VS Code link');
964
+ });
965
+ }
966
+ }
967
+ }
968
+ // langPicker stage: ESC handled above; selection via SelectInput onSelect
969
+ }
970
+ }, [menuItems, page, historyMenuStage, historyList, historyHasMore, navIndex, sessionMessages, settingData, MID_H, MSGS_PAGE_SIZE, showHelp, theme, settingsStage, settingsCursor, autoModeEnabled, bypassPermsEnabled, vscodeLinked, vscodeLinkErr]);
971
+
972
+ function cleanText(text: string): string {
973
+ return String(text ?? '').replace(/[\n\r]+/g, ' ').replace(/\u001b\[[0-9;]*m/g, '').trim();
974
+ }
975
+
976
+ function clampTextLines(text: string, maxWidth: number, maxLines: number) {
977
+ const cleaned = cleanText(text);
978
+ const out: string[] = [];
979
+ if (cleaned.length <= maxWidth) {
980
+ out.push(cleaned);
981
+ } else {
982
+ out.push(cleaned.slice(0, maxWidth - 1) + '…');
983
+ }
984
+ return out.join('\n');
985
+ }
986
+
987
+ function makeHistoryLabel(item: any, width: number, isMarked: boolean) {
988
+ const star = isMarked ? '★ ' : '';
989
+ const ts = String(item.createdAt || '').slice(0, 16).replace('T', ' ');
990
+ const cnt = String(item.messageCount ?? 0).padStart(3, ' ');
991
+ // Reserved width for: prefix(star+time) + spacer(2) + suffix(1+cnt)
992
+ // Star is width 2, ts is width 16, spacer is 2, cnt is 3, padding is 1. Total = 24
993
+ const reserved = 24;
994
+ const titleMax = Math.max(8, width - reserved);
995
+ const title = safePadEnd(truncate(String(item.title || ''), titleMax), titleMax);
996
+ return `${star}${ts} ${title} ${cnt}`;
997
+ }
998
+
999
+ // 新增:会话恢复(供快捷键和右侧菜单复用)
1000
+ // workDir: 会话原始工作目录,用于跨文件夹恢复(确保新进程能找到 session 文件)
1001
+ async function resumeSession(sessionId: string, workDir?: string | null) {
1002
+ try {
1003
+ const fsReq = require('fs');
1004
+ const pathReq = require('path');
1005
+ const { spawn } = require('child_process');
1006
+ // import.meta.dir 定位包根,避免 process.cwd() 指向用户目录
1007
+ const pkgPath = pathReq.resolve(import.meta.dir, '../../package.json');
1008
+ const pkgJson = JSON.parse(fsReq.readFileSync(pkgPath, 'utf-8'));
1009
+ const bins = pkgJson.bin || {};
1010
+ const isWin = process.platform === 'win32';
1011
+ const binName = isWin
1012
+ ? (bins['claude-haha'] ? 'claude-haha' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]))
1013
+ : (bins['claude-linux'] ? 'claude-linux' : (bins['claude'] ? 'claude' : Object.keys(bins)[0]));
1014
+ const spawnCmd = isWin ? 'cmd' : 'sh';
1015
+ // Windows 直接调全局 bingocode 命令,不用 bun 前缀
1016
+ const spawnArgs = isWin
1017
+ ? ['/c', 'start', 'cmd', '/k', `bingocode --resume ${sessionId}`]
1018
+ : ['-c', `${binName} --resume ${sessionId}`];
1019
+ const spawnEnv = await buildSpawnEnv();
1020
+ spawn(spawnCmd, spawnArgs, {
1021
+ cwd: workDir || process.env.CALLER_DIR || process.cwd(),
1022
+ env: spawnEnv,
1023
+ detached: true,
1024
+ stdio: 'ignore'
1025
+ }).unref();
1026
+ } catch {}
1027
+ }
1028
+
1029
+
1030
+ // 历史分组展示
1031
+ const groupedHistoryItems = useMemo(() => {
1032
+ if (!historyList || !Array.isArray(historyList)) return [];
1033
+ const now = new Date();
1034
+ const today: any[] = [];
1035
+ const week: any[] = [];
1036
+ const earlier: any[] = [];
1037
+ const marked: any[] = [];
1038
+
1039
+ for (const item of historyList) {
1040
+ if (markedSessionIds.has(item.id)) {
1041
+ marked.push(item);
1042
+ continue;
1043
+ }
1044
+ const dt = new Date(item.createdAt);
1045
+ const isToday =
1046
+ dt.getFullYear() === now.getFullYear() &&
1047
+ dt.getMonth() === now.getMonth() &&
1048
+ dt.getDate() === now.getDate();
1049
+ const weekStart = new Date(now);
1050
+ weekStart.setDate(now.getDate() - ((now.getDay() + 6) % 7));
1051
+ weekStart.setHours(0, 0, 0, 0);
1052
+ if (isToday) today.push(item);
1053
+ else if (dt >= weekStart) week.push(item);
1054
+ else earlier.push(item);
1055
+ }
1056
+ function groupToItems(group: any[], groupTitle: string) {
1057
+ if (group.length === 0) return [];
1058
+ return [
1059
+ { label: groupTitle, value: `__group_${groupTitle}`, isGroup: true },
1060
+ ...group.map(item => {
1061
+ const isMarked = markedSessionIds.has(item.id);
1062
+ return {
1063
+ label: makeHistoryLabel(item, Math.max(20, VIEW_W - 8), isMarked),
1064
+ value: item.id,
1065
+ color: isMarked ? 'yellow' : undefined,
1066
+ };
1067
+ })
1068
+ ];
1069
+ }
1070
+ const items = [
1071
+ ...groupToItems(marked, '—— Marked ——'),
1072
+ ...groupToItems(today, '—— Today ——'),
1073
+ ...groupToItems(week, '—— This Week ——'),
1074
+ ...groupToItems(earlier, '—— Earlier ——'),
1075
+ ];
1076
+ return items;
1077
+ }, [historyList, markedSessionIds]);
1078
+
1079
+ // Toggle Mark
1080
+ const toggleMarkSession = (sessionId: string) => {
1081
+ setMarkedSessionIds(prev => {
1082
+ const next = new Set(prev);
1083
+ if (next.has(sessionId)) next.delete(sessionId);
1084
+ else next.add(sessionId);
1085
+ saveMarkedSessionIds(next);
1086
+ return next;
1087
+ });
1088
+ };
1089
+
1090
+ const handleHistoryMenuAction = (action: string) => {
1091
+ if (action === '__back') {
1092
+ setHistoryMenuStage('list');
1093
+ setSelectedHistory(null);
1094
+ setMsgsPage(0);
1095
+ return;
1096
+ }
1097
+ if (!selectedHistory) return;
1098
+
1099
+ switch (action) {
1100
+ case '__toggle_mark':
1101
+ toggleMarkSession(selectedHistory.id);
1102
+ break;
1103
+ case '__continue':
1104
+ resumeSession(selectedHistory.id, selectedHistory.workDir);
1105
+ break;
1106
+ case '__delete':
1107
+ setHistoryMenuStage('deleteConfirm');
1108
+ break;
1109
+ case '__confirm_delete':
1110
+ handleDeleteSession(selectedHistory.id);
1111
+ break;
1112
+ case '__cancel_delete':
1113
+ setHistoryMenuStage('window');
1114
+ break;
1115
+ }
1116
+ };
1117
+
1118
+
1119
+ // Refresh history
1120
+ const refreshHistoryList = () => {
1121
+ setLoadingHist(true); setHistErr(null);
1122
+ let url = apiUrl + '/api/sessions';
1123
+ axios.get(url).then(res => {
1124
+ const pageData = res.data;
1125
+ setHistoryList(pageData?.sessions || []);
1126
+ setHistoryCursor(pageData?.first_id || null);
1127
+ setHistoryHasMore(!!pageData?.has_more);
1128
+ }).catch(e => {
1129
+ setHistErr(e.message || 'Failed to fetch history');
1130
+ }).finally(() => setLoadingHist(false));
1131
+ };
1132
+
1133
+ // Delete Session
1134
+ const handleDeleteSession = (sessionId: string) => {
1135
+ const url = apiUrl.replace(/\/+$/, '') + '/api/sessions/' + sessionId;
1136
+ axios.delete(url)
1137
+ .catch(e => {})
1138
+ .finally(() => {
1139
+ setHistoryMenuStage('list');
1140
+ setSelectedHistory(null);
1141
+ setHistoryCursor(null);
1142
+ refreshHistoryList();
1143
+ });
1144
+ };
1145
+
1146
+ // Secondary menu (bottom bar right)
1147
+ const secondaryMenu: SecondaryMenu = useMemo(() => {
1148
+ if (page === 'history' && historyMenuStage === 'window' && selectedHistory) {
1149
+ const isMarked = markedSessionIds.has(selectedHistory.id);
1150
+ const markLabel = isMarked ? i18nMap[lang].unmark : i18nMap[lang].mark;
1151
+ return {
1152
+ title: 'Session Actions',
1153
+ items: [
1154
+ { label: markLabel, value: '__toggle_mark' },
1155
+ { label: '→ Continue session', value: '__continue' },
1156
+ { label: '→ Delete session', value: '__delete' },
1157
+ { label: '← Back to list', value: '__back' },
1158
+ ],
1159
+ onSelect: (item: any) => {
1160
+ if (item.value === '__back') {
1161
+ setHistoryMenuStage('list');
1162
+ setSelectedHistory(null);
1163
+ setMsgsPage(0);
1164
+ } else if (item.value === '__continue') {
1165
+ resumeSession(selectedHistory.id, selectedHistory.workDir);
1166
+ } else if (item.value === '__delete') {
1167
+ setHistoryMenuStage('deleteConfirm');
1168
+ } else if (item.value === '__toggle_mark') {
1169
+ toggleMarkSession(selectedHistory.id);
1170
+ }
1171
+ }
1172
+ };
1173
+ }
1174
+
1175
+ if (page === 'history' && historyMenuStage === 'deleteConfirm' && selectedHistory) {
1176
+ return {
1177
+ title: 'Confirm Delete',
1178
+ items: [
1179
+ { label: 'Yes, delete', value: '__confirm_delete' },
1180
+ { label: 'No, back', value: '__cancel_delete' },
1181
+ ],
1182
+ onSelect: (item: any) => {
1183
+ if (item.value === '__cancel_delete') {
1184
+ setHistoryMenuStage('window');
1185
+ } else if (item.value === '__confirm_delete') {
1186
+ handleDeleteSession(selectedHistory.id);
1187
+ }
1188
+ }
1189
+ };
1190
+ }
1191
+ return null;
1192
+ }, [page, historyMenuStage, selectedHistory, markedSessionIds, lang]);
1193
+
1194
+ // Help Overlay
1195
+ function renderHelpOverlay() {
1196
+ return (
1197
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1198
+ <Text color="magenta">{i18nMap[lang].helpTitle}</Text>
1199
+ <Text> </Text>
1200
+ <Text color="cyan">N</Text><Text> New Session</Text>
1201
+ <Text color="cyan">R</Text><Text> Quick Resume</Text>
1202
+ <Text color="cyan">P</Text><Text> Open Provider Config</Text>
1203
+ <Text color="cyan">G</Text><Text> Toggle Theme (light/dark/highContrast)</Text>
1204
+ <Text color="cyan">L</Text><Text> Toggle Language (en zh → ja)</Text>
1205
+ <Text color="cyan">O</Text><Text> Toggle Top Animation</Text>
1206
+ <Text color="cyan">T</Text><Text> Toggle Top Tips</Text>
1207
+ <Text color="cyan">?</Text><Text> Toggle Help</Text>
1208
+ <Text> </Text>
1209
+ <Hint>ESC to close · Works anywhere</Hint>
1210
+ </Box>
1211
+ );
1212
+ }
1213
+
1214
+ // Center Content
1215
+ function renderCenter() {
1216
+ if (showHelp) return renderHelpOverlay();
1217
+
1218
+ // Home: WelcomeV2 (58 cols wide)
1219
+ if (page === null) {
1220
+ const WELCOME_W = 58;
1221
+ const leftPad = Math.max(0, Math.floor((VIEW_W - WELCOME_W) / 2));
1222
+ return (
1223
+ <Box flexDirection="column" width={VIEW_W} height={MID_H}>
1224
+ <Box flexDirection="row" width={VIEW_W} flexGrow={1}>
1225
+ <Box width={leftPad} flexShrink={0} />
1226
+ <WelcomeV2 />
1227
+ </Box>
1228
+ {!apiUrl && !bootErr && (
1229
+ <StateDisplay type="loading" message="Starting server..." />
1230
+ )}
1231
+ {bootErr && (
1232
+ <StateDisplay type="error" message={`Server boot failed: ${bootErr}`} />
1233
+ )}
1234
+ </Box>
1235
+ );
1236
+ }
1237
+
1238
+ // New Session
1239
+ if (page === 'newSession') {
1240
+ return (
1241
+ <Box flexDirection="column" width={VIEW_W} height={MID_H}>
1242
+ {creating && <StateDisplay type="loading" message="Creating..." />}
1243
+ {createErr && <StateDisplay type="error" message={`Failed to create: ${createErr}`} />}
1244
+ {newSessionId && <Box alignItems="center" justifyContent="center" flexGrow={1}><Text color="green">New Session: {newSessionId}</Text></Box>}
1245
+ {!creating && !createErr && !newSessionId && <StateDisplay type="empty" message="Entered new session page, waiting for result..." />}
1246
+ </Box>
1247
+ );
1248
+ }
1249
+
1250
+ // History
1251
+ if (page === 'history') {
1252
+ if (histErr) return <StateDisplay type="error" message={histErr} onRetry={refreshHistoryList} />;
1253
+ if (historyMenuStage === 'deleteConfirm' && selectedHistory) {
1254
+ const halfH = Math.floor(MID_H / 2);
1255
+ const items = [
1256
+ { label: 'Yes, Delete', value: '__confirm_delete' },
1257
+ { label: 'No, Back', value: '__cancel_delete' },
1258
+ ];
1259
+ return (
1260
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1261
+ <Box height={halfH} flexDirection="column" paddingX={1} paddingTop={1}>
1262
+ <Text color="red" bold>Confirm Delete?</Text>
1263
+ <Text>Title: {selectedHistory.title || 'Untitled'}</Text>
1264
+ <Text dimColor>Time: {selectedHistory.createdAt?.replace('T',' ')}</Text>
1265
+ <Text dimColor>ID: {selectedHistory.id}</Text>
1266
+ </Box>
1267
+ <Panel height={MID_H - halfH} borderStyle="round" borderColor="red" paddingX={1}>
1268
+ <SelectInput
1269
+ items={items}
1270
+ onSelect={(item) => handleHistoryMenuAction(String(item.value))}
1271
+ />
1272
+ <Hint>Enter Confirm · q Cancel</Hint>
1273
+ </Panel>
1274
+ </Box>
1275
+ );
1276
+ }
1277
+ if (!historyList.length && loadingHist) {
1278
+ return <StateDisplay type="loading" message="Loading..." />;
1279
+ }
1280
+ if (!historyList.length) {
1281
+ return <StateDisplay type="empty" message={i18nMap[lang].emptyHistory} />;
1282
+ }
1283
+
1284
+ const ACTIONS_H = 7; // Actions title(1) + 4 items + hint(1) + padding(1)
1285
+ const LIST_H = Math.max(2, MID_H - ACTIONS_H - 1);
1286
+
1287
+ if (historyMenuStage === 'window' && selectedHistory) {
1288
+ // Detailed View with Split
1289
+ const isMarked = markedSessionIds.has(selectedHistory.id);
1290
+ const displayMsgs = sessionMessages.filter(
1291
+ m => m.type === 'user' || m.type === 'assistant' || m.type === 'system'
1292
+ );
1293
+ const totalPages = Math.max(1, Math.ceil(displayMsgs.length / MSGS_PAGE_SIZE));
1294
+ const safePage = Math.min(msgsPage, totalPages - 1);
1295
+ const pageStart = safePage * MSGS_PAGE_SIZE;
1296
+ const pageMsgs = displayMsgs.slice(pageStart, pageStart + MSGS_PAGE_SIZE);
1297
+
1298
+ return (
1299
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1300
+ {/* Upper Pane: Preview */}
1301
+ <Box height={LIST_H} flexDirection="column" paddingX={1} overflow="hidden">
1302
+ <Box justifyContent="space-between" marginBottom={0}>
1303
+ <Text color={isMarked ? 'yellow' : 'cyan'} bold>
1304
+ {isMarked ? '★ ' : ''}{truncate(selectedHistory.title || 'Untitled', VIEW_W - 24)}
1305
+ </Text>
1306
+ <Text dimColor>{selectedHistory.createdAt?.slice(0,16).replace('T',' ')}</Text>
1307
+ </Box>
1308
+
1309
+ <Box flexDirection="column" flexGrow={1} overflow="hidden">
1310
+ {loadingMsgs && <StateDisplay type="loading" message="Loading messages..." />}
1311
+ {msgsErr && <StateDisplay type="error" message={msgsErr} />}
1312
+ {!loadingMsgs && pageMsgs.length === 0 && <StateDisplay type="empty" message="No messages" />}
1313
+ {pageMsgs.map((msg) => {
1314
+ const text = extractTextFromContent(msg.content);
1315
+ const roleLabel = msg.type === 'user' ? 'You' : 'Bot';
1316
+ const roleColor = msg.type === 'user' ? 'green' : 'cyan';
1317
+ return (
1318
+ <Box key={msg.id} marginBottom={0} flexDirection="column" height={1} overflow="hidden">
1319
+ <Text color={roleColor} bold>{roleLabel}: <Text color="white" bold={false}>{clampTextLines(text, VIEW_W - 10, 1)}</Text></Text>
1320
+ </Box>
1321
+ );
1322
+ })}
1323
+ </Box>
1324
+ {totalPages > 1 && (
1325
+ <Box justifyContent="center" height={1}>
1326
+ <Hint>Page {safePage + 1}/{totalPages} (↑↓ to scroll)</Hint>
1327
+ </Box>
1328
+ )}
1329
+ </Box>
1330
+
1331
+ <Box height={1} marginBottom={0}><Text dimColor>{'─'.repeat(VIEW_W - 4)}</Text></Box>
1332
+
1333
+ {/* Lower Pane: Actions */}
1334
+ <Box height={ACTIONS_H} paddingX={1} flexDirection="column" overflow="hidden">
1335
+ <Text color="magenta" bold>Actions</Text>
1336
+ <Box marginTop={0} height={ACTIONS_H - 2} overflow="hidden">
1337
+ <SelectInput
1338
+ items={secondaryMenu?.items || []}
1339
+ onSelect={secondaryMenu?.onSelect}
1340
+ />
1341
+ </Box>
1342
+ <Hint>ESC Back · ↑↓ Select Action · Q/M/C Shortcut</Hint>
1343
+ </Box>
1344
+ </Box>
1345
+ );
1346
+ }
1347
+
1348
+ // History List View (Default)
1349
+ // MID_H - 1 (hint bar at top) - 1 (scrollbar safety) = MID_H - 2 visible items
1350
+ const HIST_VISIBLE = MID_H - 2;
1351
+ const start = Math.min(listOffset, Math.max(0, groupedHistoryItems.length - HIST_VISIBLE));
1352
+ const slicedItems = groupedHistoryItems.slice(start, start + HIST_VISIBLE);
1353
+
1354
+ return (
1355
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1356
+ {/* Hint bar — fixed 1 row at top, never overlaps list */}
1357
+ <Box height={1} paddingX={1}>
1358
+ <Hint>{i18nMap[lang].historyHint}</Hint>
1359
+ </Box>
1360
+ {/* List area — takes the rest of the height */}
1361
+ <Box flexDirection="row" flexGrow={1} position="relative">
1362
+ <Box flexDirection="column" flexGrow={1} paddingX={1}>
1363
+ <SelectInput
1364
+ key={`${historyCursor ?? 'first'}:${slicedItems.length}:${start}`}
1365
+ items={slicedItems}
1366
+ onSelect={item => {
1367
+ if (String(item.value).startsWith('__group_')) return;
1368
+ const session = historyList.find(h => h.id === item.value);
1369
+ if (session) {
1370
+ setSelectedHistory(session);
1371
+ setHistoryMenuStage('window');
1372
+ }
1373
+ }}
1374
+ itemComponent={({ isSelected, label }) => {
1375
+ const it = groupedHistoryItems.find(i => i.label === label);
1376
+ const isGroup = it?.isGroup;
1377
+ const color = it?.color;
1378
+ return (
1379
+ <Box height={1} overflow="hidden">
1380
+ <Text wrap="truncate" color={isGroup ? 'gray' : (color ? color : (isSelected ? 'cyan' : undefined))}>
1381
+ {isSelected ? '> ' : ' '}{label}
1382
+ </Text>
1383
+ </Box>
1384
+ )
1385
+ }}
1386
+ />
1387
+ </Box>
1388
+ <ScrollBar total={groupedHistoryItems.length} offset={start} height={MID_H - 3} />
1389
+ </Box>
1390
+ </Box>
1391
+ );
1392
+ }
1393
+
1394
+ // Provider
1395
+ if (page === 'provider') {
1396
+ if (!apiUrl) {
1397
+ return (
1398
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1399
+ <StateDisplay
1400
+ type={bootErr ? "error" : "loading"}
1401
+ message={bootErr ? `Server boot failed: ${bootErr}` : 'Starting server, please wait...'}
1402
+ onRetry={() => process.exit(1)} // Or another way to trigger reboot
1403
+ />
1404
+ <Text dimColor alignSelf="center">ESC for main menu</Text>
1405
+ </Box>
1406
+ );
1407
+ }
1408
+ return (
1409
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1410
+ <ProviderPanel apiUrl={apiUrl} height={MID_H} onBack={() => setPage(null)} />
1411
+ </Box>
1412
+ );
1413
+ }
1414
+
1415
+ // Settings
1416
+ if (page === 'settings') {
1417
+ if (loadingSetting) return <StateDisplay type="loading" message="Loading settings..." />;
1418
+ if (setErr) return <StateDisplay type="error" message={setErr} />;
1419
+
1420
+ const tS = i18nMap[lang];
1421
+ const currentLangLabel = LANG_OPTIONS.find(o => o.value === lang)?.label ?? lang;
1422
+
1423
+ // --- langPicker sub-menu ---
1424
+ if (settingsStage === 'langPicker') {
1425
+ return (
1426
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1427
+ <Box paddingX={1} marginBottom={1}>
1428
+ <Text color="magenta" bold>{tS.langPickerTitle}</Text>
1429
+ </Box>
1430
+ <Box paddingX={2} flexGrow={1} flexDirection="column">
1431
+ <SelectInput
1432
+ items={tS.langOptions}
1433
+ initialIndex={tS.langOptions.findIndex(o => o.value === lang)}
1434
+ onSelect={(item: { label: string; value: Lang }) => {
1435
+ setLang(item.value);
1436
+ try { writeBingoSettings({ language: item.value }); } catch {}
1437
+ setSettingsStage('list');
1438
+ }}
1439
+ />
1440
+ </Box>
1441
+ <Box paddingX={1}>
1442
+ <Hint>↩ confirm · ESC back</Hint>
1443
+ </Box>
1444
+ </Box>
1445
+ );
1446
+ }
1447
+
1448
+ // --- settings list ---
1449
+ type SettingRow = { key: string; label: string; value: string; interactive: boolean };
1450
+ const fixedRows: SettingRow[] = [
1451
+ { key: '__lang', label: tS.langLabel, value: currentLangLabel, interactive: true },
1452
+ { key: '__autoMode', label: tS.autoModeLabel, value: autoModeEnabled ? tS.autoModeOn : tS.autoModeOff, interactive: true },
1453
+ { key: '__bypassPerms', label: tS.bypassPermsLabel, value: bypassPermsEnabled ? tS.bypassPermsOn : tS.bypassPermsOff, interactive: true },
1454
+ { key: '__vscode', label: tS.vscodeLabel, value: vscodeLinked ? tS.vscodeOn : tS.vscodeOff, interactive: true },
1455
+ ];
1456
+ const dataEntries = settingData && typeof settingData === 'object' ? Object.entries(settingData) : [];
1457
+ const dataRows: SettingRow[] = dataEntries.map(([k, v]) => ({
1458
+ key: k,
1459
+ label: k,
1460
+ value: typeof v === 'object' ? JSON.stringify(v) : String(v),
1461
+ interactive: false,
1462
+ }));
1463
+ const allRows: SettingRow[] = [...fixedRows, ...dataRows];
1464
+ const visible = Math.max(1, MID_H - 2);
1465
+ const start = Math.min(settingsOffset, Math.max(0, allRows.length - visible));
1466
+ const sliced = allRows.slice(start, start + visible);
1467
+
1468
+ return (
1469
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1470
+ <Box flexDirection="row" position="relative" flexGrow={1}>
1471
+ <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
1472
+ {sliced.map((row, idx) => {
1473
+ const absIdx = start + idx;
1474
+ const isCursor = absIdx === settingsCursor;
1475
+ const prefix = isCursor ? '>' : ' ';
1476
+ const labelColor = isCursor ? 'cyan' : (row.interactive ? 'white' : 'gray');
1477
+ const valueColor = row.interactive ? 'green' : undefined;
1478
+ return (
1479
+ <Box key={row.key} height={1}>
1480
+ <Text color={labelColor}>
1481
+ {prefix} {row.label}:{' '}
1482
+ <Text color={valueColor ?? (isCursor ? 'white' : 'gray')}>
1483
+ {row.value}
1484
+ {row.interactive ? ' ↩' : ''}
1485
+ </Text>
1486
+ </Text>
1487
+ </Box>
1488
+ );
1489
+ })}
1490
+ </Box>
1491
+ <ScrollBar total={allRows.length} offset={start} height={visible - 1} />
1492
+ </Box>
1493
+ <Box paddingX={1}>
1494
+ {vscodeLinkErr && <Text color="red">{vscodeLinkErr}</Text>}
1495
+ {!vscodeLinkErr && <Hint>{tS.settingsHint} · {start + 1}-{Math.min(start + visible, allRows.length)}/{allRows.length}</Hint>}
1496
+ </Box>
1497
+ </Box>
1498
+ );
1499
+ }
1500
+
1501
+ // About
1502
+ if (page === 'about') {
1503
+ return (
1504
+ <Box width={VIEW_W} height={MID_H} flexDirection="column">
1505
+ <Text color="cyan" bold>{i18nMap[lang].about}</Text>
1506
+ <Box marginTop={1} flexDirection="column">
1507
+ <Text>{(i18nMap[lang] as any).aboutContent}</Text>
1508
+ </Box>
1509
+ <Box marginTop={1}>
1510
+ <Hint>
1511
+ API Base: {apiUrl}
1512
+ </Hint>
1513
+ </Box>
1514
+ <Box marginTop={1}>
1515
+ <Text color="gray">{(i18nMap[lang] as any).aboutFooter}</Text>
1516
+ </Box>
1517
+ </Box>
1518
+ );
1519
+ }
1520
+
1521
+ // Exit
1522
+ if (page === 'exit') {
1523
+ exit();
1524
+ return <Box width={VIEW_W} height={MID_H}><Text>Exiting...</Text></Box>;
1525
+ }
1526
+
1527
+ return <Box width={VIEW_W} height={MID_H} />;
1528
+ }
1529
+
1530
+ // Exit logic
1531
+ if (terminalSize.columns < 60 || terminalSize.rows < 15) {
1532
+ return (
1533
+ <Box flexDirection="column" padding={2}>
1534
+ <Text color="red">Terminal too small!</Text>
1535
+ <Text>Current: {terminalSize.columns}x{terminalSize.rows}</Text>
1536
+ <Text>Please resize to continue...</Text>
1537
+ </Box>
1538
+ );
1539
+ }
1540
+
1541
+ // Root Render
1542
+ return (
1543
+ <Box flexDirection="column" width={VIEW_W}>
1544
+ {/* Top Welcome / Logo Area + Toolbar */}
1545
+ <TopBar
1546
+ ready={configReady}
1547
+ page={page}
1548
+ width={VIEW_W}
1549
+ height={TOP_H}
1550
+ toolbar={
1551
+ <TopToolbar
1552
+ ready={configReady}
1553
+ page={page}
1554
+ animEnabled={animEnabled}
1555
+ tipsEnabled={tipsEnabled}
1556
+ ip={apiUrl ? apiUrl.replace(/^https?:\/\//, '') : undefined}
1557
+ />
1558
+ }
1559
+ />
1560
+
1561
+ {/* Center Center Area */}
1562
+ {page === null ? (
1563
+ <Panel width={VIEW_W} height={MID_H} noBorder paddingX={0} paddingY={0} marginY={0}>
1564
+ {renderCenter()}
1565
+ </Panel>
1566
+ ) : (
1567
+ <Panel width={VIEW_W} height={MID_H} borderStyle="single" paddingX={1} paddingY={0} marginY={1}>
1568
+ {renderCenter()}
1569
+ </Panel>
1570
+ )}
1571
+
1572
+ {/* Bottom Menu & Secondary Menu */}
1573
+ <BottomBar
1574
+ width={VIEW_W}
1575
+ height={BOTTOM_H}
1576
+ menuItems={menuItems}
1577
+ page={page}
1578
+ navIndex={navIndex}
1579
+ tips={i18nMap[lang].tipsSimple}
1580
+ secondaryMenu={
1581
+ page === 'history' && (historyMenuStage === 'window' || historyMenuStage === 'deleteConfirm')
1582
+ ? null
1583
+ : secondaryMenu
1584
+ }
1585
+ />
1586
+ </Box>
1587
+ );
1588
+ };
1589
+
1584
1590
  export default CliMenuManager;