deel-local-cli 1.0.0 → 1.0.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.en.md CHANGED
@@ -394,51 +394,51 @@ Two things matter here:
394
394
  - **Beginners do not get fewer safeguards.** Undo, workspace scope and dangerous-command
395
395
  blocking are identical. A beginner needs the undo more, not less.
396
396
 
397
- ### Two screens
398
-
399
- Launched in a terminal, deel runs **full-screen**: conversation on the left, files changed
400
- this session at the top right, todos below them, with a status line and an input box pinned
401
- at the bottom.
402
-
403
- ```
404
- ┌ 대화 ─────────────────────────────────────────────┬ 바뀐 파일 ───────────────┐
405
- │ ❊ Grep(console.log) │ src/runner.js +3-1 │
406
- │ └ 3 files · 11 hits │ src/index.js +1-1 │
407
- │ │ src/ui/log.js +12-0 │
408
- │ ◈ Edit(src/runner.js) ├ 할 일 ───────────────────│
409
- │ └ 1 spot +3-1 │ ☑ find log calls │
410
- │ - 12 console.log('시작', 이름) │ ☑ unify runner.js │
411
- │ + 12 logger.info({ 단계: '시작', 이름 }) │ ▶ unify index.js │
412
- │ │ ☐ update docs │
413
- │ ▌ Unified log calls to the logger format. │ │
414
- │ │ │
415
- │ ── 4.2s · 3 tools · ↑3,900 ↓180 │ │
416
- └───────────────────────────────────────────────────┴──────────────────────────┘
417
- ▏myproject · qwen2.5-coder:7b ▏ ▰▰▱▱▱▱▱▱ 22% ▏ ◎ 종합 · ◇ medium · auto ▏ ↑3.8k ↓180
418
- ╭─────────────────────────────────────────────────────────────────────────────╮
419
- │ ❯ │
420
- ╰─────────────────────────────────────────────────────────────────────────────╯
397
+ ### The input box
398
+
399
+ Launched in a terminal, **the conversation scrolls normally and an input box is pinned at
400
+ the bottom.** Only the box is erased and redrawn nothing above it is touched.
401
+
421
402
  ```
403
+ ❊ Grep(console.log)
404
+ └ 3 files · 11 hits
405
+ ◈ Edit(src/runner.js)
406
+ └ 1 spot +3-1
407
+ - 12 console.log('시작', 이름)
408
+ + 12 logger.info({ 단계: '시작', 이름 })
422
409
 
423
- **It switches itself off where it would do harm.** Piped or redirected output, `CI` set,
424
- `TERM=dumb`, or a window under 60×16 all fall back to the **scrolling view** without asking —
425
- `deel … | tee log.txt` must not become a pile of escape codes. Passing `--tui` does not
426
- override a pipe.
410
+ Unified log calls to the logger format. One change in runner.js.
427
411
 
428
- | | Full-screen | Scrolling (`--no-tui`) |
429
- |---|---|---|
430
- | When | Launched in a terminal | Pipes, CI, small windows, `--no-tui` |
431
- | Files changed / todos | Always visible on the right (≥96 cols) | Printed inline as they happen |
432
- | Earlier output | Wrapped and paged inside the frame | Your terminal's own scrollback |
433
- | On exit | **Replays the conversation into scrollback** | Already there |
434
- | For keeping a log | Not this one | This one |
412
+ ── 4.2s · 3 tools · ↑3,900 ↓180
435
413
 
436
- Full-screen uses the alternate screen buffer, like vim. Exiting would otherwise erase the whole
437
- conversation, so the recent lines are reprinted into the real screen on the way out. Whether it
438
- exits on Ctrl+C or dies on an uncaught error, **the terminal is always restored.**
414
+ ▏myproject · qwen2.5-coder:7b ▰▰▱▱▱▱▱▱ 22% 종합 · medium · auto
415
+ ╭─────────────────────────────────────────────────────────────────────────────╮
416
+ also shrink the aggregate helpers │
417
+ ╰─────────────────────────────────────────────────────────────────────────────╯
418
+ ```
419
+
420
+ Terminal scrollback, selection and `Ctrl+F` all keep working, because the conversation is
421
+ never trapped inside a pane of ours. Long input grows the box to as many lines as it needs.
439
422
 
440
- If the full-screen view cannot be set up an unusual terminal, anything at all — it falls back
441
- to the scrolling view and says why in one line. **No program should fail to start over a screen.**
423
+ **It switches itself off where it would do harm.** Piped or redirected output, `CI` set,
424
+ `TERM=dumb`, or a window under 40 columns: no box, no asking. `deel | tee log.txt` must
425
+ not become a pile of escape codes. Passing `--tui` does not override a pipe, and `--no-tui`
426
+ turns it off at any time.
427
+
428
+ Line editing stays entirely with Node's readline — Korean IME composition, paste, history,
429
+ Ctrl+A/E, backspace. We only *draw* the string readline is holding. Hand-rolling a line
430
+ editor is how you break IME input first.
431
+
432
+ > **A path taken and abandoned** — the first version borrowed the whole terminal (an
433
+ > alternate screen, like vim) and split it into conversation, changed-files and todo panes.
434
+ > It looked the part, and **every slash command went dead.** Six modules including
435
+ > `commands.js` write straight to the terminal rather than through the screen object, and a
436
+ > full repaint erased their output the instant it appeared. Not "the command didn't run" —
437
+ > "you can't see that it ran", which is worse. Fixing it would mean threading every one of
438
+ > those six through the screen object, plus every one added later, with a silent return of
439
+ > the same symptom if one is missed. So the design went the other way: let the conversation
440
+ > flow, manage only the box. ([`test/box.test.js`](test/box.test.js) spawns a child that
441
+ > pretends to be a terminal, so this one cannot ship again.)
442
442
 
443
443
  ---
444
444
 
@@ -1195,7 +1195,7 @@ deel --effort <profile> even / save (default) / deep
1195
1195
  deel --offline Nothing leaves this machine
1196
1196
  deel --continue Resume the most recent conversation
1197
1197
  deel --resume <id> Resume a specific one
1198
- deel --no-tui Turn the full-screen view off; use the scrolling view (see below)
1198
+ deel --no-tui Turn the input box off; plain scrolling view (see below)
1199
1199
  ```
1200
1200
 
1201
1201
  ### Project rules
@@ -1226,7 +1226,7 @@ If the working folder has `DEEL.md`, `CLAUDE.md` or `AGENTS.md`, it is loaded as
1226
1226
  ## Development
1227
1227
 
1228
1228
  ```bash
1229
- npm test Full suite (1,745 checks)
1229
+ npm test Full suite (1,787 checks)
1230
1230
  npm run coverage Which lines the tests actually execute
1231
1231
  npm run verify Import + network checks only
1232
1232
  npm run bench Edit success rate
@@ -1280,7 +1280,7 @@ Zero dependencies rules out c8 and nyc, so this reads Node's own
1280
1280
  `NODE_V8_COVERAGE` instead — nothing new to get through an import review. It picks up
1281
1281
  child processes too, so the `cli` suite that spawns `deel` counts like everything else.
1282
1282
 
1283
- Currently **92% overall** (6,960 of 7,570 lines). Three files are deliberately left short.
1283
+ Currently **92% overall** (6,911 of 7,496 lines). Three files are deliberately left short.
1284
1284
 
1285
1285
  | File | Now | Why it stops there |
1286
1286
  |---|---|---|
package/README.md CHANGED
@@ -401,50 +401,51 @@ LM Studio 는 `/api/v0/models`, llama.cpp 는 `/props`. 못 알아보면 `(추
401
401
  - **초보라고 승인을 덜 받지 않습니다.** 되돌리기·작업 범위·위험 명령 차단은 두 수준이 같습니다.
402
402
  초보일수록 되돌릴 수 있어야 합니다.
403
403
 
404
- ### 화면 두 가지
405
-
406
- 터미널에서 그냥 켜면 **전체화면**입니다. 왼쪽은 대화, 오른쪽 위는 이번에 바뀐 파일,
407
- 오른쪽 아래는 일입니다. 아래에 상태줄과 입력칸이 고정으로 붙습니다.
408
-
409
- ```
410
- 대화 ─────────────────────────────────────────────┬ 바뀐 파일 ───────────────┐
411
- │ ❊ Grep(console.log) │ src/runner.js +3-1 │
412
- │ └ 3개 파일 · 11건 │ src/index.js +1-1 │
413
- │ │ src/ui/log.js +12-0 │
414
- │ ◈ Edit(src/runner.js) ├ 할 일 ───────────────────│
415
- │ └ 1군데 +3-1 │ 로그 호출 찾기 │
416
- │ - 12 console.log('시작', 이름) │ ☑ runner.js 통일 │
417
- │ + 12 logger.info({ 단계: '시작', 이름 }) │ ▶ index.js 통일 │
418
- │ │ ☐ 문서 갱신 │
419
- │ ▌ 로그 호출을 logger 형식으로 통일했습니다. │ │
420
- │ │ │
421
- │ ── 4.2초 · 도구 3회 · ↑3,900 ↓180 │ │
422
- └───────────────────────────────────────────────────┴──────────────────────────┘
423
- ▏myproject · qwen2.5-coder:7b ▏ ▰▰▱▱▱▱▱▱ 22% ▏ ◎ 종합 · ◇ medium · auto ▏ ↑3.8k ↓180
404
+ ### 입력칸
405
+
406
+ 터미널에서 그냥 켜면 **대화는 위로 흘러가고, 아래에 입력 상자가 붙습니다.**
407
+ 상자만 우리가 지우고 다시 그립니다 위쪽 대화는 손대지 않습니다.
408
+
409
+ ```
410
+ Grep(console.log)
411
+ 3개 파일 · 11건
412
+ Edit(src/runner.js)
413
+ 1군데 +3-1
414
+ - 12 console.log('시작', 이름)
415
+ + 12 logger.info({ 단계: '시작', 이름 })
416
+
417
+ 로그 호출을 logger 형식으로 통일했습니다. runner.js 한 군데입니다.
418
+
419
+ ── 4.2초 · 도구 3회 · ↑3,900 ↓180
420
+
421
+ ▏myproject · qwen2.5-coder:7b ▏ ▰▰▱▱▱▱▱▱ 22% ▏ ◎ 종합 · medium · auto
424
422
  ╭─────────────────────────────────────────────────────────────────────────────╮
425
- │ ❯
423
+ │ ❯ 집계 함수도 줄여줘
426
424
  ╰─────────────────────────────────────────────────────────────────────────────╯
427
425
  ```
428
426
 
427
+ 터미널 스크롤·복사·`Ctrl+F` 찾기가 **그대로 됩니다.** 대화를 우리 칸에 가둬 두지
428
+ 않기 때문입니다. 긴 글을 치면 상자가 알아서 여러 줄로 늘어납니다.
429
+
429
430
  **저절로 꺼지는 자리가 있습니다.** 파이프·리다이렉트로 넘길 때, `CI` 가 켜져 있을 때,
430
- `TERM=dumb` 일 때, 창이 60칸·16줄보다 작을 때는 묻지 않고 **줄 화면**으로 갑니다.
431
+ `TERM=dumb` 일 때, 창이 40칸보다 좁을 때는 묻지 않고 상자를 그립니다.
431
432
  `deel … | tee 기록.txt` 가 제어문자 덩어리가 되면 안 되기 때문입니다.
432
- `--tui` 를 줘도 파이프면 안 켭니다.
433
-
434
- | | 전체화면 | 화면 (`--no-tui`) |
435
- |---|---|---|
436
- | 언제 | 터미널에서 그냥 | 파이프·CI·좁은 창·`--no-tui` |
437
- | 바뀐 파일·할 일 | 오른쪽 칸에 늘 보임 (96칸 이상) | 나올 때 줄로 흘려보냄 |
438
- | 지난 화면 | 스크롤 대신 안에서 접어 보여 | 터미널 스크롤 그대로 |
439
- | 나갈 | **나눈 대화를 스크롤백에 되살림** | 이미 거기 있음 |
440
- | 기록으로 남길 | 맞음 | 이쪽 |
441
-
442
- 전체화면은 vim 처럼 화면(대체 화면 버퍼)을 씁니다. 그냥 나가면 방금 나눈 대화가
443
- 통째로 사라지므로, 나갈 최근 줄들을 원래 화면에 다시 찍어 놓습니다.
444
- Ctrl+C 죽든 오류로 터지든 **터미널은 반드시 되돌려 놓습니다.**
445
-
446
- 전체화면을 세우면 유별난 터미널이거나 무엇이 됐든 — 조용히 줄 화면으로 가고
447
- 그랬는지 말합니다. **화면 하나 때문에 프로그램이 뜨는 일은 없습니다.**
433
+ `--tui` 를 줘도 파이프면 안 켭니다. `--no-tui` 로 언제든 끌 수 있습니다.
434
+
435
+ 편집은 전부 Node readline 그대로 맡습니다 — 한글 조합, 붙여넣기,
436
+ 위아래 이력, Ctrl+A/E, 백스페이스. 우리는 readline 이 들고 있는 글을 상자 안에
437
+ **그리기만** 합니다. 직접 키를 받아 편집을 짜기 시작하면 한글 입력기부터 깨집니다.
438
+
439
+ > **한 틀렸던 길** 처음에는 터미널을 통째로 빌려(vim 처럼 화면) 대화 칸·
440
+ > 파일 칸·할 칸을 나눠 그렸습니다. 보기에는 그럴듯했는데 **슬래시 명령이 전부
441
+ > 먹통**이 됐습니다. `commands.js` 비롯한 여섯 모듈이 화면 객체를 안 거치고
442
+ > 터미널에 바로 쓰는데, 매번 화면을 통째로 다시 그리니 그 글이 찍히자마자 덮여
443
+ > 사라졌던 것입니다. 명령이 도는 아니라 **결과가 보이는** 것이라
444
+ > 나빴습니다. 고치려면 터미널에 쓰는 자리를 전부 화면 객체로 꿰야 하는데, 지금
445
+ > 여섯 곳이고 앞으로 것이며, 하나라도 빠뜨리면 같은 증상이 조용히 돌아옵니다.
446
+ > 그래서 반대로 갔습니다 — 대화는 그냥 흘려보내고 상자만 관리합니다.
447
+ > ([`test/box.test.js`](test/box.test.js) 터미널인 척하는 자식을 띄워
448
+ > 결함이 다시나가는지 봅니다.)
448
449
 
449
450
  ---
450
451
 
@@ -1216,7 +1217,7 @@ deel --effort <배분> even / save(기본) / deep
1216
1217
  deel --offline 이 컴퓨터 밖으로 아무것도 안 보냄
1217
1218
  deel --continue 가장 최근 대화 이어하기
1218
1219
  deel --resume <id> 골라서 이어하기
1219
- deel --no-tui 전체화면을 끄고 줄 화면으로 (아래 참고)
1220
+ deel --no-tui 입력 상자를 끄고 줄 화면으로 (아래 참고)
1220
1221
  ```
1221
1222
 
1222
1223
  ### 프로젝트 규칙
@@ -1247,7 +1248,7 @@ deel --no-tui 전체화면을 끄고 줄 화면으로 (아래 참고)
1247
1248
  ## 개발
1248
1249
 
1249
1250
  ```bash
1250
- npm test 전체 검증 (1,745항목)
1251
+ npm test 전체 검증 (1,787항목)
1251
1252
  npm run coverage 검사가 소스의 어디를 밟았는지
1252
1253
  npm run verify 반입·통신 검증만
1253
1254
  npm run bench 편집 성공률 측정
@@ -1314,7 +1315,7 @@ node test/coverage.mjs --json 기계가 읽을 형태로
1314
1315
  `NODE_V8_COVERAGE` 를 읽습니다 — 새로 반입 심사할 것이 하나도 안 늡니다.
1315
1316
  자식 프로세스까지 잡히므로 `deel` 을 띄워 보는 `cli` 검사도 그대로 집계됩니다.
1316
1317
 
1317
- 지금 **전체 92%** (7,570줄 중 6,960줄). 일부러 못 채운 곳이 셋 있습니다.
1318
+ 지금 **전체 92%** (7,496줄 중 6,911줄). 일부러 못 채운 곳이 셋 있습니다.
1318
1319
 
1319
1320
  | 파일 | 지금 | 왜 못 채우나 |
1320
1321
  |---|---|---|
@@ -1328,8 +1329,9 @@ node test/coverage.mjs --json 기계가 읽을 형태로
1328
1329
  bin/deel.js 진입점
1329
1330
  src/
1330
1331
  ui/ 색·한글 폭·상자·상태줄·입력
1331
- ui/screen.js 화면 고르기 (줄 화면 / 전체화면)
1332
- ui/tui.js 전체화면 접어쓰기·오른쪽 칸·나갈 되살리기
1332
+ ui/screen.js 화면 고르기 (줄 화면 / 상자 화면)
1333
+ ui/inputbox.js 아래 입력 상자 그리기·지우기·커서 자리
1334
+ ui/wrap.js 색을 지키며 폭에 맞춰 접기
1333
1335
  agent/loop.js 에이전트 루프
1334
1336
  agent/session.js 대화 상태 + 컨텍스트 셈
1335
1337
  agent/effort.js 단계별 추론 강도 배분
package/bin/deel.js CHANGED
@@ -102,7 +102,7 @@ function help() {
102
102
  say(` ${c.gray('--max-tokens <길이>')} 한 번에 받을 답 길이 상한 (32k). 큰 파일이 잘리면 올린다 — /out 과 같은 값`);
103
103
  say(` ${c.gray('--think <수준>')} off / low / medium(기본) / high / max`);
104
104
  say(` ${c.gray('--effort <배분>')} even(균일) / save(절약, 기본) / deep(깊게)`);
105
- say(` ${c.gray('--no-tui')} 전체화면 대신 줄 화면으로 (파이프·기록·좁은 터미널)`);
105
+ say(` ${c.gray('--no-tui')} 입력 상자 없이 줄 화면으로 (파이프·기록·좁은 터미널)`);
106
106
  say(` ${c.gray('--offline')} 이 컴퓨터 밖으로는 아무것도 안 보냄 (자물쇠)`);
107
107
  say(` ${c.gray('--continue')} 이 폴더에서 가장 최근 대화 이어하기`);
108
108
  say(` ${c.gray('--resume <id>')} 골라서 이어하기 (deel sessions 로 id 확인)`);
@@ -173,9 +173,9 @@ async function main() {
173
173
  maxTokens: flags['max-tokens'] ? parseSize(String(flags['max-tokens'])) : undefined,
174
174
  think: flags.think ? String(flags.think) : undefined,
175
175
  effort: flags.effort ? String(flags.effort) : undefined,
176
- // 전체화면을 쓸지. 안 주면 null — 그러면 화면 쪽이 상황을 보고 정한다.
177
- // --no-tui 줄 화면으로 (파이프·기록·좁은 터미널·문제가 생겼을 때)
178
- // --tui 터미널이면 무조건 전체화면으로
176
+ // 입력 상자를 쓸지. 안 주면 null — 그러면 화면 쪽이 상황을 보고 정한다.
177
+ // --no-tui 입력 상자 없이 줄 화면으로 (파이프·기록·좁은 터미널)
178
+ // --tui 터미널이면 무조건 입력 상자를 켠다
179
179
  tui: flags['no-tui'] === true ? false : (flags.tui === true ? true : null),
180
180
  offline: flags.offline === true || flags.offline === 'true',
181
181
  continue: flags.continue === true || flags.c === true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI — 외부 의존성 0개 / Zero-dependency coding agent CLI for local LLMs and private gateways",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/repl.js CHANGED
@@ -69,8 +69,8 @@ const 답표시 = c.hcyan('▌');
69
69
  export async function chatLoop(opts = {}) {
70
70
  const cfg = load();
71
71
  const prof = activeProfile(cfg);
72
- // 연결이 없으면 화면을 세우기 전에 끝난다. 전체화면은 터미널을 통째로
73
- // 바꿔치기하므로, 세웠다가 바로 나가면 안내가 화면과 함께 사라진다.
72
+ // 연결이 없으면 화면을 세우기 전에 끝난다. 세운 뒤에 나가면 상자를
73
+ // 그렸다 지우는 제어문자가안내 사이에 끼어 화면이 지저분해진다.
74
74
  if (!prof) {
75
75
  바로쓰기('');
76
76
  바로쓰기(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 을 먼저 실행하세요.`);
@@ -169,7 +169,33 @@ export async function chatLoop(opts = {}) {
169
169
  // 지난 대화에서 정해 둔 것을 들고 시작한다.
170
170
  session.memory = 기억토막(root);
171
171
 
172
- const rl = createInterface({ input: process.stdin, output: process.stdout, historySize: 200 });
172
+ /*
173
+ * 입력 상자를 쓸 때는 readline 이 스스로 되비추지 못하게 한다.
174
+ *
175
+ * readline 은 자기가 아는 커서 자리를 기준으로 지우고 다시 그린다. 그런데
176
+ * 그 자리는 우리가 그린 상자 테두리 안이 아니라 줄 맨 앞이다. 그대로 두면
177
+ * 백스페이스가 테두리를 갉아먹고, 긴 글이 접힐 때 상자가 무너진다.
178
+ *
179
+ * 그래서 되비추는 일만 뺏는다. 어디까지나 **되비추기만** 이다 — 한글 조합,
180
+ * 붙여넣기, 위아래 이력, Ctrl+A/E, 백스페이스는 전부 readline 이 그대로
181
+ * 맡는다. 우리는 readline 이 들고 있는 글(rl.line)을 상자 안에 그릴 뿐이다.
182
+ * 줄 편집을 직접 짜기 시작하면 한글 입력기부터 깨진다.
183
+ */
184
+ const 상자쓰나 = 화면.kind === 'box';
185
+ // 사람이 지금 입력을 기다리는 중인가. 도구가 도는 동안 키를 눌러도
186
+ // 상자를 다시 그리면 안 된다 — 그 자리는 이미 대화가 흘러가고 있다.
187
+ let 입력기다림 = false;
188
+ // 되묻는 중이면 그 앞머리. 상자 대신 한 줄로 되비춘다.
189
+ let 묻는중 = null;
190
+ // 이번 틱에 다시 그리기로 이미 잡아 뒀나 (붙여넣기로 키가 쏟아질 때)
191
+ let 그릴예정 = false;
192
+ const 먹통 = { write() { return true; }, end() {}, on() {}, once() {}, emit() {}, removeListener() {} };
193
+ const rl = createInterface({
194
+ input: process.stdin,
195
+ output: 상자쓰나 ? 먹통 : process.stdout,
196
+ terminal: 상자쓰나 ? true : undefined,
197
+ historySize: 200,
198
+ });
173
199
 
174
200
  // 입력을 큐로 받는다. rl.question 을 겹쳐 쓰면 파이프로 넣을 때 닫혀 버린다.
175
201
  const queue = [];
@@ -179,6 +205,17 @@ export async function chatLoop(opts = {}) {
179
205
 
180
206
  rl.on('line', (l) => {
181
207
  if (echo) say(c.gray(l));
208
+ if (상자쓰나) {
209
+ if (묻는중 !== null) {
210
+ // 되묻는 자리: 답을 그 줄에 남긴 채 줄만 넘긴다.
211
+ process.stdout.write(`\r\x1b[2K${묻는중}${c.white(l)}\n`);
212
+ } else {
213
+ // 상자를 걷어내고, 사람이 보낸 글을 대화에 남긴다. 안 남기면 스크롤을
214
+ // 올렸을 때 답만 있고 무엇을 물었는지가 없다.
215
+ 화면.입력지움();
216
+ if (l.trim()) say(` ${c.hcyan('❯')} ${c.white(l)}`);
217
+ }
218
+ }
182
219
  if (waiter) { const w = waiter; waiter = null; w(l); }
183
220
  else queue.push(l);
184
221
  });
@@ -194,13 +231,47 @@ export async function chatLoop(opts = {}) {
194
231
  if (process.stdin.isTTY) {
195
232
  emitKeypressEvents(process.stdin, rl);
196
233
  process.stdin.on('keypress', (_ch, key) => {
197
- if (!key || key.name !== 'tab' || !key.shift) return;
198
- session.work = nextWork(session.work);
199
- const w = getWork(session.work);
200
- 화면.입력지움();
201
- say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
202
- + (canWrite(session.work) ? '' : ` ${c.green('· 파일을 못 바꿉니다')}`));
203
- prompt();
234
+ if (key && key.name === 'tab' && key.shift) {
235
+ session.work = nextWork(session.work);
236
+ const w = getWork(session.work);
237
+ 화면.입력지움();
238
+ say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
239
+ + (canWrite(session.work) ? '' : ` ${c.green('· 파일을 못 바꿉니다')}`));
240
+ prompt();
241
+ return;
242
+ }
243
+ /*
244
+ * 친 것을 상자 안에 그린다.
245
+ *
246
+ * readline 이 이 키를 처리하고 rl.line 을 고친 **뒤에** 그려야 하는데,
247
+ * keypress 는 그 전에 온다. 그래서 한 틱 미룬다. 안 미루면 늘 한 글자
248
+ * 뒤처진 글이 보인다 — 치는 사람 눈에는 마지막 글자가 안 찍히는 것으로
249
+ * 보이고, 그게 제일 못 미더운 화면이다.
250
+ */
251
+ if (!상자쓰나) return;
252
+ if (key && key.name === 'return') return; // 줄이 끝나는 것은 'line' 이 맡는다
253
+ if (묻는중 !== null) {
254
+ const 앞 = 묻는중;
255
+ setImmediate(() => {
256
+ if (묻는중 === null) return;
257
+ process.stdout.write(`\r\x1b[2K${앞}${c.white(rl.line ?? '')}`);
258
+ });
259
+ return;
260
+ }
261
+ if (!입력기다림) return;
262
+ /*
263
+ * 여러 키가 한꺼번에 들어와도 **한 번만** 그린다.
264
+ *
265
+ * 붙여넣기는 글자 수만큼 키가 쏟아진다. 스무 줄짜리를 붙이면 상자를
266
+ * 수백 번 다시 그리게 되고, 화면이 눈에 띄게 떨린다. 어차피 마지막
267
+ * 한 번이 지금 상태이므로, 이번 틱에 이미 잡아 뒀으면 그냥 넘긴다.
268
+ */
269
+ if (그릴예정) return;
270
+ 그릴예정 = true;
271
+ setImmediate(() => {
272
+ 그릴예정 = false;
273
+ if (입력기다림) 화면.입력갱신(session, rl.line ?? '', rl.cursor ?? 0);
274
+ });
204
275
  });
205
276
  }
206
277
 
@@ -211,10 +282,20 @@ export async function chatLoop(opts = {}) {
211
282
  };
212
283
 
213
284
  const ask = async (label, o = {}) => {
214
- 화면.붙임(` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`);
215
- const a = await nextLine();
216
- if (a === null) return o.def ?? '';
217
- return a.trim() || o.def || '';
285
+ const 앞 = ` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`;
286
+ 화면.붙임();
287
+ /*
288
+ * 되묻는 자리는 상자를 쓴다 — '실행할까요? (y/n)' 에 테두리를 두르면
289
+ * 대화의 흐름이 끊긴다. 대신 되비추는 일은 우리가 맡아야 한다.
290
+ * 상자 모드에서는 readline 의 되비추기를 꺼 놨기 때문이다. 안 해 주면
291
+ * y 를 쳐도 화면에 아무것도 안 나타난다 — 먹은 건지 안 먹은 건지 모른다.
292
+ */
293
+ 묻는중 = 상자쓰나 ? 앞 : null;
294
+ try {
295
+ const a = await nextLine();
296
+ if (a === null) return o.def ?? '';
297
+ return a.trim() || o.def || '';
298
+ } finally { 묻는중 = null; }
218
299
  };
219
300
 
220
301
  /**
@@ -357,7 +438,7 @@ export async function chatLoop(opts = {}) {
357
438
  say(` ${c.gray('/help 명령 목록')} ${c.gray('/think 추론 강도')} ${c.gray('Ctrl+C 중단·끝내기')}`);
358
439
 
359
440
  // 입력 자리. 어떻게 생겼는지는 화면 쪽이 정한다 —
360
- // 줄화면은 상태줄을 깔고 그 아래 ❯ 를, 전체화면은 아래 칸에 상자를 그린다.
441
+ // 줄화면은 상태줄을 깔고 그 아래 ❯ 를, 상자화면은 테두리를 두른 칸을 그린다.
361
442
  const prompt = () => 화면.입력자리(session);
362
443
 
363
444
  // Ctrl+C 는 상황에 따라 뜻이 다르다.
@@ -380,7 +461,9 @@ export async function chatLoop(opts = {}) {
380
461
 
381
462
  for (;;) {
382
463
  prompt();
464
+ 입력기다림 = true;
383
465
  const line = await nextLine();
466
+ 입력기다림 = false;
384
467
  if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
385
468
  interrupted = false;
386
469
  const text = line.trim();
@@ -495,7 +578,7 @@ export async function chatLoop(opts = {}) {
495
578
  * 같아서, 화면을 훑을 때 '모델이 뭐라고 했는지' 를 눈으로 못 찾았다.
496
579
  * 도구 이름·결과·바뀐 자리가 줄줄이 지나간 끝에 답이 섞여 있었다.
497
580
  *
498
- * 세로줄 하나면 된다. 전체화면 UI 갈 이유가 없다 —
581
+ * 세로줄 하나면 된다. 칸을 나눠 그리는 화면으로 갈 이유가 없다 —
499
582
  * 파이프로 넘기거나 기록으로 남길 때도 그대로 읽힌다.
500
583
  */
501
584
  case 'content':
@@ -534,12 +617,6 @@ export async function chatLoop(opts = {}) {
534
617
  const 글 = t.state === 'done' ? c.gray(t.text) : t.state === 'doing' ? c.white(t.text) : c.gray(t.text);
535
618
  say(` ${표} ${clip(글, 74)}`);
536
619
  }
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
620
  } else {
544
621
  say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
545
622
  // 파일을 고쳤으면 무엇이 바뀌었는지 바로 보여 준다.
@@ -552,11 +629,6 @@ export async function chatLoop(opts = {}) {
552
629
  // 엉뚱한 자리를 가리킨다 — 목록에 ../../.. 가 찍힌다.
553
630
  session.noteChange(ev.result.changed ?? ev.args?.file_path, ev.result.diff);
554
631
  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
632
  }
561
633
  }
562
634
  flush(); // 도구가 하나 끝날 때마다 적어 둔다
@@ -692,8 +764,8 @@ export async function chatLoop(opts = {}) {
692
764
  // 띄운 남의 프로세스는 반드시 거둔다. 안 거두면 deel 을 껐는데도
693
765
  // 그 서버가 계속 돌고 있게 된다 — 사람 눈에는 안 보이는 채로.
694
766
  for (const s of mcp붙임.서버들) s.닫기();
695
- // 끝맺음은 화면을 접기 **전에** 그린다. 전체화면은 close() 에서 터미널을
696
- // 원래대로 되돌리는데, 그 뒤에 찍으면 되돌아간 화면에 뜬금없이 한 줄이 남는다.
767
+ // 끝맺음은 화면을 접기 **전에** 그린다. close() 상자를 걷어내므로,
768
+ // 그 뒤에 찍으면 걷어낸 자리에 뜬금없이 한 줄이 남는다.
697
769
  say('');
698
770
  say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
699
771
  say('');