byuckchon-frontend-cli 1.7.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.
@@ -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
 
@@ -115,7 +117,7 @@ async function pasteClipboardImage() {
115
117
  * 슬래시 명령은 Input 컴포넌트의 onSubmit 에서 가로채서 처리.
116
118
  */
117
119
 
118
- function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo }) {
120
+ function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo, conventionFiles }) {
119
121
  let ragLabel;
120
122
  if (!hasIndex) ragLabel = '(준비 중 — 자동 빌드 또는 /index)';
121
123
  else if (ragOn) ragLabel = 'on (관련 코드 자동 주입)';
@@ -167,6 +169,14 @@ function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo
167
169
  h(Text, null, openapiLabel),
168
170
  )
169
171
  : null,
172
+ conventionFiles?.length
173
+ ? h(
174
+ Box,
175
+ null,
176
+ h(Text, { dimColor: true }, 'docs '),
177
+ h(Text, null, conventionFiles.join(', ')),
178
+ )
179
+ : null,
170
180
  gateway
171
181
  ? h(
172
182
  Box,
@@ -279,6 +289,8 @@ function AttachBar({ pending }) {
279
289
  const SLASH_COMMANDS = [
280
290
  { cmd: '/help', hint: '', desc: '명령 도움말' },
281
291
  { cmd: '/clear', hint: '', desc: '대화 컨텍스트 비우기' },
292
+ { cmd: '/history', hint: '', desc: '프로젝트의 이전 대화 목록' },
293
+ { cmd: '/retry', hint: '', desc: '마지막 사용자 요청 다시 실행' },
282
294
  { cmd: '/model', hint: '<id>', desc: '세션 모델 변경 (인자 없으면 목록)' },
283
295
  { cmd: '/cost', hint: '', desc: '누적 토큰/비용' },
284
296
  { cmd: '/image', hint: '<path>', desc: '이미지 첨부 (Finder 에서 끌어다 놔도 됨)' },
@@ -342,7 +354,45 @@ function SlashMenu({ items, activeIndex }) {
342
354
  );
343
355
  }
344
356
 
345
- 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
+ }) {
346
396
  const app = useApp();
347
397
  const { stdout } = useStdout();
348
398
  const [cfg, setCfg] = useState(initialConfig);
@@ -372,12 +422,17 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
372
422
  const [indexProgress, setIndexProgress] = useState('');
373
423
  const [ragEnabled, setRagEnabled] = useState(true);
374
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);
375
429
  const [, force] = useState(0);
376
430
  const rerender = useCallback(() => force((n) => n + 1), []);
377
431
 
378
432
  // 슬래시 메뉴: input 상태에 따라 동적으로 계산.
379
433
  const slashItems = state === 'idle' ? filterSlashCommands(input) : [];
380
434
  const slashOpen = slashItems.length > 0;
435
+ const historyOpen = state === 'idle' && historyItems.length > 0;
381
436
  // input 이 바뀌면 선택 인덱스를 0 으로 리셋 (필터 변경 시 자연스럽게).
382
437
  useEffect(() => {
383
438
  setMenuIndex(0);
@@ -499,6 +554,29 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
499
554
  app.exit();
500
555
  return;
501
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
+ }
502
580
  if (!slashOpen) return;
503
581
  if (key.upArrow) {
504
582
  setMenuIndex((i) => Math.max(0, i - 1));
@@ -544,6 +622,33 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
544
622
  pushSystemInfo('대화 컨텍스트를 비웠습니다.');
545
623
  return true;
546
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
+ }
547
652
  if (cmd === 'cost') {
548
653
  pushSystemInfo(meterRef.current.format());
549
654
  return true;
@@ -648,13 +753,70 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
648
753
  return true;
649
754
  };
650
755
 
651
- 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
+ ) => {
652
814
  const userMsg = {
653
815
  role: 'user',
654
816
  text,
655
- attachments: pendingAttachments,
817
+ attachments,
656
818
  };
657
- const newMessages = [...messages, userMsg];
819
+ const newMessages = [...baseMessages, userMsg];
658
820
  setMessages(newMessages);
659
821
  setPendingAttachments([]);
660
822
  setState('thinking');
@@ -870,6 +1032,7 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
870
1032
  ragOn: ragEnabled,
871
1033
  hasIndex,
872
1034
  openapiInfo: cfg.openapiInfo,
1035
+ conventionFiles: cfg.conventionFiles,
873
1036
  }),
874
1037
  h(
875
1038
  Box,
@@ -887,12 +1050,20 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
887
1050
  }),
888
1051
  ),
889
1052
  h(AttachBar, { pending: pendingAttachments }),
1053
+ historyOpen
1054
+ ? h(HistoryMenu, {
1055
+ items: historyItems,
1056
+ activeIndex: historyIndex,
1057
+ activeSessionId,
1058
+ deleteId: historyDeleteId,
1059
+ })
1060
+ : null,
890
1061
  slashOpen ? h(SlashMenu, { items: slashItems, activeIndex: menuIndex }) : null,
891
1062
  h(
892
1063
  Box,
893
1064
  { marginTop: 0 },
894
1065
  h(Text, { color: 'magenta', bold: true }, 'you › '),
895
- state === 'idle'
1066
+ state === 'idle' && !historyOpen
896
1067
  ? h(TextInput, {
897
1068
  value: input,
898
1069
  onChange: setInput,
@@ -903,7 +1074,15 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
903
1074
  // 가짜 커서가 있으면 한글 IME 미리보기가 한 칸 어긋나 보일 수 있다.
904
1075
  showCursor: false,
905
1076
  })
906
- : h(Text, { dimColor: true }, '(응답 받는 중 — 잠시만)'),
1077
+ : h(
1078
+ Text,
1079
+ { dimColor: true },
1080
+ historyOpen
1081
+ ? historyDeleteId
1082
+ ? '(y 또는 n을 입력하세요)'
1083
+ : '(이전 대화를 선택하세요)'
1084
+ : '(응답 받는 중 — 잠시만)',
1085
+ ),
907
1086
  ),
908
1087
  );
909
1088
  }