byuckchon-frontend-cli 1.1.0 → 1.4.0

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.
@@ -0,0 +1,812 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+
4
+ import React, { useEffect, useState, useRef, useCallback } from 'react';
5
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
6
+ import TextInput from 'ink-text-input';
7
+ import Spinner from 'ink-spinner';
8
+ import { streamText } from 'ai';
9
+
10
+ import { resolveModel } from '../ai/provider.js';
11
+ import { TokenMeter } from '../ai/tokenMeter.js';
12
+ import { findModel, MODEL_CATALOG } from '../ai/models.js';
13
+ import { toSdkMessages, isImagePath } from '../ai/messageContent.js';
14
+ import { searchIndex } from '../indexer/search.js';
15
+ import { loadIndex, buildIndex } from '../indexer/store.js';
16
+
17
+ const h = React.createElement;
18
+
19
+ /**
20
+ * macOS 클립보드의 이미지(예: 스크린샷)를 임시 파일로 떨궈 절대경로를 돌려준다.
21
+ *
22
+ * - 외부 도구 `pngpaste` 가 필요 (Homebrew: `brew install pngpaste`).
23
+ * - macOS 가 아니거나 도구가 없으면 도움이 되는 에러 메시지로 throw.
24
+ */
25
+ async function pasteClipboardImage() {
26
+ if (process.platform !== 'darwin') {
27
+ throw new Error(
28
+ '/paste 는 현재 macOS 만 지원합니다. 다른 OS 에서는 이미지를 파일로 저장하고 /image <경로> 를 쓰세요.',
29
+ );
30
+ }
31
+ const { spawn } = await import('node:child_process');
32
+ const fs = await import('node:fs/promises');
33
+ const os = await import('node:os');
34
+ const pathMod = await import('node:path');
35
+
36
+ // pngpaste 가 설치되어 있는지 빠르게 확인.
37
+ const checkOk = await new Promise((resolve) => {
38
+ const p = spawn('which', ['pngpaste']);
39
+ p.on('close', (code) => resolve(code === 0));
40
+ p.on('error', () => resolve(false));
41
+ });
42
+ if (!checkOk) {
43
+ throw new Error(
44
+ 'pngpaste 가 필요합니다. 설치: `brew install pngpaste` (Homebrew 가 없으면 https://brew.sh)',
45
+ );
46
+ }
47
+
48
+ const tmp = pathMod.join(
49
+ os.tmpdir(),
50
+ `bc-paste-${Date.now()}.png`,
51
+ );
52
+ await new Promise((resolve, reject) => {
53
+ const p = spawn('pngpaste', [tmp]);
54
+ let stderr = '';
55
+ p.stderr.on('data', (d) => (stderr += d.toString()));
56
+ p.on('close', (code) => {
57
+ if (code === 0) resolve();
58
+ else
59
+ reject(
60
+ new Error(
61
+ '클립보드에 이미지가 없거나 읽을 수 없습니다.' +
62
+ (stderr ? ' (' + stderr.trim() + ')' : ''),
63
+ ),
64
+ );
65
+ });
66
+ p.on('error', (e) => reject(e));
67
+ });
68
+
69
+ // 파일이 실제로 만들어졌는지 한 번 더 확인.
70
+ const stat = await fs.stat(tmp);
71
+ if (!stat.isFile() || stat.size === 0) {
72
+ throw new Error('클립보드 이미지 저장 실패 (파일이 비어있음).');
73
+ }
74
+ return tmp;
75
+ }
76
+
77
+ /**
78
+ * ink 기반 bc chat UI.
79
+ *
80
+ * 화면 구성:
81
+ * ┌ Header : 로고 + 모델 + 게이트웨이/프로젝트 정보
82
+ * │ Messages: 사용자/어시스턴트 말풍선 (스크롤은 터미널 자체에 맡김)
83
+ * │ Status : 입력중/스트리밍중/에러 한 줄
84
+ * │ Attach : 다음 user 메시지에 같이 보낼 첨부 목록
85
+ * └ Input : prompt + TextInput
86
+ *
87
+ * 슬래시 명령은 Input 컴포넌트의 onSubmit 에서 가로채서 처리.
88
+ */
89
+
90
+ function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo }) {
91
+ let ragLabel;
92
+ if (!hasIndex) ragLabel = '(준비 중 — 자동 빌드 또는 /index)';
93
+ else if (ragOn) ragLabel = 'on (관련 코드 자동 주입)';
94
+ else ragLabel = 'off (/rag on 으로 켜기)';
95
+
96
+ let openapiLabel;
97
+ if (openapiInfo) {
98
+ openapiLabel = openapiInfo.source + (openapiInfo.cached ? ' (cached)' : ' (live)');
99
+ } else {
100
+ openapiLabel = null;
101
+ }
102
+
103
+ return h(
104
+ Box,
105
+ { flexDirection: 'column', borderStyle: 'round', borderColor: 'cyan', paddingX: 1 },
106
+ h(
107
+ Box,
108
+ null,
109
+ h(Text, { bold: true, color: 'cyan' }, 'bc chat '),
110
+ h(Text, { dimColor: true }, '· Byuckchon Frontend Workbench'),
111
+ ),
112
+ h(
113
+ Box,
114
+ null,
115
+ h(Text, { dimColor: true }, 'model '),
116
+ h(Text, null, modelMeta.label),
117
+ ),
118
+ h(
119
+ Box,
120
+ null,
121
+ h(Text, { dimColor: true }, 'project '),
122
+ h(
123
+ Text,
124
+ { dimColor: !projectFile },
125
+ projectFile ?? '(글로벌만 사용 — bc.config.json 없음)',
126
+ ),
127
+ ),
128
+ h(
129
+ Box,
130
+ null,
131
+ h(Text, { dimColor: true }, 'rag '),
132
+ h(Text, { dimColor: !ragOn || !hasIndex }, ragLabel),
133
+ ),
134
+ openapiLabel
135
+ ? h(
136
+ Box,
137
+ null,
138
+ h(Text, { dimColor: true }, 'openapi '),
139
+ h(Text, null, openapiLabel),
140
+ )
141
+ : null,
142
+ gateway
143
+ ? h(
144
+ Box,
145
+ null,
146
+ h(Text, { dimColor: true }, 'gateway '),
147
+ h(Text, null, gateway),
148
+ )
149
+ : null,
150
+ );
151
+ }
152
+
153
+ function MessageBubble({ message }) {
154
+ if (message.role === 'system-info') {
155
+ return h(
156
+ Box,
157
+ { marginY: 0 },
158
+ h(Text, { dimColor: true }, '· ' + message.text),
159
+ );
160
+ }
161
+ if (message.role === 'system-error') {
162
+ return h(
163
+ Box,
164
+ { marginY: 0 },
165
+ h(Text, { color: 'red' }, '✗ ' + message.text),
166
+ );
167
+ }
168
+ const isUser = message.role === 'user';
169
+ const label = isUser ? 'you' : 'bc';
170
+ const color = isUser ? 'magenta' : 'green';
171
+
172
+ return h(
173
+ Box,
174
+ { flexDirection: 'column', marginY: 0 },
175
+ h(
176
+ Box,
177
+ null,
178
+ h(Text, { color, bold: true }, label + ' › '),
179
+ h(Text, null, message.text || (message.streaming ? '' : '')),
180
+ ),
181
+ message.attachments?.length
182
+ ? h(
183
+ Box,
184
+ { marginLeft: 6 },
185
+ h(
186
+ Text,
187
+ { dimColor: true },
188
+ '📎 ' +
189
+ message.attachments
190
+ .map((a) => `${path.basename(a.path)} (${a.sizeKb}KB)`)
191
+ .join(', '),
192
+ ),
193
+ )
194
+ : null,
195
+ );
196
+ }
197
+
198
+ function StatusLine({ state, meter, indexBusy, indexProgress }) {
199
+ const cost = meter ? meter.format() : null;
200
+ if (indexBusy) {
201
+ return h(
202
+ Box,
203
+ null,
204
+ h(Text, { color: 'magenta' }, h(Spinner, { type: 'dots' })),
205
+ h(Text, { color: 'magenta' }, ' 📚 인덱싱 중 '),
206
+ h(Text, { dimColor: true }, indexProgress || ''),
207
+ );
208
+ }
209
+ if (state === 'streaming') {
210
+ return h(
211
+ Box,
212
+ null,
213
+ h(Text, { color: 'yellow' }, h(Spinner, { type: 'dots' })),
214
+ h(Text, { color: 'yellow' }, ' 응답 받는 중… '),
215
+ cost ? h(Text, { dimColor: true }, cost) : null,
216
+ );
217
+ }
218
+ if (state === 'thinking') {
219
+ return h(
220
+ Box,
221
+ null,
222
+ h(Text, { color: 'cyan' }, h(Spinner, { type: 'dots' })),
223
+ h(Text, { color: 'cyan' }, ' 보내는 중…'),
224
+ );
225
+ }
226
+ return h(
227
+ Box,
228
+ null,
229
+ h(Text, { dimColor: true }, cost ? cost : '/help · /image <path> · /exit'),
230
+ );
231
+ }
232
+
233
+ function AttachBar({ pending }) {
234
+ if (!pending.length) return null;
235
+ return h(
236
+ Box,
237
+ { borderStyle: 'single', borderColor: 'gray', paddingX: 1, marginTop: 0 },
238
+ h(
239
+ Text,
240
+ { dimColor: true },
241
+ '다음 메시지에 첨부됨: ' +
242
+ pending.map((p) => path.basename(p.path)).join(', '),
243
+ ),
244
+ );
245
+ }
246
+
247
+ /**
248
+ * 슬래시 명령 카탈로그 — 도움말 + 자동완성 메뉴 양쪽에서 공유한다.
249
+ * `cmd` 는 슬래시까지 포함, `hint` 는 인자 형식, `desc` 는 설명.
250
+ */
251
+ const SLASH_COMMANDS = [
252
+ { cmd: '/help', hint: '', desc: '명령 도움말' },
253
+ { cmd: '/clear', hint: '', desc: '대화 컨텍스트 비우기' },
254
+ { cmd: '/model', hint: '<id>', desc: '세션 모델 변경 (인자 없으면 목록)' },
255
+ { cmd: '/cost', hint: '', desc: '누적 토큰/비용' },
256
+ { cmd: '/image', hint: '<path>', desc: '이미지 첨부 (Finder 에서 끌어다 놔도 됨)' },
257
+ { cmd: '/paste', hint: '', desc: '클립보드의 이미지(스크린샷)를 첨부 — macOS' },
258
+ { cmd: '/attachments', hint: '', desc: '현재 첨부 목록' },
259
+ { cmd: '/clear-attach', hint: '', desc: '첨부 비우기' },
260
+ { cmd: '/index', hint: '', desc: '코드베이스 인덱스 빌드/갱신 (지금 실행)' },
261
+ { cmd: '/rag', hint: 'on|off', desc: '코드베이스 RAG on/off' },
262
+ { cmd: '/exit', hint: '', desc: '종료 (Ctrl+C 도 가능)' },
263
+ ];
264
+
265
+ const HELP_TEXT = SLASH_COMMANDS
266
+ .map((c) => `${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)} ${c.desc}`)
267
+ .join('\n');
268
+
269
+ /** 사용자가 친 input 으로부터 자동완성 후보를 거른다. */
270
+ function filterSlashCommands(input) {
271
+ if (!input.startsWith('/')) return [];
272
+ // 공백이 들어왔다면 이미 인자 입력 단계 → 메뉴 닫음.
273
+ if (/\s/.test(input)) return [];
274
+ const filter = input.slice(1).toLowerCase();
275
+ return SLASH_COMMANDS.filter((c) =>
276
+ c.cmd.slice(1).toLowerCase().startsWith(filter),
277
+ );
278
+ }
279
+
280
+ /** 메뉴에서 선택된 항목을 실제 input 문자열로 펼친다. */
281
+ function expandSlash(item) {
282
+ return item.cmd + (item.hint ? ' ' : '');
283
+ }
284
+
285
+ function SlashMenu({ items, activeIndex }) {
286
+ if (items.length === 0) return null;
287
+ return h(
288
+ Box,
289
+ {
290
+ flexDirection: 'column',
291
+ borderStyle: 'single',
292
+ borderColor: 'gray',
293
+ paddingX: 1,
294
+ marginTop: 0,
295
+ },
296
+ ...items.map((it, i) =>
297
+ h(
298
+ Box,
299
+ { key: it.cmd },
300
+ h(
301
+ Text,
302
+ { color: i === activeIndex ? 'cyan' : undefined, bold: i === activeIndex },
303
+ (i === activeIndex ? '› ' : ' ') + it.cmd,
304
+ ),
305
+ it.hint ? h(Text, { dimColor: true }, ' ' + it.hint) : null,
306
+ h(Text, { dimColor: true }, ' ' + it.desc),
307
+ ),
308
+ ),
309
+ h(
310
+ Box,
311
+ { marginTop: 0 },
312
+ h(Text, { dimColor: true }, ' ↑↓ 선택 · Enter/Tab 자동완성 · Esc 취소'),
313
+ ),
314
+ );
315
+ }
316
+
317
+ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpdate }) {
318
+ const app = useApp();
319
+ const { stdout } = useStdout();
320
+ const [cfg, setCfg] = useState(initialConfig);
321
+ const [resolved, setResolved] = useState(initialResolved);
322
+ const meterRef = useRef(new TokenMeter(initialResolved.meta, initialConfig.effective.limits));
323
+
324
+ // ── 한글/일본어 IME 대응 ────────────────────────────────────────
325
+ // ink 는 시작 시 터미널 커서를 숨긴다. 그러면 macOS Hangul IME 가
326
+ // 조합 중인 글자(예: '안' 조합) 미리보기를 띄울 위치를 못 찾아서 입력이
327
+ // 한 글자씩 묵음 처리되는 것처럼 보인다. 매 렌더 후 커서를 다시 켜서
328
+ // OS 의 IME 오버레이가 정상적으로 입력 위치 위에 뜨도록 만든다.
329
+ useEffect(() => {
330
+ stdout.write('\u001B[?25h'); // CSI ? 25 h = cursor show
331
+ });
332
+ useEffect(() => {
333
+ return () => {
334
+ stdout.write('\u001B[?25h');
335
+ };
336
+ }, [stdout]);
337
+
338
+ const [input, setInput] = useState('');
339
+ const [messages, setMessages] = useState(session?.messages ?? []);
340
+ const [state, setState] = useState('idle'); // idle | thinking | streaming
341
+ const [pendingAttachments, setPendingAttachments] = useState([]);
342
+ const [hasIndex, setHasIndex] = useState(false);
343
+ const [indexBusy, setIndexBusy] = useState(false);
344
+ const [indexProgress, setIndexProgress] = useState('');
345
+ const [ragEnabled, setRagEnabled] = useState(true);
346
+ const [menuIndex, setMenuIndex] = useState(0);
347
+ const [, force] = useState(0);
348
+ const rerender = useCallback(() => force((n) => n + 1), []);
349
+
350
+ // 슬래시 메뉴: input 상태에 따라 동적으로 계산.
351
+ const slashItems = state === 'idle' ? filterSlashCommands(input) : [];
352
+ const slashOpen = slashItems.length > 0;
353
+ // input 이 바뀌면 선택 인덱스를 0 으로 리셋 (필터 변경 시 자연스럽게).
354
+ useEffect(() => {
355
+ setMenuIndex(0);
356
+ }, [input]);
357
+
358
+ // 인덱스 빌드 헬퍼 — 자동/수동 양쪽에서 공유.
359
+ const runIndexBuild = useCallback(
360
+ async ({ rebuild = false, silent = false } = {}) => {
361
+ if (indexBusy) return false;
362
+ if (!cfg.paths.projectFile) {
363
+ if (!silent) {
364
+ setMessages((m) => [
365
+ ...m,
366
+ { role: 'system-info', text: 'bc.config.json 이 없어 인덱싱 대상을 모릅니다. 먼저 `bc adopt` 또는 `bc init` 을 실행해주세요.' },
367
+ ]);
368
+ }
369
+ return false;
370
+ }
371
+ if (!cfg.effective.apiKeys?.openai && !cfg.effective.gateway) {
372
+ if (!silent) {
373
+ setMessages((m) => [
374
+ ...m,
375
+ { role: 'system-info', text: 'RAG 인덱싱에는 OpenAI 키가 필요합니다. `bc config set-key openai` 후 /index 다시 실행해주세요.' },
376
+ ]);
377
+ }
378
+ return false;
379
+ }
380
+
381
+ setIndexBusy(true);
382
+ setIndexProgress('시작...');
383
+ setMessages((m) => [
384
+ ...m,
385
+ { role: 'system-info', text: '📚 코드 인덱스 빌드 중... (RAG 활성화 준비)' },
386
+ ]);
387
+ try {
388
+ const res = await buildIndex({
389
+ effective: cfg.effective,
390
+ contextCfg: cfg.effective.context,
391
+ rebuild,
392
+ onProgress: (msg) => setIndexProgress(msg.trim()),
393
+ });
394
+ if (!res.ok) {
395
+ setMessages((m) => [
396
+ ...m,
397
+ { role: 'system-error', text: '인덱스 빌드 실패: ' + res.reason },
398
+ ]);
399
+ return false;
400
+ }
401
+ setHasIndex(true);
402
+ setMessages((m) => [
403
+ ...m,
404
+ {
405
+ role: 'system-info',
406
+ text: `✓ 인덱스 빌드 완료 — 파일 ${res.manifest.fileCount} · 청크 ${res.manifest.chunkCount}`,
407
+ },
408
+ ]);
409
+ return true;
410
+ } catch (err) {
411
+ setMessages((m) => [
412
+ ...m,
413
+ { role: 'system-error', text: '인덱스 빌드 오류: ' + (err?.message ?? String(err)) },
414
+ ]);
415
+ return false;
416
+ } finally {
417
+ setIndexBusy(false);
418
+ setIndexProgress('');
419
+ }
420
+ },
421
+ [cfg, indexBusy],
422
+ );
423
+
424
+ // 시작 시: 인덱스 존재 여부 확인 → 없고 조건 맞으면 자동으로 한 번 빌드.
425
+ useEffect(() => {
426
+ let cancelled = false;
427
+ (async () => {
428
+ const idx = await loadIndex().catch(() => null);
429
+ if (cancelled) return;
430
+ if (idx?.chunks?.length) {
431
+ setHasIndex(true);
432
+ return;
433
+ }
434
+ // 인덱스 없음 — 자동 빌드 시도
435
+ const canAuto =
436
+ !!cfg.paths.projectFile &&
437
+ (!!cfg.effective.apiKeys?.openai || !!cfg.effective.gateway);
438
+ if (!canAuto) {
439
+ if (cfg.paths.projectFile) {
440
+ setMessages((m) => [
441
+ ...m,
442
+ {
443
+ role: 'system-info',
444
+ text:
445
+ '💡 RAG 코드 컨텍스트를 쓰려면 OpenAI 키가 필요합니다.\n' +
446
+ ' `bc config set-key openai` 후 /index 또는 `bc index` 를 실행하세요.',
447
+ },
448
+ ]);
449
+ }
450
+ return;
451
+ }
452
+ await runIndexBuild({ silent: true });
453
+ })();
454
+ return () => {
455
+ cancelled = true;
456
+ };
457
+ // 의도적으로 cfg 만 의존: 세션 변경 시 재실행되지 않도록.
458
+ // eslint-disable-next-line react-hooks/exhaustive-deps
459
+ }, []);
460
+
461
+ // 메시지 변경 시 세션을 자동 저장. 디스크 IO 는 비동기로 흘려보낸다.
462
+ useEffect(() => {
463
+ if (!onSessionUpdate) return;
464
+ onSessionUpdate(messages).catch(() => {
465
+ /* 저장 실패는 화면 흐름을 막지 않는다. */
466
+ });
467
+ }, [messages, onSessionUpdate]);
468
+
469
+ useInput((char, key) => {
470
+ if (key.ctrl && char === 'c') {
471
+ app.exit();
472
+ return;
473
+ }
474
+ if (!slashOpen) return;
475
+ if (key.upArrow) {
476
+ setMenuIndex((i) => Math.max(0, i - 1));
477
+ return;
478
+ }
479
+ if (key.downArrow) {
480
+ setMenuIndex((i) => Math.min(slashItems.length - 1, i + 1));
481
+ return;
482
+ }
483
+ if (key.escape) {
484
+ setInput('');
485
+ return;
486
+ }
487
+ // Tab 자동완성 — ink-text-input 이 Tab 을 자체 처리하지 않아 충돌 없음.
488
+ if (key.tab) {
489
+ const sel = slashItems[Math.min(menuIndex, slashItems.length - 1)];
490
+ if (sel) setInput(expandSlash(sel));
491
+ return;
492
+ }
493
+ });
494
+
495
+ const pushSystemInfo = useCallback((text) => {
496
+ setMessages((m) => [...m, { role: 'system-info', text }]);
497
+ }, []);
498
+ const pushSystemError = useCallback((text) => {
499
+ setMessages((m) => [...m, { role: 'system-error', text }]);
500
+ }, []);
501
+
502
+ const handleSlash = async (line) => {
503
+ const [cmd, ...rest] = line.slice(1).split(/\s+/);
504
+ const arg = rest.join(' ').trim();
505
+
506
+ if (cmd === 'exit' || cmd === 'quit') {
507
+ app.exit();
508
+ return true;
509
+ }
510
+ if (cmd === 'help') {
511
+ pushSystemInfo(HELP_TEXT);
512
+ return true;
513
+ }
514
+ if (cmd === 'clear') {
515
+ setMessages([]);
516
+ pushSystemInfo('대화 컨텍스트를 비웠습니다.');
517
+ return true;
518
+ }
519
+ if (cmd === 'cost') {
520
+ pushSystemInfo(meterRef.current.format());
521
+ return true;
522
+ }
523
+ if (cmd === 'model') {
524
+ if (!arg) {
525
+ const list = MODEL_CATALOG.map((m) => ` ${m.id.padEnd(22)} ${m.label}`).join('\n');
526
+ pushSystemInfo('사용 가능한 모델:\n' + list);
527
+ return true;
528
+ }
529
+ const next = findModel(arg);
530
+ if (!next) {
531
+ pushSystemError('알 수 없는 모델: ' + arg);
532
+ return true;
533
+ }
534
+ try {
535
+ const nextCfg = { ...cfg, effective: { ...cfg.effective, model: next.id } };
536
+ const nextResolved = resolveModel(nextCfg.effective);
537
+ meterRef.current = new TokenMeter(nextResolved.meta, nextCfg.effective.limits);
538
+ setCfg(nextCfg);
539
+ setResolved(nextResolved);
540
+ pushSystemInfo('세션 모델을 ' + nextResolved.meta.label + ' 로 변경했습니다.');
541
+ } catch (err) {
542
+ pushSystemError(err.message);
543
+ }
544
+ return true;
545
+ }
546
+ if (cmd === 'image') {
547
+ if (!arg) {
548
+ pushSystemError('사용법: /image <파일 경로> (Finder 에서 입력창 위로 끌어다 놓으면 경로가 자동 입력됩니다.)');
549
+ return true;
550
+ }
551
+ // 드래그 앤 드롭 시 경로가 따옴표로 감싸지거나 백슬래시로 이스케이프되는 경우가 많아서 정리.
552
+ const cleanedPath = arg
553
+ .replace(/^['"]|['"]$/g, '')
554
+ .replace(/\\ /g, ' ');
555
+ try {
556
+ const fs = await import('node:fs/promises');
557
+ const abs = path.resolve(cleanedPath);
558
+ if (!isImagePath(abs)) {
559
+ pushSystemError('지원 안 하는 확장자: ' + path.extname(abs));
560
+ return true;
561
+ }
562
+ const stat = await fs.stat(abs);
563
+ const sizeKb = Math.max(1, Math.round(stat.size / 1024));
564
+ setPendingAttachments((arr) => [...arr, { kind: 'image', path: abs, sizeKb }]);
565
+ pushSystemInfo(`첨부 추가: ${path.basename(abs)} (${sizeKb}KB) · 다음 메시지와 함께 전송됨`);
566
+ } catch (err) {
567
+ pushSystemError('이미지를 읽을 수 없습니다: ' + err.message);
568
+ }
569
+ return true;
570
+ }
571
+ if (cmd === 'paste') {
572
+ try {
573
+ const attached = await pasteClipboardImage();
574
+ const fs = await import('node:fs/promises');
575
+ const stat = await fs.stat(attached);
576
+ const sizeKb = Math.max(1, Math.round(stat.size / 1024));
577
+ setPendingAttachments((arr) => [...arr, { kind: 'image', path: attached, sizeKb }]);
578
+ pushSystemInfo(`클립보드 이미지 첨부: ${path.basename(attached)} (${sizeKb}KB)`);
579
+ } catch (err) {
580
+ pushSystemError(err.message);
581
+ }
582
+ return true;
583
+ }
584
+ if (cmd === 'attachments') {
585
+ if (!pendingAttachments.length) {
586
+ pushSystemInfo('첨부 없음.');
587
+ } else {
588
+ pushSystemInfo(
589
+ '현재 첨부:\n' +
590
+ pendingAttachments
591
+ .map((a, i) => ` ${i + 1}. ${a.path} (${a.sizeKb}KB)`)
592
+ .join('\n'),
593
+ );
594
+ }
595
+ return true;
596
+ }
597
+ if (cmd === 'clear-attach') {
598
+ setPendingAttachments([]);
599
+ pushSystemInfo('첨부를 비웠습니다.');
600
+ return true;
601
+ }
602
+ if (cmd === 'index') {
603
+ // 명령 자체는 즉시 끝내고 빌드는 백그라운드에서 진행. 메시지로 진행 표시.
604
+ runIndexBuild({ rebuild: arg === 'rebuild', silent: false });
605
+ return true;
606
+ }
607
+ if (cmd === 'rag') {
608
+ if (arg === 'on') {
609
+ setRagEnabled(true);
610
+ pushSystemInfo(hasIndex ? 'RAG on (인덱스 사용)' : 'RAG on (단, 인덱스 없음 — bc index 로 빌드)');
611
+ } else if (arg === 'off') {
612
+ setRagEnabled(false);
613
+ pushSystemInfo('RAG off — 코드 컨텍스트 자동 주입 안 함.');
614
+ } else {
615
+ pushSystemInfo(`현재 RAG ${ragEnabled && hasIndex ? 'on' : 'off'}. 사용법: /rag on|off`);
616
+ }
617
+ return true;
618
+ }
619
+ pushSystemError('알 수 없는 명령: /' + cmd);
620
+ return true;
621
+ };
622
+
623
+ const sendMessage = async (text) => {
624
+ const userMsg = {
625
+ role: 'user',
626
+ text,
627
+ attachments: pendingAttachments,
628
+ };
629
+ const newMessages = [...messages, userMsg];
630
+ setMessages(newMessages);
631
+ setPendingAttachments([]);
632
+ setState('thinking');
633
+
634
+ // assistant placeholder — 스트리밍하면서 채워 넣음
635
+ const assistantIdx = newMessages.length;
636
+ setMessages((m) => [...m, { role: 'assistant', text: '', streaming: true }]);
637
+
638
+ // onError 와 후속 setter 가 경합하지 않게 "에러 났다" 표식을 둔다.
639
+ // SDK 가 에러를 onError 로만 알려주는 경우도 있고, textStream throw 로 주는
640
+ // 경우도 있어서 두 경로 모두 잡는다.
641
+ let errorText = null;
642
+
643
+ const replaceWithError = (msg) => {
644
+ errorText = msg;
645
+ setMessages((m) => {
646
+ const next = [...m];
647
+ next[assistantIdx] = { role: 'system-error', text: msg };
648
+ return next;
649
+ });
650
+ };
651
+
652
+ // RAG 컨텍스트 주입 — 마지막 user 메시지 기준으로 관련 코드 검색해서
653
+ // 시스템 프롬프트에 덧붙인다 (메시지 자체는 건드리지 않아 토큰 캐싱 유지).
654
+ let systemWithContext = cfg.system;
655
+ if (ragEnabled && hasIndex) {
656
+ try {
657
+ const res = await searchIndex(text, cfg.effective, { topK: 5, minScore: 0.2 });
658
+ if (res.ok && res.results.length > 0) {
659
+ const block = res.results
660
+ .map(
661
+ (r) =>
662
+ `// ${r.chunk.file}:${r.chunk.startLine}-${r.chunk.endLine} (score=${r.score.toFixed(3)})\n${r.chunk.text}`,
663
+ )
664
+ .join('\n\n');
665
+ systemWithContext +=
666
+ '\n\n관련 코드 (코드베이스 인덱스에서 검색):\n```\n' + block + '\n```\n' +
667
+ '위 코드를 우선 참조해서 답하라. 새 컴포넌트가 이미 있으면 재사용을 권장하라.';
668
+ }
669
+ } catch {
670
+ /* RAG 실패는 챗 흐름을 막지 않는다 — 그냥 컨텍스트 없이 진행. */
671
+ }
672
+ }
673
+
674
+ try {
675
+ const sdkMessages = await toSdkMessages(newMessages);
676
+ const result = streamText({
677
+ model: resolved.model,
678
+ system: systemWithContext,
679
+ messages: sdkMessages,
680
+ onError: ({ error }) => {
681
+ replaceWithError('AI 호출 에러: ' + (error?.message ?? String(error)));
682
+ },
683
+ });
684
+
685
+ setState('streaming');
686
+ let acc = '';
687
+ try {
688
+ for await (const delta of result.textStream) {
689
+ acc += delta;
690
+ setMessages((m) => {
691
+ const next = [...m];
692
+ next[assistantIdx] = { role: 'assistant', text: acc, streaming: true };
693
+ return next;
694
+ });
695
+ }
696
+ } catch (streamErr) {
697
+ replaceWithError('스트리밍 중단: ' + (streamErr?.message ?? String(streamErr)));
698
+ }
699
+
700
+ // 에러가 안 났을 때만 final assistant 로 마무리.
701
+ if (!errorText) {
702
+ if (acc.length === 0) {
703
+ // 응답이 아예 비었지만 onError 도 안 떴다 → finishReason 으로 추적.
704
+ let reason = 'unknown';
705
+ try {
706
+ reason = await result.finishReason;
707
+ } catch {
708
+ /* noop */
709
+ }
710
+ replaceWithError(`빈 응답 (finishReason=${reason}). API 키/크레딧/모델을 확인하세요.`);
711
+ } else {
712
+ setMessages((m) => {
713
+ const next = [...m];
714
+ next[assistantIdx] = { role: 'assistant', text: acc, streaming: false };
715
+ return next;
716
+ });
717
+ }
718
+ }
719
+
720
+ try {
721
+ const usage = await result.usage;
722
+ meterRef.current.add(usage);
723
+ rerender();
724
+ } catch {
725
+ /* SDK 가 usage 안 줬으면 그냥 패스 */
726
+ }
727
+ } catch (err) {
728
+ replaceWithError('스트리밍 실패: ' + (err?.message ?? String(err)));
729
+ } finally {
730
+ setState('idle');
731
+ }
732
+ };
733
+
734
+ const onSubmit = async (raw) => {
735
+ const line = raw.trim();
736
+
737
+ // 슬래시 메뉴가 열려 있으면 Enter 의 의미가 두 갈래:
738
+ // 1) input 이 메뉴의 아이템 cmd 와 정확히 일치 + 인자 필요 없는 명령 → 즉시 실행
739
+ // 2) 그 외 → 선택 항목으로 자동완성만 (실행은 Enter 한 번 더)
740
+ if (slashOpen) {
741
+ const sel = slashItems[Math.min(menuIndex, slashItems.length - 1)];
742
+ const exact = slashItems.find((it) => it.cmd === line);
743
+ if (exact && !exact.hint) {
744
+ // 정확히 매칭 + 인자 불필요 → 바로 실행
745
+ setInput('');
746
+ await handleSlash(exact.cmd);
747
+ return;
748
+ }
749
+ // 자동완성만 하고 멈춤 — 사용자가 인자를 더 칠 수 있게.
750
+ if (sel) setInput(expandSlash(sel));
751
+ return;
752
+ }
753
+
754
+ setInput('');
755
+ if (!line) return;
756
+
757
+ if (line.startsWith('/')) {
758
+ await handleSlash(line);
759
+ return;
760
+ }
761
+ await sendMessage(line);
762
+ };
763
+
764
+ return h(
765
+ Box,
766
+ { flexDirection: 'column' },
767
+ h(Header, {
768
+ modelMeta: resolved.meta,
769
+ projectFile: cfg.paths.projectFile,
770
+ gateway: cfg.effective.gateway,
771
+ ragOn: ragEnabled,
772
+ hasIndex,
773
+ openapiInfo: cfg.openapiInfo,
774
+ }),
775
+ h(
776
+ Box,
777
+ { flexDirection: 'column', marginTop: 1 },
778
+ ...messages.map((m, i) => h(MessageBubble, { key: i, message: m })),
779
+ ),
780
+ h(
781
+ Box,
782
+ { marginTop: 1 },
783
+ h(StatusLine, {
784
+ state,
785
+ meter: meterRef.current,
786
+ indexBusy,
787
+ indexProgress,
788
+ }),
789
+ ),
790
+ h(AttachBar, { pending: pendingAttachments }),
791
+ slashOpen ? h(SlashMenu, { items: slashItems, activeIndex: menuIndex }) : null,
792
+ h(
793
+ Box,
794
+ { marginTop: 0 },
795
+ h(Text, { color: 'magenta', bold: true }, 'you › '),
796
+ state === 'idle'
797
+ ? h(TextInput, {
798
+ value: input,
799
+ onChange: setInput,
800
+ onSubmit,
801
+ placeholder: '질문을 입력하거나 / 로 명령 메뉴 …',
802
+ // ink 가 그리는 가짜 커서(인버스 한 칸)를 끔. 실제 터미널 커서를
803
+ // 위 useEffect 가 강제로 켜놓아서 입력 끝에 위치하므로
804
+ // 가짜 커서가 있으면 한글 IME 미리보기가 한 칸 어긋나 보일 수 있다.
805
+ showCursor: false,
806
+ })
807
+ : h(Text, { dimColor: true }, '(응답 받는 중 — 잠시만)'),
808
+ ),
809
+ );
810
+ }
811
+
812
+ export default ChatApp;