byuckchon-frontend-cli 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -284,6 +284,8 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
284
284
  | ------------------- | ------------------------------------------ |
285
285
  | `/help` | 도움말 |
286
286
  | `/clear` | 대화 컨텍스트 초기화 |
287
+ | `/history` | 이전 대화 선택 후 해당 컨텍스트 이어가기 |
288
+ | `/retry` | 마지막 사용자 요청 다시 실행 |
287
289
  | `/model [id]` | 세션 모델 변경 (인자 없으면 목록) |
288
290
  | `/cost` | 누적 토큰/비용 |
289
291
  | `/image <path>` | 다음 메시지에 이미지 첨부 (Vision 모델 권장) |
@@ -294,6 +296,10 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
294
296
  | `/rag on\|off` | RAG 컨텍스트 주입 즉석 토글 |
295
297
  | `/exit` | 종료 (`Ctrl+C` 도 가능) |
296
298
 
299
+ Ink 모드에서 `/history`를 실행하면 `↑↓`로 세션을 선택하고 `Enter`로 불러올 수 있습니다.
300
+ 선택한 세션에서 `d`를 누른 뒤 `y`로 확인하면 해당 기록을 삭제합니다. 메시지를 한 번도
301
+ 보내지 않고 종료한 빈 세션은 저장되거나 목록에 표시되지 않습니다.
302
+
297
303
  이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
298
304
  Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
299
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
5
5
  "type": "module",
6
6
  "engines": {
@@ -17,10 +17,12 @@ import { printInlineThumbnail } from '../ai/imagePreview.js';
17
17
  import {
18
18
  createSession,
19
19
  saveSession,
20
+ deleteSession,
20
21
  listSessions,
21
22
  loadSession,
22
23
  loadLatestSession,
23
24
  } from '../history/store.js';
25
+ import { findLastRetryableUser, formatSessionList } from '../history/management.js';
24
26
  import { getCachedOpenApi } from '../openapi/cache.js';
25
27
  import { summarizeOpenApi } from '../openapi/summary.js';
26
28
 
@@ -162,8 +164,6 @@ export async function chatCommand(opts = {}) {
162
164
  // 모델은 사용자가 명시했거나 글로벌 설정으로 갱신 가능 — 세션의 model 은 표시용.
163
165
  session.model = resolved.meta.id;
164
166
  }
165
- await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
166
-
167
167
  // ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
168
168
  // - --plain 플래그가 명시되거나 비-TTY 면 readline 폴백.
169
169
  // - 글로벌 ui.mode 가 "plain" 이면 한글 IME 가 깨지는 케이스를 자동 회피.
@@ -206,9 +206,36 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventi
206
206
 
207
207
  const initialConfig = { ...cfg, system, openapiInfo, conventionFiles };
208
208
 
209
+ let activeSession = session;
210
+
209
211
  const onSessionUpdate = async (messages) => {
210
- session.messages = messages;
211
- await saveSession(session);
212
+ const target = activeSession;
213
+ const hasConversation = messages.some(
214
+ (message) =>
215
+ (message.role === 'user' || message.role === 'assistant') &&
216
+ typeof message.text === 'string' &&
217
+ message.text.trim(),
218
+ );
219
+ if (!hasConversation && !target._persisted) return;
220
+ target.messages = messages;
221
+ await saveSession(target);
222
+ };
223
+
224
+ const onSessionSwitch = async (id) => {
225
+ const loaded = await loadSession(id);
226
+ loaded.model = resolved.meta.id;
227
+ activeSession = loaded;
228
+ return loaded;
229
+ };
230
+
231
+ const onSessionDelete = async (id) => {
232
+ const deletingActive = activeSession.id === id;
233
+ const result = await deleteSession(id);
234
+ if (!deletingActive) return { ...result, replacementSession: null };
235
+
236
+ const replacementSession = await createSession({ model: resolved.meta.id });
237
+ activeSession = replacementSession;
238
+ return { ...result, replacementSession };
212
239
  };
213
240
 
214
241
  const { waitUntilExit } = render(
@@ -217,6 +244,8 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventi
217
244
  initialResolved: resolved,
218
245
  session,
219
246
  onSessionUpdate,
247
+ onSessionSwitch,
248
+ onSessionDelete,
220
249
  }),
221
250
  { exitOnCtrlC: false },
222
251
  );
@@ -291,7 +320,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
291
320
  console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
292
321
  }
293
322
  console.log(
294
- chalk.dim(' /image <경로> · /paste(클립보드 이미지) · /clear-attach · /exit\n'),
323
+ chalk.dim(
324
+ ' /history · /retry · /image <경로> · /paste · /clear-attach · /exit\n',
325
+ ),
295
326
  );
296
327
 
297
328
  // 다음 메시지에 함께 보낼 이미지 첨부 목록.
@@ -341,6 +372,21 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
341
372
  const history = (session?.messages ?? [])
342
373
  .filter((m) => m.role === 'user' || m.role === 'assistant')
343
374
  .map((m) => ({ role: m.role, content: m.text ?? '' }));
375
+ const persistHistory = async () => {
376
+ if (!session) return;
377
+ session.messages = history.map((message) => {
378
+ // 이미지 바이트는 세션 파일에 저장하지 않고 사용자 텍스트만 보존한다.
379
+ if (Array.isArray(message.content)) {
380
+ const textPart = message.content.find((part) => part.type === 'text');
381
+ return {
382
+ role: message.role,
383
+ text: textPart?.text ?? '[이미지 첨부]',
384
+ };
385
+ }
386
+ return { role: message.role, text: message.content };
387
+ });
388
+ await saveSession(session);
389
+ };
344
390
  const ask = () => rl.prompt();
345
391
 
346
392
  rl.on('close', () => {
@@ -359,6 +405,29 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
359
405
  rl.close();
360
406
  return;
361
407
  }
408
+ if (line === '/history') {
409
+ try {
410
+ const sessions = await listSessions();
411
+ console.log(chalk.dim('\n' + formatSessionList(sessions)));
412
+ console.log(chalk.dim('\n 이어가기: bc chat --resume <id>'));
413
+ } catch (err) {
414
+ console.log(chalk.red(' 대화 목록을 불러올 수 없습니다: ' + err.message));
415
+ }
416
+ ask();
417
+ continue;
418
+ }
419
+ let retryContent = null;
420
+ if (line === '/retry') {
421
+ const retry = findLastRetryableUser(history);
422
+ if (!retry) {
423
+ console.log(chalk.red(' 다시 실행할 사용자 요청이 없습니다.'));
424
+ ask();
425
+ continue;
426
+ }
427
+ retryContent = retry.message.content;
428
+ history.splice(retry.index);
429
+ console.log(chalk.dim(' 마지막 요청을 다시 실행합니다.'));
430
+ }
362
431
  // 이미지 첨부 관련 슬래시 명령 — plain 모드에서도 지원.
363
432
  if (line.startsWith('/image')) {
364
433
  await addImage(line.slice('/image'.length).trim());
@@ -388,7 +457,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
388
457
  }
389
458
 
390
459
  // 일반 메시지 — 첨부가 있으면 멀티모달 content 로 구성.
391
- if (pendingAttachments.length) {
460
+ if (retryContent != null) {
461
+ history.push({ role: 'user', content: retryContent });
462
+ } else if (pendingAttachments.length) {
392
463
  const parts = [{ type: 'text', text: line }];
393
464
  try {
394
465
  for (const att of pendingAttachments) {
@@ -403,6 +474,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
403
474
  } else {
404
475
  history.push({ role: 'user', content: line });
405
476
  }
477
+ try {
478
+ await persistHistory();
479
+ } catch {
480
+ /* 저장 실패가 AI 요청을 막지는 않게 한다. */
481
+ }
406
482
  rl.pause();
407
483
 
408
484
  const projectRoot = cfg.paths.projectFile
@@ -457,21 +533,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
457
533
  }
458
534
  if (acc) history.push({ role: 'assistant', content: acc });
459
535
 
460
- // 세션 자동 저장 readline 모드에서도 끊김 대비.
461
- if (session) {
462
- session.messages = history.map((h) => {
463
- // 멀티모달(content 가 배열) 인 경우 text 파트만 추출해 저장 (이미지 바이트는 세션에 안 남김).
464
- if (Array.isArray(h.content)) {
465
- const textPart = h.content.find((p) => p.type === 'text');
466
- return { role: h.role, text: textPart?.text ?? '[이미지 첨부]' };
467
- }
468
- return { role: h.role, text: h.content };
469
- });
470
- try {
471
- await saveSession(session);
472
- } catch {
473
- /* noop */
474
- }
536
+ // 응답까지 포함한 최신 상태로 다시 저장한다.
537
+ try {
538
+ await persistHistory();
539
+ } catch {
540
+ /* noop */
475
541
  }
476
542
 
477
543
  rl.resume();
@@ -0,0 +1,30 @@
1
+ function messageText(message) {
2
+ if (typeof message?.text === 'string') return message.text;
3
+ if (typeof message?.content === 'string') return message.content;
4
+ if (Array.isArray(message?.content)) {
5
+ return message.content.find((part) => part.type === 'text')?.text ?? '[이미지 첨부]';
6
+ }
7
+ return '';
8
+ }
9
+
10
+ export function findLastRetryableUser(messages) {
11
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
12
+ const message = messages[index];
13
+ if (message.role === 'user' && messageText(message).trim()) {
14
+ return { index, message, text: messageText(message) };
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+
20
+ export function formatSessionList(sessions, { limit = 10 } = {}) {
21
+ if (!sessions.length) return '저장된 이전 세션이 없습니다.';
22
+ return sessions
23
+ .slice(0, limit)
24
+ .map((session) => {
25
+ const when = session.updatedAt?.replace('T', ' ').slice(0, 19) ?? '';
26
+ const preview = session.preview || '(빈 세션)';
27
+ return `${session.id} ${when} ${session.turns} turns\n ${preview}`;
28
+ })
29
+ .join('\n');
30
+ }
@@ -49,7 +49,7 @@ export async function createSession({ model, cwd = process.cwd() } = {}) {
49
49
  cwd,
50
50
  messages: [],
51
51
  };
52
- return { ...session, _file: path.join(dir, `${id}.json`) };
52
+ return { ...session, _file: path.join(dir, `${id}.json`), _persisted: false };
53
53
  }
54
54
 
55
55
  export async function saveSession(session) {
@@ -64,6 +64,7 @@ export async function saveSession(session) {
64
64
  messages: session.messages,
65
65
  };
66
66
  await fs.writeFile(file, JSON.stringify(data, null, 2) + '\n', 'utf8');
67
+ session._persisted = true;
67
68
  }
68
69
 
69
70
  export async function listSessions(cwd = process.cwd(), { limit = 20 } = {}) {
@@ -77,19 +78,27 @@ export async function listSessions(cwd = process.cwd(), { limit = 20 } = {}) {
77
78
  }
78
79
  const files = entries.filter((f) => f.endsWith('.json')).sort().reverse();
79
80
  const out = [];
80
- for (const name of files.slice(0, limit)) {
81
+ for (const name of files) {
82
+ if (out.length >= limit) break;
81
83
  const file = path.join(dir, name);
82
84
  try {
83
85
  const raw = await fs.readFile(file, 'utf8');
84
86
  const data = JSON.parse(raw);
85
- const firstUser = data.messages?.find((m) => m.role === 'user');
87
+ const conversation = (data.messages ?? []).filter(
88
+ (message) =>
89
+ (message.role === 'user' || message.role === 'assistant') &&
90
+ typeof message.text === 'string' &&
91
+ message.text.trim(),
92
+ );
93
+ if (conversation.length === 0) continue;
94
+ const firstUser = conversation.find((message) => message.role === 'user');
86
95
  out.push({
87
96
  id: data.id,
88
97
  file,
89
98
  startedAt: data.startedAt,
90
99
  updatedAt: data.updatedAt,
91
100
  model: data.model,
92
- turns: data.messages?.length ?? 0,
101
+ turns: conversation.length,
93
102
  preview: firstUser?.text?.slice(0, 60) ?? '(빈 세션)',
94
103
  });
95
104
  } catch {
@@ -107,7 +116,7 @@ export async function loadSession(idOrFile, cwd = process.cwd()) {
107
116
  }
108
117
  const raw = await fs.readFile(file, 'utf8');
109
118
  const data = JSON.parse(raw);
110
- return { ...data, _file: file };
119
+ return { ...data, _file: file, _persisted: true };
111
120
  }
112
121
 
113
122
  export async function loadLatestSession(cwd = process.cwd()) {
@@ -115,3 +124,22 @@ export async function loadLatestSession(cwd = process.cwd()) {
115
124
  if (list.length === 0) return null;
116
125
  return loadSession(list[0].id, cwd);
117
126
  }
127
+
128
+ export async function deleteSession(id, cwd = process.cwd()) {
129
+ const normalized = String(id ?? '').replace(/\.json$/, '');
130
+ if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
131
+ const error = new Error(`잘못된 세션 ID: ${id}`);
132
+ error.code = 'BC_INVALID_SESSION_ID';
133
+ throw error;
134
+ }
135
+
136
+ const dir = await getHistoryDir(cwd);
137
+ const file = path.join(dir, `${normalized}.json`);
138
+ try {
139
+ await fs.unlink(file);
140
+ return { id: normalized, file, deleted: true };
141
+ } catch (error) {
142
+ if (error.code === 'ENOENT') return { id: normalized, file, deleted: false };
143
+ throw error;
144
+ }
145
+ }
package/src/ui/ChatApp.js CHANGED
@@ -14,6 +14,8 @@ import { toSdkMessages, isImagePath } from '../ai/messageContent.js';
14
14
  import { buildTools } from '../ai/tools.js';
15
15
  import { searchIndex } from '../indexer/search.js';
16
16
  import { loadIndex, buildIndex } from '../indexer/store.js';
17
+ import { listSessions } from '../history/store.js';
18
+ import { findLastRetryableUser } from '../history/management.js';
17
19
 
18
20
  const h = React.createElement;
19
21
 
@@ -287,6 +289,8 @@ function AttachBar({ pending }) {
287
289
  const SLASH_COMMANDS = [
288
290
  { cmd: '/help', hint: '', desc: '명령 도움말' },
289
291
  { cmd: '/clear', hint: '', desc: '대화 컨텍스트 비우기' },
292
+ { cmd: '/history', hint: '', desc: '프로젝트의 이전 대화 목록' },
293
+ { cmd: '/retry', hint: '', desc: '마지막 사용자 요청 다시 실행' },
290
294
  { cmd: '/model', hint: '<id>', desc: '세션 모델 변경 (인자 없으면 목록)' },
291
295
  { cmd: '/cost', hint: '', desc: '누적 토큰/비용' },
292
296
  { cmd: '/image', hint: '<path>', desc: '이미지 첨부 (Finder 에서 끌어다 놔도 됨)' },
@@ -350,7 +354,45 @@ function SlashMenu({ items, activeIndex }) {
350
354
  );
351
355
  }
352
356
 
353
- export function ChatApp({ initialConfig, initialResolved, session, onSessionUpdate }) {
357
+ function HistoryMenu({ items, activeIndex, activeSessionId, deleteId }) {
358
+ if (items.length === 0) return null;
359
+ return h(
360
+ Box,
361
+ {
362
+ flexDirection: 'column',
363
+ borderStyle: 'single',
364
+ borderColor: 'gray',
365
+ paddingX: 1,
366
+ },
367
+ h(Text, { bold: true }, '이전 대화'),
368
+ ...items.map((item, index) => {
369
+ const active = index === activeIndex;
370
+ const current = item.id === activeSessionId;
371
+ const when = item.updatedAt?.replace('T', ' ').slice(0, 16) ?? '';
372
+ return h(
373
+ Text,
374
+ {
375
+ key: item.id,
376
+ color: item.id === deleteId ? 'red' : active ? 'cyan' : undefined,
377
+ bold: active,
378
+ },
379
+ `${active ? '›' : ' '} ${item.id}${current ? ' (현재)' : ''} ${when} ${item.turns} turns\n ${item.preview}`,
380
+ );
381
+ }),
382
+ deleteId
383
+ ? h(Text, { color: 'red' }, `${deleteId} 세션을 삭제할까요? y/n`)
384
+ : h(Text, { dimColor: true }, '↑↓ 선택 · Enter 불러오기 · d 삭제 · Esc 취소'),
385
+ );
386
+ }
387
+
388
+ export function ChatApp({
389
+ initialConfig,
390
+ initialResolved,
391
+ session,
392
+ onSessionUpdate,
393
+ onSessionSwitch,
394
+ onSessionDelete,
395
+ }) {
354
396
  const app = useApp();
355
397
  const { stdout } = useStdout();
356
398
  const [cfg, setCfg] = useState(initialConfig);
@@ -380,12 +422,17 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
380
422
  const [indexProgress, setIndexProgress] = useState('');
381
423
  const [ragEnabled, setRagEnabled] = useState(true);
382
424
  const [menuIndex, setMenuIndex] = useState(0);
425
+ const [historyItems, setHistoryItems] = useState([]);
426
+ const [historyIndex, setHistoryIndex] = useState(0);
427
+ const [historyDeleteId, setHistoryDeleteId] = useState(null);
428
+ const [activeSessionId, setActiveSessionId] = useState(session?.id ?? null);
383
429
  const [, force] = useState(0);
384
430
  const rerender = useCallback(() => force((n) => n + 1), []);
385
431
 
386
432
  // 슬래시 메뉴: input 상태에 따라 동적으로 계산.
387
433
  const slashItems = state === 'idle' ? filterSlashCommands(input) : [];
388
434
  const slashOpen = slashItems.length > 0;
435
+ const historyOpen = state === 'idle' && historyItems.length > 0;
389
436
  // input 이 바뀌면 선택 인덱스를 0 으로 리셋 (필터 변경 시 자연스럽게).
390
437
  useEffect(() => {
391
438
  setMenuIndex(0);
@@ -507,6 +554,29 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
507
554
  app.exit();
508
555
  return;
509
556
  }
557
+ if (historyOpen) {
558
+ if (historyDeleteId) {
559
+ if (char.toLowerCase() === 'y') {
560
+ void deleteHistorySession();
561
+ } else if (char.toLowerCase() === 'n' || key.escape) {
562
+ setHistoryDeleteId(null);
563
+ }
564
+ return;
565
+ }
566
+ if (key.upArrow) {
567
+ setHistoryIndex((index) => Math.max(0, index - 1));
568
+ } else if (key.downArrow) {
569
+ setHistoryIndex((index) => Math.min(historyItems.length - 1, index + 1));
570
+ } else if (key.escape) {
571
+ setHistoryItems([]);
572
+ } else if (char.toLowerCase() === 'd') {
573
+ const selected = historyItems[historyIndex];
574
+ if (selected) setHistoryDeleteId(selected.id);
575
+ } else if (key.return) {
576
+ void selectHistorySession();
577
+ }
578
+ return;
579
+ }
510
580
  if (!slashOpen) return;
511
581
  if (key.upArrow) {
512
582
  setMenuIndex((i) => Math.max(0, i - 1));
@@ -552,6 +622,33 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
552
622
  pushSystemInfo('대화 컨텍스트를 비웠습니다.');
553
623
  return true;
554
624
  }
625
+ if (cmd === 'history') {
626
+ try {
627
+ const sessions = await listSessions();
628
+ if (!sessions.length) {
629
+ pushSystemInfo('저장된 이전 세션이 없습니다.');
630
+ return true;
631
+ }
632
+ setHistoryItems(sessions.slice(0, 10));
633
+ setHistoryIndex(0);
634
+ setHistoryDeleteId(null);
635
+ } catch (err) {
636
+ pushSystemError('대화 목록을 불러올 수 없습니다: ' + err.message);
637
+ }
638
+ return true;
639
+ }
640
+ if (cmd === 'retry') {
641
+ const retry = findLastRetryableUser(messages);
642
+ if (!retry) {
643
+ pushSystemError('다시 실행할 사용자 요청이 없습니다.');
644
+ return true;
645
+ }
646
+ await sendMessage(retry.text, {
647
+ baseMessages: messages.slice(0, retry.index),
648
+ attachments: retry.message.attachments ?? [],
649
+ });
650
+ return true;
651
+ }
555
652
  if (cmd === 'cost') {
556
653
  pushSystemInfo(meterRef.current.format());
557
654
  return true;
@@ -656,13 +753,70 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
656
753
  return true;
657
754
  };
658
755
 
659
- const sendMessage = async (text) => {
756
+ const selectHistorySession = async () => {
757
+ const selected = historyItems[historyIndex];
758
+ if (!selected || !onSessionSwitch) return;
759
+
760
+ setHistoryItems([]);
761
+ setState('thinking');
762
+ try {
763
+ const loaded = await onSessionSwitch(selected.id);
764
+ setActiveSessionId(loaded.id);
765
+ setPendingAttachments([]);
766
+ setMessages([
767
+ ...(loaded.messages ?? []),
768
+ {
769
+ role: 'system-info',
770
+ text: `세션 ${loaded.id} 컨텍스트를 불러왔습니다.`,
771
+ },
772
+ ]);
773
+ } catch (err) {
774
+ pushSystemError('세션을 불러올 수 없습니다: ' + (err?.message ?? String(err)));
775
+ } finally {
776
+ setState('idle');
777
+ }
778
+ };
779
+
780
+ const deleteHistorySession = async () => {
781
+ const id = historyDeleteId;
782
+ if (!id || !onSessionDelete) return;
783
+
784
+ setHistoryDeleteId(null);
785
+ try {
786
+ const result = await onSessionDelete(id);
787
+ const remaining = historyItems.filter((item) => item.id !== id);
788
+ setHistoryItems(remaining);
789
+ setHistoryIndex((index) => Math.min(index, Math.max(0, remaining.length - 1)));
790
+
791
+ if (result.replacementSession) {
792
+ setActiveSessionId(result.replacementSession.id);
793
+ setPendingAttachments([]);
794
+ setMessages([
795
+ {
796
+ role: 'system-info',
797
+ text: `세션 ${id}을 삭제하고 새 세션을 시작했습니다.`,
798
+ },
799
+ ]);
800
+ } else {
801
+ pushSystemInfo(
802
+ result.deleted ? `세션 ${id}을 삭제했습니다.` : `세션 ${id}을 찾을 수 없습니다.`,
803
+ );
804
+ }
805
+ } catch (err) {
806
+ pushSystemError('세션을 삭제할 수 없습니다: ' + (err?.message ?? String(err)));
807
+ }
808
+ };
809
+
810
+ const sendMessage = async (
811
+ text,
812
+ { baseMessages = messages, attachments = pendingAttachments } = {},
813
+ ) => {
660
814
  const userMsg = {
661
815
  role: 'user',
662
816
  text,
663
- attachments: pendingAttachments,
817
+ attachments,
664
818
  };
665
- const newMessages = [...messages, userMsg];
819
+ const newMessages = [...baseMessages, userMsg];
666
820
  setMessages(newMessages);
667
821
  setPendingAttachments([]);
668
822
  setState('thinking');
@@ -896,12 +1050,20 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
896
1050
  }),
897
1051
  ),
898
1052
  h(AttachBar, { pending: pendingAttachments }),
1053
+ historyOpen
1054
+ ? h(HistoryMenu, {
1055
+ items: historyItems,
1056
+ activeIndex: historyIndex,
1057
+ activeSessionId,
1058
+ deleteId: historyDeleteId,
1059
+ })
1060
+ : null,
899
1061
  slashOpen ? h(SlashMenu, { items: slashItems, activeIndex: menuIndex }) : null,
900
1062
  h(
901
1063
  Box,
902
1064
  { marginTop: 0 },
903
1065
  h(Text, { color: 'magenta', bold: true }, 'you › '),
904
- state === 'idle'
1066
+ state === 'idle' && !historyOpen
905
1067
  ? h(TextInput, {
906
1068
  value: input,
907
1069
  onChange: setInput,
@@ -912,7 +1074,15 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
912
1074
  // 가짜 커서가 있으면 한글 IME 미리보기가 한 칸 어긋나 보일 수 있다.
913
1075
  showCursor: false,
914
1076
  })
915
- : h(Text, { dimColor: true }, '(응답 받는 중 — 잠시만)'),
1077
+ : h(
1078
+ Text,
1079
+ { dimColor: true },
1080
+ historyOpen
1081
+ ? historyDeleteId
1082
+ ? '(y 또는 n을 입력하세요)'
1083
+ : '(이전 대화를 선택하세요)'
1084
+ : '(응답 받는 중 — 잠시만)',
1085
+ ),
916
1086
  ),
917
1087
  );
918
1088
  }