deel-local-cli 1.0.0 → 1.0.2
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.en.md +175 -40
- package/README.md +179 -40
- package/bin/deel.js +4 -4
- package/package.json +1 -1
- package/src/agent/modes.js +1 -1
- package/src/commands.js +29 -4
- package/src/repl.js +210 -38
- package/src/ui/approve.js +66 -0
- package/src/ui/complete.js +89 -0
- package/src/ui/inputbox.js +309 -0
- package/src/ui/screen.js +123 -28
- package/src/ui/status.js +29 -6
- package/src/ui/working.js +118 -0
- package/src/ui/wrap.js +68 -0
- package/src/ui/tui.js +0 -419
package/src/commands.js
CHANGED
|
@@ -199,15 +199,40 @@ export async function handle(line, session, ctx) {
|
|
|
199
199
|
return { handled: true };
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/*
|
|
203
|
+
* /mode — 무엇을 물어보고 무엇을 그냥 할지.
|
|
204
|
+
*
|
|
205
|
+
* 목록을 그릴 때 **지금 것을 표시로 찍는다.** 전에는 '지금 auto' 라고 한 줄
|
|
206
|
+
* 적고 아래에 셋을 나란히 늘어놨는데, 그러면 어느 것이 켜져 있는지 두 군데를
|
|
207
|
+
* 견줘 봐야 안다. 내 파일이 물어보고 바뀌는지 아닌지는 흘깃 봐서 알아야 한다.
|
|
208
|
+
*/
|
|
202
209
|
case 'mode': {
|
|
210
|
+
const { 승인, 차례: 승인차례, 표시: 승인표시 } = await import('./ui/approve.js');
|
|
203
211
|
if (!MODES[arg]) {
|
|
204
|
-
|
|
205
|
-
for (const
|
|
212
|
+
rule('승인 방식 — 무엇을 물어볼까', 70);
|
|
213
|
+
for (const k of 승인차례) {
|
|
214
|
+
const 지금 = k === session.mode;
|
|
215
|
+
const m = 승인[k];
|
|
216
|
+
say(` ${지금 ? c.hgreen('●') : c.gray('○')} ${승인표시(k)}${c.gray(pad('', Math.max(1, 14 - width(m.이름))))}`
|
|
217
|
+
+ `${c.gray(pad('/mode ' + k, 15))} ${지금 ? c.white(m.한줄) : c.gray(m.한줄)}`);
|
|
218
|
+
}
|
|
219
|
+
say('');
|
|
220
|
+
say(` ${c.gray('지금은')} ${승인표시(session.mode)} ${c.gray('입니다. 상태줄 오른쪽에도 늘 떠 있습니다.')}`);
|
|
221
|
+
say(` ${c.gray('치지 않고 바꾸려면')} ${c.cyan('Shift+Tab')} ${c.gray('— 누를 때마다 차례로 돕니다.')}`);
|
|
206
222
|
say('');
|
|
207
223
|
return { handled: true };
|
|
208
224
|
}
|
|
225
|
+
const 앞 = session.mode;
|
|
209
226
|
session.mode = arg;
|
|
210
|
-
|
|
227
|
+
const m = 승인[arg];
|
|
228
|
+
say('');
|
|
229
|
+
say(` ${mark.ok} ${승인표시(앞)} ${c.gray('→')} ${승인표시(arg)}`);
|
|
230
|
+
say(` ${c.gray(m.한줄)}`);
|
|
231
|
+
// 안 묻는 쪽으로 옮길 때만 안전망을 짚어 준다. 반대로 갈 때는 안 짚는다 —
|
|
232
|
+
// 조심하는 쪽으로 가는 사람에게 경고를 붙일 이유가 없다.
|
|
233
|
+
if (arg === 'auto' && 앞 !== 'auto') {
|
|
234
|
+
say(` ${c.gray('되돌리려면')} ${c.cyan('/undo')}${c.gray(', 무엇이 바뀌었는지는')} ${c.cyan('/diff')}`);
|
|
235
|
+
}
|
|
211
236
|
say('');
|
|
212
237
|
return { handled: true };
|
|
213
238
|
}
|
|
@@ -1386,7 +1411,7 @@ function showWork(session) {
|
|
|
1386
1411
|
say(` ${canWrite(k) ? c.gray('파일 바꿈') : c.green('읽기만')}${c.gray(' · 생각 ' + (w.think ?? '그대로') + '·' + w.effort + ' · 최대 ' + w.steps + '걸음')}`);
|
|
1387
1412
|
}
|
|
1388
1413
|
say('');
|
|
1389
|
-
say(` ${c.gray('바꾸려면')} ${c.cyan('/plan')} ${c.cyan('/code')} ${c.cyan('/debug')} ${c.gray('… 또는')} ${c.cyan('
|
|
1414
|
+
say(` ${c.gray('바꾸려면')} ${c.cyan('/plan')} ${c.cyan('/code')} ${c.cyan('/debug')} ${c.gray('… 또는')} ${c.cyan('Ctrl+O')} ${c.gray('로 차례로')}`);
|
|
1390
1415
|
say(` ${c.gray('이건 승인 정책(')}${c.cyan('/mode')}${c.gray(')과 다른 축입니다 — 무엇을 하느냐 / 얼마나 물어보냐.')}`);
|
|
1391
1416
|
say('');
|
|
1392
1417
|
}
|
package/src/repl.js
CHANGED
|
@@ -6,7 +6,7 @@ import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
|
|
|
6
6
|
import { headerLines } from './ui/status.js';
|
|
7
7
|
import { 화면고르기 } from './ui/screen.js';
|
|
8
8
|
import { STAGES } from './agent/effort.js';
|
|
9
|
-
import { handle } from './commands.js';
|
|
9
|
+
import { handle, COMMANDS } from './commands.js';
|
|
10
10
|
import { next as nextWork, get as getWork, canWrite } from './agent/modes.js';
|
|
11
11
|
import { route } from './agent/route.js';
|
|
12
12
|
import { run } from './agent/loop.js';
|
|
@@ -19,12 +19,15 @@ import { discover } from './skills/discover.js';
|
|
|
19
19
|
import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
|
|
20
20
|
import { Store, latest, prune } from './agent/store.js';
|
|
21
21
|
import { askHidden } from './ui/prompt.js';
|
|
22
|
-
import { explain } from './ui/level.js';
|
|
22
|
+
import { explain, shows as levelShows } from './ui/level.js';
|
|
23
|
+
import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
|
|
24
|
+
import { 추천, 채울글 } from './ui/complete.js';
|
|
23
25
|
import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
|
|
24
26
|
import { renderDiff, shortStat } from './ui/diff.js';
|
|
25
27
|
import { expand as expandMentions } from './agent/mention.js';
|
|
26
28
|
import { 다붙이기 } from './backend/mcp.js';
|
|
27
29
|
import { 프롬프트토막 as 기억토막, 읽기 as 기억읽기 } from './agent/memory.js';
|
|
30
|
+
import { 갈래고르기 } from './ui/working.js';
|
|
28
31
|
|
|
29
32
|
// 도구마다 눈에 띄는 글자를 다르게 준다. 훑을 때 종류가 먼저 보인다.
|
|
30
33
|
const TOOL_GLYPH = {
|
|
@@ -69,8 +72,8 @@ const 답표시 = c.hcyan('▌');
|
|
|
69
72
|
export async function chatLoop(opts = {}) {
|
|
70
73
|
const cfg = load();
|
|
71
74
|
const prof = activeProfile(cfg);
|
|
72
|
-
// 연결이 없으면 화면을 세우기 전에 끝난다.
|
|
73
|
-
//
|
|
75
|
+
// 연결이 없으면 화면을 세우기 전에 끝난다. 세운 뒤에 나가면 상자를
|
|
76
|
+
// 그렸다 지우는 제어문자가 이 안내 사이에 끼어 화면이 지저분해진다.
|
|
74
77
|
if (!prof) {
|
|
75
78
|
바로쓰기('');
|
|
76
79
|
바로쓰기(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 을 먼저 실행하세요.`);
|
|
@@ -169,7 +172,49 @@ export async function chatLoop(opts = {}) {
|
|
|
169
172
|
// 지난 대화에서 정해 둔 것을 들고 시작한다.
|
|
170
173
|
session.memory = 기억토막(root);
|
|
171
174
|
|
|
172
|
-
|
|
175
|
+
/*
|
|
176
|
+
* 입력 상자를 쓸 때는 readline 이 스스로 되비추지 못하게 한다.
|
|
177
|
+
*
|
|
178
|
+
* readline 은 자기가 아는 커서 자리를 기준으로 지우고 다시 그린다. 그런데
|
|
179
|
+
* 그 자리는 우리가 그린 상자 테두리 안이 아니라 줄 맨 앞이다. 그대로 두면
|
|
180
|
+
* 백스페이스가 테두리를 갉아먹고, 긴 글이 접힐 때 상자가 무너진다.
|
|
181
|
+
*
|
|
182
|
+
* 그래서 되비추는 일만 뺏는다. 어디까지나 **되비추기만** 이다 — 한글 조합,
|
|
183
|
+
* 붙여넣기, 위아래 이력, Ctrl+A/E, 백스페이스는 전부 readline 이 그대로
|
|
184
|
+
* 맡는다. 우리는 readline 이 들고 있는 글(rl.line)을 상자 안에 그릴 뿐이다.
|
|
185
|
+
* 줄 편집을 직접 짜기 시작하면 한글 입력기부터 깨진다.
|
|
186
|
+
*/
|
|
187
|
+
const 상자쓰나 = 화면.kind === 'box';
|
|
188
|
+
// 사람이 지금 입력을 기다리는 중인가. 도구가 도는 동안 키를 눌러도
|
|
189
|
+
// 상자를 다시 그리면 안 된다 — 그 자리는 이미 대화가 흘러가고 있다.
|
|
190
|
+
let 입력기다림 = false;
|
|
191
|
+
// 되묻는 중이면 그 앞머리. 상자 대신 한 줄로 되비춘다.
|
|
192
|
+
let 묻는중 = null;
|
|
193
|
+
// 이번 틱에 다시 그리기로 이미 잡아 뒀나 (붙여넣기로 키가 쏟아질 때)
|
|
194
|
+
let 그릴예정 = false;
|
|
195
|
+
const 먹통 = { write() { return true; }, end() {}, on() {}, once() {}, emit() {}, removeListener() {} };
|
|
196
|
+
const rl = createInterface({
|
|
197
|
+
input: process.stdin,
|
|
198
|
+
output: 상자쓰나 ? 먹통 : process.stdout,
|
|
199
|
+
terminal: 상자쓰나 ? true : undefined,
|
|
200
|
+
historySize: 200,
|
|
201
|
+
/*
|
|
202
|
+
* 빈 완성기. 아무것도 안 내놓지만 **달아 둬야** 한다.
|
|
203
|
+
*
|
|
204
|
+
* 네 가지를 실제로 눌러 보고 정했다.
|
|
205
|
+
*
|
|
206
|
+
* 완성기 없음 Tab → 줄에 리터럴 탭이 박힌다 (`/hel` + Tab → `/hel\t`).
|
|
207
|
+
* 그 글이 그대로 모델에게 간다. 검사 6개가 여기서 빨개진다.
|
|
208
|
+
* 진짜 완성기 readline 이 자기 방식으로 줄을 고쳐 버린다. 우리가 그린
|
|
209
|
+
* 상자와 어긋나고, 후보가 있는 줄에서 Shift+Tab 을 누르면
|
|
210
|
+
* 승인 방식만 바뀌어야 하는데 글까지 바뀐다.
|
|
211
|
+
* 빈 완성기 Tab 도 Shift+Tab 도 줄을 안 건드린다. ← 이것
|
|
212
|
+
*
|
|
213
|
+
* 그래서 readline 에게서는 '줄을 안 건드림' 만 받고, 무엇을 채울지는 우리가
|
|
214
|
+
* rl.write 로 직접 정한다. rl.write 는 공개 API 라 한글도 안 깨진다.
|
|
215
|
+
*/
|
|
216
|
+
completer: 상자쓰나 ? (line) => [[], line] : undefined,
|
|
217
|
+
});
|
|
173
218
|
|
|
174
219
|
// 입력을 큐로 받는다. rl.question 을 겹쳐 쓰면 파이프로 넣을 때 닫혀 버린다.
|
|
175
220
|
const queue = [];
|
|
@@ -179,6 +224,17 @@ export async function chatLoop(opts = {}) {
|
|
|
179
224
|
|
|
180
225
|
rl.on('line', (l) => {
|
|
181
226
|
if (echo) say(c.gray(l));
|
|
227
|
+
if (상자쓰나) {
|
|
228
|
+
if (묻는중 !== null) {
|
|
229
|
+
// 되묻는 자리: 답을 그 줄에 남긴 채 줄만 넘긴다.
|
|
230
|
+
process.stdout.write(`\r\x1b[2K${묻는중}${c.white(l)}\n`);
|
|
231
|
+
} else {
|
|
232
|
+
// 상자를 걷어내고, 사람이 보낸 글을 대화에 남긴다. 안 남기면 스크롤을
|
|
233
|
+
// 올렸을 때 답만 있고 무엇을 물었는지가 없다.
|
|
234
|
+
화면.입력지움();
|
|
235
|
+
if (l.trim()) say(` ${c.hcyan('❯')} ${c.white(l)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
182
238
|
if (waiter) { const w = waiter; waiter = null; w(l); }
|
|
183
239
|
else queue.push(l);
|
|
184
240
|
});
|
|
@@ -187,20 +243,109 @@ export async function chatLoop(opts = {}) {
|
|
|
187
243
|
if (waiter) { const w = waiter; waiter = null; w(null); }
|
|
188
244
|
});
|
|
189
245
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
246
|
+
/*
|
|
247
|
+
* 지금 치고 있는 글에 맞는 명령들.
|
|
248
|
+
*
|
|
249
|
+
* 수준에 따라 감춘 명령이 있다(쉬움에서는 자주 쓰는 것만 보인다). 그런데
|
|
250
|
+
* **감춘 것이 못 쓰는 것은 아니다** — 치면 그대로 돌아간다. 그래서 보이는
|
|
251
|
+
* 것 중에 맞는 게 없으면 감춘 것까지 뒤진다. `/recall` 을 아는 사람이
|
|
252
|
+
* 쉬움 수준이라는 이유로 "그런 명령 없다" 는 화면을 보면 안 된다.
|
|
253
|
+
*/
|
|
254
|
+
const 지금추천 = (글) => {
|
|
255
|
+
const 보이는것 = Object.keys(COMMANDS).filter((n) => n !== 'quit' && levelShows(session.level, n));
|
|
256
|
+
const 것 = 추천(글, COMMANDS, 보이는것);
|
|
257
|
+
if (것.length) return 것;
|
|
258
|
+
return 추천(글, COMMANDS, Object.keys(COMMANDS).filter((n) => n !== 'quit'));
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/*
|
|
262
|
+
* 키를 가로챈다 — 터미널일 때만.
|
|
263
|
+
*
|
|
264
|
+
* 파이프로 넣을 때 가로채면 입력이 깨진다. 검사와 데모가 그렇게 돌아간다.
|
|
265
|
+
*
|
|
266
|
+
* ── Shift+Tab 은 무엇을 돌려야 하나 ──────────────────────────────────
|
|
267
|
+
*
|
|
268
|
+
* 전에는 **작업 모드**(종합/코드/계획…)를 돌렸다. 바꾼다. Shift+Tab 은
|
|
269
|
+
* **승인 방식**을 돌린다 — 안 묻고 고칠지, 매번 물을지.
|
|
270
|
+
*
|
|
271
|
+
* 두 가지가 이 자리를 놓고 다퉜는데, 자주 눌러야 하는 쪽이 이겨야 한다.
|
|
272
|
+
* 작업 모드는 요청을 보고 저절로 옮겨 가므로 사람이 손댈 일이 드물다.
|
|
273
|
+
* 반면 승인 방식은 "이번 건 좀 봐야겠다" 싶을 때 **일하는 도중에** 바꾸고
|
|
274
|
+
* 싶어진다. 그리고 이건 안전 설정이라, 손이 기억하는 자리에 있어야 한다.
|
|
275
|
+
* 다른 도구(Claude Code)도 같은 키에 같은 것을 둔다.
|
|
276
|
+
*
|
|
277
|
+
* 작업 모드는 Ctrl+O 로 옮겼다. `/work` 도 그대로 된다.
|
|
278
|
+
*/
|
|
194
279
|
if (process.stdin.isTTY) {
|
|
195
280
|
emitKeypressEvents(process.stdin, rl);
|
|
196
281
|
process.stdin.on('keypress', (_ch, key) => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
282
|
+
// Shift+Tab — 승인 방식 (자동 → 위험만 → 모두)
|
|
283
|
+
if (key && key.name === 'tab' && key.shift) {
|
|
284
|
+
const 앞 = 승인고르기(session.mode);
|
|
285
|
+
session.mode = 승인다음(session.mode);
|
|
286
|
+
const 뒤 = 승인고르기(session.mode);
|
|
287
|
+
화면.입력지움();
|
|
288
|
+
say(` ${뒤.색(뒤.글자)} ${c.bold(뒤.색(뒤.이름))} ${c.gray(뒤.한줄)}`);
|
|
289
|
+
say(` ${c.gray(`${앞.이름} → ${뒤.이름} · Shift+Tab 으로 계속 바꿉니다`)}`);
|
|
290
|
+
prompt();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// Ctrl+O — 작업 모드 (종합/코드/계획/설계/디버그/묻기/총괄)
|
|
294
|
+
if (key && key.ctrl && key.name === 'o') {
|
|
295
|
+
session.work = nextWork(session.work);
|
|
296
|
+
const w = getWork(session.work);
|
|
297
|
+
화면.입력지움();
|
|
298
|
+
say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
|
|
299
|
+
+ (canWrite(session.work) ? '' : ` ${c.green('· 파일을 못 바꿉니다')}`));
|
|
300
|
+
prompt();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
/*
|
|
304
|
+
* Tab — 치던 슬래시 명령을 채운다.
|
|
305
|
+
*
|
|
306
|
+
* 하나만 맞으면 끝까지, 여럿이면 다 같이 가진 앞부분까지. 목록에서
|
|
307
|
+
* 위아래로 고르게 하지 않는다 — 그러면 지난 입력 이력(위 화살표)을
|
|
308
|
+
* 뺏어야 하는데, 그건 훨씬 자주 쓰는 기능이다.
|
|
309
|
+
*/
|
|
310
|
+
if (key && key.name === 'tab' && !key.shift && 상자쓰나 && 입력기다림 && 묻는중 === null) {
|
|
311
|
+
const 채울 = 채울글(rl.line ?? '', 지금추천(rl.line ?? ''));
|
|
312
|
+
if (채울) rl.write(채울);
|
|
313
|
+
// 채울 게 없어도 그리기는 한다 — 후보 목록이 그대로 남아 있어야 한다.
|
|
314
|
+
화면.입력갱신(session, rl.line ?? '', rl.cursor ?? 0, 지금추천(rl.line ?? ''));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
/*
|
|
318
|
+
* 친 것을 상자 안에 그린다.
|
|
319
|
+
*
|
|
320
|
+
* readline 이 이 키를 처리하고 rl.line 을 고친 **뒤에** 그려야 하는데,
|
|
321
|
+
* keypress 는 그 전에 온다. 그래서 한 틱 미룬다. 안 미루면 늘 한 글자
|
|
322
|
+
* 뒤처진 글이 보인다 — 치는 사람 눈에는 마지막 글자가 안 찍히는 것으로
|
|
323
|
+
* 보이고, 그게 제일 못 미더운 화면이다.
|
|
324
|
+
*/
|
|
325
|
+
if (!상자쓰나) return;
|
|
326
|
+
if (key && key.name === 'return') return; // 줄이 끝나는 것은 'line' 이 맡는다
|
|
327
|
+
if (묻는중 !== null) {
|
|
328
|
+
const 앞 = 묻는중;
|
|
329
|
+
setImmediate(() => {
|
|
330
|
+
if (묻는중 === null) return;
|
|
331
|
+
process.stdout.write(`\r\x1b[2K${앞}${c.white(rl.line ?? '')}`);
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!입력기다림) return;
|
|
336
|
+
/*
|
|
337
|
+
* 여러 키가 한꺼번에 들어와도 **한 번만** 그린다.
|
|
338
|
+
*
|
|
339
|
+
* 붙여넣기는 글자 수만큼 키가 쏟아진다. 스무 줄짜리를 붙이면 상자를
|
|
340
|
+
* 수백 번 다시 그리게 되고, 화면이 눈에 띄게 떨린다. 어차피 마지막
|
|
341
|
+
* 한 번이 지금 상태이므로, 이번 틱에 이미 잡아 뒀으면 그냥 넘긴다.
|
|
342
|
+
*/
|
|
343
|
+
if (그릴예정) return;
|
|
344
|
+
그릴예정 = true;
|
|
345
|
+
setImmediate(() => {
|
|
346
|
+
그릴예정 = false;
|
|
347
|
+
if (입력기다림) 화면.입력갱신(session, rl.line ?? '', rl.cursor ?? 0, 지금추천(rl.line ?? ''));
|
|
348
|
+
});
|
|
204
349
|
});
|
|
205
350
|
}
|
|
206
351
|
|
|
@@ -211,10 +356,20 @@ export async function chatLoop(opts = {}) {
|
|
|
211
356
|
};
|
|
212
357
|
|
|
213
358
|
const ask = async (label, o = {}) => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
359
|
+
const 앞 = ` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`;
|
|
360
|
+
화면.붙임(앞);
|
|
361
|
+
/*
|
|
362
|
+
* 되묻는 자리는 상자를 안 쓴다 — '실행할까요? (y/n)' 에 테두리를 두르면
|
|
363
|
+
* 대화의 흐름이 끊긴다. 대신 되비추는 일은 우리가 맡아야 한다.
|
|
364
|
+
* 상자 모드에서는 readline 의 되비추기를 꺼 놨기 때문이다. 안 해 주면
|
|
365
|
+
* y 를 쳐도 화면에 아무것도 안 나타난다 — 먹은 건지 안 먹은 건지 모른다.
|
|
366
|
+
*/
|
|
367
|
+
묻는중 = 상자쓰나 ? 앞 : null;
|
|
368
|
+
try {
|
|
369
|
+
const a = await nextLine();
|
|
370
|
+
if (a === null) return o.def ?? '';
|
|
371
|
+
return a.trim() || o.def || '';
|
|
372
|
+
} finally { 묻는중 = null; }
|
|
218
373
|
};
|
|
219
374
|
|
|
220
375
|
/**
|
|
@@ -356,9 +511,19 @@ export async function chatLoop(opts = {}) {
|
|
|
356
511
|
for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
|
|
357
512
|
say(` ${c.gray('/help 명령 목록')} ${c.gray('/think 추론 강도')} ${c.gray('Ctrl+C 중단·끝내기')}`);
|
|
358
513
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
514
|
+
/*
|
|
515
|
+
* 입력 자리. 어떻게 생겼는지는 화면 쪽이 정한다 —
|
|
516
|
+
* 줄화면은 상태줄을 깔고 그 아래 ❯ 를, 상자화면은 테두리를 두른 칸을 그린다.
|
|
517
|
+
*
|
|
518
|
+
* 치던 글은 되살린다. Shift+Tab 이나 Ctrl+C 처럼 **입력 도중에** 한 줄을
|
|
519
|
+
* 끼워 넣고 다시 그리는 자리가 있는데, 그때 빈 칸을 그리면 치던 글이
|
|
520
|
+
* 사라진 것처럼 보인다. 실제로는 readline 이 그대로 들고 있어서 Enter 를
|
|
521
|
+
* 치면 멀쩡히 보내진다 — 화면만 거짓말을 하는 셈이라 더 나쁘다.
|
|
522
|
+
*/
|
|
523
|
+
const prompt = () => {
|
|
524
|
+
const 글 = 상자쓰나 && 입력기다림 ? (rl.line ?? '') : '';
|
|
525
|
+
화면.입력자리(session, 글, 글 ? (rl.cursor ?? 0) : 0, 글 ? 지금추천(글) : []);
|
|
526
|
+
};
|
|
362
527
|
|
|
363
528
|
// Ctrl+C 는 상황에 따라 뜻이 다르다.
|
|
364
529
|
// 모델이 답하는 중 → 그 답을 끊는다 (프로그램은 살아 있다)
|
|
@@ -380,7 +545,9 @@ export async function chatLoop(opts = {}) {
|
|
|
380
545
|
|
|
381
546
|
for (;;) {
|
|
382
547
|
prompt();
|
|
548
|
+
입력기다림 = true;
|
|
383
549
|
const line = await nextLine();
|
|
550
|
+
입력기다림 = false;
|
|
384
551
|
if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
|
|
385
552
|
interrupted = false;
|
|
386
553
|
const text = line.trim();
|
|
@@ -454,6 +621,14 @@ export async function chatLoop(opts = {}) {
|
|
|
454
621
|
const 꼬리표 = (ev) => { const t = stageTag(ev, session.level); return t ? t + ' ' : ''; };
|
|
455
622
|
|
|
456
623
|
turn = new AbortController();
|
|
624
|
+
/*
|
|
625
|
+
* 상자를 '일하는 중' 으로 바꾼다.
|
|
626
|
+
*
|
|
627
|
+
* 로컬 모델은 느리다 — 한 걸음에 수십 초가 걸린다. 그 동안 화면 아래가
|
|
628
|
+
* 텅 비어 있으면 사람은 멈춘 줄 알고 Ctrl+C 를 누른다. 다 되어 가던 일이
|
|
629
|
+
* 그렇게 날아간다. 테두리를 그대로 두고 안엣것만 바꾸는 이유다.
|
|
630
|
+
*/
|
|
631
|
+
화면.일시작(session, '생각');
|
|
457
632
|
try {
|
|
458
633
|
for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
|
|
459
634
|
switch (ev.type) {
|
|
@@ -495,18 +670,21 @@ export async function chatLoop(opts = {}) {
|
|
|
495
670
|
* 같아서, 화면을 훑을 때 '모델이 뭐라고 했는지' 를 눈으로 못 찾았다.
|
|
496
671
|
* 도구 이름·결과·바뀐 자리가 줄줄이 지나간 끝에 답이 섞여 있었다.
|
|
497
672
|
*
|
|
498
|
-
* 세로줄 하나면 된다.
|
|
673
|
+
* 세로줄 하나면 된다. 칸을 나눠 그리는 화면으로 갈 이유가 없다 —
|
|
499
674
|
* 파이프로 넘기거나 기록으로 남길 때도 그대로 읽힌다.
|
|
500
675
|
*/
|
|
501
676
|
case 'content':
|
|
502
677
|
clearThinking();
|
|
503
|
-
if (!streamed) { streamed = true; 화면.붙임(` ${답표시} `); }
|
|
678
|
+
if (!streamed) { streamed = true; 화면.일바꿈('답'); 화면.붙임(` ${답표시} `); }
|
|
504
679
|
화면.붙임(ev.text.replace(/\n/g, `\n ${답표시} `));
|
|
505
680
|
break;
|
|
506
681
|
|
|
507
682
|
case 'tool_start':
|
|
508
683
|
clearThinking();
|
|
509
684
|
if (streamed) { say(''); streamed = false; }
|
|
685
|
+
// 문구를 지금 하는 일에 맞춘다. 아무 말이나 돌려 대면 두 번째부터
|
|
686
|
+
// 아무도 안 읽고, 그때부터는 화면이 조용한 것과 같아진다.
|
|
687
|
+
화면.일바꿈(갈래고르기(ev.name));
|
|
510
688
|
say('');
|
|
511
689
|
say(` ${toolLabel(ev.name, ev.args)}`);
|
|
512
690
|
break;
|
|
@@ -515,6 +693,7 @@ export async function chatLoop(opts = {}) {
|
|
|
515
693
|
case 'tools_start':
|
|
516
694
|
clearThinking();
|
|
517
695
|
if (streamed) { say(''); streamed = false; }
|
|
696
|
+
화면.일바꿈(갈래고르기(ev.names?.[0]));
|
|
518
697
|
say('');
|
|
519
698
|
say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
|
|
520
699
|
break;
|
|
@@ -534,12 +713,6 @@ export async function chatLoop(opts = {}) {
|
|
|
534
713
|
const 글 = t.state === 'done' ? c.gray(t.text) : t.state === 'doing' ? c.white(t.text) : c.gray(t.text);
|
|
535
714
|
say(` ${표} ${clip(글, 74)}`);
|
|
536
715
|
}
|
|
537
|
-
// 전체화면이면 오른쪽 칸에도 세워 둔다. 흘러가 버리지 않게 —
|
|
538
|
-
// 긴 일에서 '어디까지 했나' 는 늘 보여야 하는 정보다.
|
|
539
|
-
화면.할일칸(ev.result.todos.map((t) => {
|
|
540
|
-
const 표 = t.state === 'done' ? c.green('☑') : t.state === 'doing' ? c.hyellow('▶') : c.gray('☐');
|
|
541
|
-
return `${표} ${t.state === 'doing' ? c.white(t.text) : c.gray(t.text)}`;
|
|
542
|
-
}));
|
|
543
716
|
} else {
|
|
544
717
|
say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
|
|
545
718
|
// 파일을 고쳤으면 무엇이 바뀌었는지 바로 보여 준다.
|
|
@@ -552,11 +725,6 @@ export async function chatLoop(opts = {}) {
|
|
|
552
725
|
// 엉뚱한 자리를 가리킨다 — 목록에 ../../.. 가 찍힌다.
|
|
553
726
|
session.noteChange(ev.result.changed ?? ev.args?.file_path, ev.result.diff);
|
|
554
727
|
for (const l of renderDiff(ev.result.diff, { maxLines: DIFF_LINES[session.level] ?? 20 })) say(l);
|
|
555
|
-
// 오른쪽 칸에 '이번 대화에서 뭘 건드렸나' 를 세워 둔다.
|
|
556
|
-
화면.파일칸([...session.changes].map(([p, d]) => {
|
|
557
|
-
const 이름 = ctx?.scope ? ctx.scope.show(p) : p;
|
|
558
|
-
return `${c.white(이름)} ${c.green('+' + d.added)}${c.red('-' + d.removed)}`;
|
|
559
|
-
}));
|
|
560
728
|
}
|
|
561
729
|
}
|
|
562
730
|
flush(); // 도구가 하나 끝날 때마다 적어 둔다
|
|
@@ -576,6 +744,7 @@ export async function chatLoop(opts = {}) {
|
|
|
576
744
|
* (Ctrl+C 로 멈출 수도 있다 — 그건 compact.js 쪽에서 받는다.)
|
|
577
745
|
*/
|
|
578
746
|
case 'compacting':
|
|
747
|
+
화면.일바꿈('접기');
|
|
579
748
|
clearThinking();
|
|
580
749
|
화면.돌리기('컨텍스트가 찼습니다 — 앞선 대화를 요약해 접는 중…');
|
|
581
750
|
접는중 = true;
|
|
@@ -673,6 +842,9 @@ export async function chatLoop(opts = {}) {
|
|
|
673
842
|
오류보이기(err.message);
|
|
674
843
|
}
|
|
675
844
|
접기멈춤();
|
|
845
|
+
// 어떻게 끝났든 일하는 표시는 반드시 걷는다. 오류로 빠져나온 길에서
|
|
846
|
+
// 안 걷으면 돌아가는 표시가 화면에 붙박이로 남고, 시계도 계속 돈다.
|
|
847
|
+
화면.일끝();
|
|
676
848
|
turn = null;
|
|
677
849
|
interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
|
|
678
850
|
flush(); // 오류로 끝났어도 여기까지는 남긴다
|
|
@@ -692,8 +864,8 @@ export async function chatLoop(opts = {}) {
|
|
|
692
864
|
// 띄운 남의 프로세스는 반드시 거둔다. 안 거두면 deel 을 껐는데도
|
|
693
865
|
// 그 서버가 계속 돌고 있게 된다 — 사람 눈에는 안 보이는 채로.
|
|
694
866
|
for (const s of mcp붙임.서버들) s.닫기();
|
|
695
|
-
// 끝맺음은 화면을 접기 **전에** 그린다.
|
|
696
|
-
//
|
|
867
|
+
// 끝맺음은 화면을 접기 **전에** 그린다. close() 가 상자를 걷어내므로,
|
|
868
|
+
// 그 뒤에 찍으면 걷어낸 자리에 뜬금없이 한 줄이 남는다.
|
|
697
869
|
say('');
|
|
698
870
|
say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
|
|
699
871
|
say('');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 승인 방식을 사람 말로.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 따로 두나 ────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 상태줄에 `auto` 라고만 떠 있었다. 그 옆에는 `종합` 과 `medium·절약` 이 나란히
|
|
7
|
+
* 있어서, 셋 다 그냥 '모드' 처럼 보였다. 그중 하나가 **묻지 않고 파일을 고친다**
|
|
8
|
+
* 는 뜻이라는 것은 화면 어디에도 없었다.
|
|
9
|
+
*
|
|
10
|
+
* 이건 꾸미기가 아니라 안전 표시다. 지금 이 순간 내 파일이 물어보고 바뀌는지
|
|
11
|
+
* 안 물어보고 바뀌는지는, 화면을 흘깃 봐서 **바로** 알아야 하는 한 가지다.
|
|
12
|
+
*
|
|
13
|
+
* 그래서 세 가지를 준다 — 눈에 띄는 글자, 사람 말 이름, 무엇을 묻는지 한 줄.
|
|
14
|
+
*/
|
|
15
|
+
import { c } from './ansi.js';
|
|
16
|
+
|
|
17
|
+
export const 승인 = {
|
|
18
|
+
auto: {
|
|
19
|
+
글자: '⏵⏵',
|
|
20
|
+
이름: '자동 승인',
|
|
21
|
+
짧은이름: '자동',
|
|
22
|
+
한줄: '안 묻고 고칩니다. 되돌리기(/undo)가 안전망입니다',
|
|
23
|
+
색: c.hyellow,
|
|
24
|
+
},
|
|
25
|
+
confirm: {
|
|
26
|
+
글자: '⏵',
|
|
27
|
+
이름: '위험만 확인',
|
|
28
|
+
짧은이름: '위험만',
|
|
29
|
+
한줄: '되돌릴 수 없는 명령만 물어봅니다. 파일은 안 묻고 고칩니다',
|
|
30
|
+
색: c.white,
|
|
31
|
+
},
|
|
32
|
+
strict: {
|
|
33
|
+
글자: '⏸',
|
|
34
|
+
이름: '모두 확인',
|
|
35
|
+
짧은이름: '모두',
|
|
36
|
+
한줄: '파일을 고치거나 명령을 돌리기 전에 매번 물어봅니다',
|
|
37
|
+
색: c.hgreen,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const 차례 = ['auto', 'confirm', 'strict'];
|
|
42
|
+
export const 기본 = 'auto';
|
|
43
|
+
|
|
44
|
+
export function 고르기(이름) {
|
|
45
|
+
return 승인[이름] ?? 승인[기본];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 다음 승인 방식. Shift+Tab 이 이걸로 돈다.
|
|
50
|
+
*
|
|
51
|
+
* 자동 → 위험만 → 모두 → 자동. **느슨한 쪽에서 조이는 쪽으로** 도는 차례다 —
|
|
52
|
+
* 한 번 누를 때마다 더 많이 물어보게 되니, 잘못 눌러도 위험해지지 않는다.
|
|
53
|
+
* 반대로 돌면 Shift+Tab 한 번에 '안 묻고 고침' 으로 떨어진다.
|
|
54
|
+
*/
|
|
55
|
+
export function 다음(이름) {
|
|
56
|
+
// 모르는 값이면 화면에 떠 있는 것(고르기가 내주는 기본)을 기준으로 삼는다.
|
|
57
|
+
// 안 그러면 보이는 것과 한 칸 어긋나서, 눌러도 안 바뀐 것처럼 보인다.
|
|
58
|
+
const i = 차례.indexOf(승인[이름] ? 이름 : 기본);
|
|
59
|
+
return 차례[(i + 1) % 차례.length];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 상태줄에 넣을 조각. 짧게=true 면 좁은 창용. */
|
|
63
|
+
export function 표시(이름, { 짧게 = false } = {}) {
|
|
64
|
+
const m = 고르기(이름);
|
|
65
|
+
return `${m.색(m.글자)} ${m.색(짧게 ? m.짧은이름 : m.이름)}`;
|
|
66
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 슬래시 명령 자동완성.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇을 푸는가 ───────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 명령이 서른 개가 넘는다. 다 외우고 있는 사람은 만든 사람뿐이고, 그마저도
|
|
7
|
+
* `/mem…` 까지 치고 "이게 memory 였나 memo 였나" 하고 멈춘다. 그때 할 수 있는
|
|
8
|
+
* 것이 `/help` 를 쳐서 서른 줄을 받아 눈으로 훑는 것뿐이면, 그건 명령이 아니라
|
|
9
|
+
* 시험이다.
|
|
10
|
+
*
|
|
11
|
+
* 그래서 치는 도중에 보여 준다. 고르는 것이 아니라 **알아보는 것**이 목적이라
|
|
12
|
+
* 위아래 화살표로 고르게 하지 않는다 — 그러면 지난 입력 이력(위 화살표)을
|
|
13
|
+
* 뺏어야 하고, 그건 훨씬 자주 쓰는 기능이다. Tab 한 번이면 채워진다.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// 화면에 한 번에 보여 줄 최대 개수. 더 많으면 목록이 화면을 밀어낸다.
|
|
17
|
+
export const 최대추천 = 6;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 지금 치고 있는 글에 맞는 명령들.
|
|
21
|
+
*
|
|
22
|
+
* 슬래시로 시작하고 아직 빈칸을 안 친 동안에만 본다. `/mode auto` 처럼 인자를
|
|
23
|
+
* 치기 시작하면 명령은 이미 정해진 것이라 더 보여 줄 것이 없다.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} 글 사람이 지금까지 친 것
|
|
26
|
+
* @param {object} 명령표 { 이름: { desc, arg } }
|
|
27
|
+
* @param {string[]} 보일것 이 수준에서 보여 줄 이름들 (없으면 전부)
|
|
28
|
+
*/
|
|
29
|
+
export function 추천(글, 명령표 = {}, 보일것 = null) {
|
|
30
|
+
const s = String(글 ?? '');
|
|
31
|
+
const m = /^\/([^\s]*)$/.exec(s);
|
|
32
|
+
if (!m) return [];
|
|
33
|
+
|
|
34
|
+
const 친것 = m[1].toLowerCase();
|
|
35
|
+
const 이름들 = 보일것 ?? Object.keys(명령표);
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* 앞에서부터 맞는 것을 먼저, 가운데 맞는 것을 뒤에.
|
|
39
|
+
*
|
|
40
|
+
* `/mo` 를 치면 mode·model 이 먼저 오고 memory 는 안 온다. 그런데 `/emo` 처럼
|
|
41
|
+
* 앞을 틀리게 쳤을 때도 memory 가 나오면 고맙다 — 오타는 앞글자에서 제일 많이 난다.
|
|
42
|
+
*/
|
|
43
|
+
const 앞 = [];
|
|
44
|
+
const 안 = [];
|
|
45
|
+
for (const 이름 of 이름들) {
|
|
46
|
+
const 낮 = 이름.toLowerCase();
|
|
47
|
+
if (!친것) { 앞.push(이름); continue; }
|
|
48
|
+
if (낮.startsWith(친것)) 앞.push(이름);
|
|
49
|
+
else if (낮.includes(친것)) 안.push(이름);
|
|
50
|
+
}
|
|
51
|
+
return [...앞, ...안].map((이름) => ({
|
|
52
|
+
이름,
|
|
53
|
+
설명: 명령표[이름]?.desc ?? '',
|
|
54
|
+
인자: 명령표[이름]?.arg ?? '',
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Tab 을 눌렀을 때 **덧붙일 글자**. 없으면 빈 글자.
|
|
60
|
+
*
|
|
61
|
+
* 하나만 맞으면 끝까지 채우고 빈칸을 하나 붙인다(인자를 받는 명령이면 바로 이어
|
|
62
|
+
* 칠 수 있게). 여럿이면 **다 같이 가진 앞부분까지만** 채운다 — 하나를 골라
|
|
63
|
+
* 넣어 버리면 사람이 원한 것이 아닐 때 지우는 수고가 더 든다.
|
|
64
|
+
*/
|
|
65
|
+
export function 채울글(글, 후보들) {
|
|
66
|
+
const s = String(글 ?? '');
|
|
67
|
+
const m = /^\/([^\s]*)$/.exec(s);
|
|
68
|
+
if (!m || !후보들?.length) return '';
|
|
69
|
+
const 친것 = m[1];
|
|
70
|
+
|
|
71
|
+
// 앞에서부터 맞는 것만 채운다. 가운데 맞은 것을 채우면 친 글자가 사라진다.
|
|
72
|
+
const 맞는것 = 후보들.filter((x) => x.이름.toLowerCase().startsWith(친것.toLowerCase()));
|
|
73
|
+
if (!맞는것.length) return '';
|
|
74
|
+
|
|
75
|
+
if (맞는것.length === 1) {
|
|
76
|
+
const 이름 = 맞는것[0].이름;
|
|
77
|
+
return 이름.slice(친것.length) + (맞는것[0].인자 ? ' ' : '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 다 같이 가진 앞부분.
|
|
81
|
+
let 같은데까지 = 맞는것[0].이름;
|
|
82
|
+
for (const x of 맞는것.slice(1)) {
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < 같은데까지.length && i < x.이름.length
|
|
85
|
+
&& 같은데까지[i].toLowerCase() === x.이름[i].toLowerCase()) i++;
|
|
86
|
+
같은데까지 = 같은데까지.slice(0, i);
|
|
87
|
+
}
|
|
88
|
+
return 같은데까지.length > 친것.length ? 같은데까지.slice(친것.length) : '';
|
|
89
|
+
}
|