deel-local-cli 1.4.3 → 1.5.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 CHANGED
@@ -18,7 +18,7 @@ Zero dependencies · Node 20+ · Exactly one place your source can go
18
18
 
19
19
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
20
20
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
21
- [![tests](https://img.shields.io/badge/tests-3%2C498%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
21
+ [![tests](https://img.shields.io/badge/tests-3%2C614%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
22
22
 
23
23
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
24
24
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -100,7 +100,7 @@ This page is the **summary**. Each section links to the detail behind it.
100
100
  | [Speed and spend](docs/en/tuning.md) | Per-stage effort · the prefix cache · context length |
101
101
  | [Safety and corporate review](docs/en/safety.md) | Undo · working scope · audit log · the review package |
102
102
  | [Configuration](docs/en/config.md) · [Development](docs/en/develop.md) | Env vars · run flags · running the tests · folder layout |
103
- | [Release notes](docs/en/releases.md) | [1.4.3](docs/en/releases.md#143) · [1.4.2](docs/en/releases.md#142) · [1.4.1](docs/en/releases.md#141) · [1.4.0](docs/en/releases.md#140) · [1.3.0](docs/en/releases.md#130) · [1.2.0](docs/en/releases.md#120) |
103
+ | [Release notes](docs/en/releases.md) | [1.5.2](docs/en/releases.md#152) · [1.5.1](docs/en/releases.md#151) · [1.5.0](docs/en/releases.md#150) · [1.4.3](docs/en/releases.md#143) · [1.4.2](docs/en/releases.md#142) · [1.4.1](docs/en/releases.md#141) · [1.4.0](docs/en/releases.md#140) · [1.3.0](docs/en/releases.md#130) · [1.2.0](docs/en/releases.md#120) |
104
104
 
105
105
  ---
106
106
 
@@ -980,7 +980,10 @@ so one run tells you everything.
980
980
 
981
981
  | Version | What changed |
982
982
  |---|---|
983
- | **[1.4.3](docs/en/releases.md#143)** | The README explains what's different, and the review report gets its missing line |
983
+ | **[1.5.2](docs/en/releases.md#152)** | Everything else the adversarial reviews turned up where the screen was lying |
984
+ | [1.5.1](docs/en/releases.md#151) | Two of the things 1.5.0 added shipped broken. This fixes them |
985
+ | [1.5.0](docs/en/releases.md#150) | Line breaks exist now, the screen got fun, and it came out lighter than before |
986
+ | [1.4.3](docs/en/releases.md#143) | The README explains what's different, and the review report gets its missing line |
984
987
  | [1.4.2](docs/en/releases.md#142) | 1.4.1 shipped before its own security fixes — this corrects that |
985
988
  | [1.4.1](docs/en/releases.md#141) | No new features, only what was actually found and fixed — Windows abort, ReDoS, XSS |
986
989
  | [1.4.0](docs/en/releases.md#140) | deel gets a face, speaks English, and sees meaning — eleven places |
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
20
20
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
21
- [![tests](https://img.shields.io/badge/tests-3%2C498%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
21
+ [![tests](https://img.shields.io/badge/tests-3%2C614%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
22
22
 
23
23
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
24
24
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -101,7 +101,7 @@
101
101
  | [속도와 씀씀이](docs/ko/tuning.md) | 단계별 추론 강도 · 프리픽스 캐시 · 컨텍스트 길이 |
102
102
  | [안전망과 사내 반입](docs/ko/safety.md) | 되돌리기 · 작업 범위 · 감사기록 · 심사 서류 |
103
103
  | [설정](docs/ko/config.md) · [개발](docs/ko/develop.md) | 환경변수 · 실행 옵션 · 검사 돌리기 · 폴더 구조 |
104
- | [릴리스 노트](docs/ko/releases.md) | [1.4.3](docs/ko/releases.md#143) · [1.4.2](docs/ko/releases.md#142) · [1.4.1](docs/ko/releases.md#141) · [1.4.0](docs/ko/releases.md#140) · [1.3.0](docs/ko/releases.md#130) · [1.2.0](docs/ko/releases.md#120) |
104
+ | [릴리스 노트](docs/ko/releases.md) | [1.5.2](docs/ko/releases.md#152) · [1.5.1](docs/ko/releases.md#151) · [1.5.0](docs/ko/releases.md#150) · [1.4.3](docs/ko/releases.md#143) · [1.4.2](docs/ko/releases.md#142) · [1.4.1](docs/ko/releases.md#141) · [1.4.0](docs/ko/releases.md#140) · [1.3.0](docs/ko/releases.md#130) · [1.2.0](docs/ko/releases.md#120) |
105
105
 
106
106
  ---
107
107
 
@@ -971,7 +971,10 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
971
971
 
972
972
  | 판 | 무엇이 바뀌었나 |
973
973
  |---|---|
974
- | **[1.4.3](docs/ko/releases.md#143)** | README 다른지 말하고, 심사서의 빠진 줄을 채운다 |
974
+ | **[1.5.2](docs/ko/releases.md#152)** | 적대적 리뷰가 남긴 나머지를 전부 정리했다 화면이 거짓말하던 자리들 |
975
+ | [1.5.1](docs/ko/releases.md#151) | 1.5.0 이 넣은 것 두 개가 망가진 채 나갔다. 그것을 고친다 |
976
+ | [1.5.0](docs/ko/releases.md#150) | 줄바꿈이 생겼고, 화면이 재미있어졌고, 그러면서 오히려 가벼워졌다 |
977
+ | [1.4.3](docs/ko/releases.md#143) | README 가 왜 다른지 말하고, 심사서의 빠진 한 줄을 채운다 |
975
978
  | [1.4.2](docs/ko/releases.md#142) | 1.4.1 이 보안 수정 전에 나갔다 — 그걸 바로잡는 판 |
976
979
  | [1.4.1](docs/ko/releases.md#141) | 새 기능 없이, 실제로 찾은 것만 고친다 — 윈도우 abort·ReDoS·XSS |
977
980
  | [1.4.0](docs/ko/releases.md#140) | deel 이 제 얼굴을 갖고, 영어로도 말하고, 뜻까지 본다 — 열한 자리 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.4.3",
3
+ "version": "1.5.2",
4
4
  "description": "로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI — 외부 의존성 0개 / Zero-dependency coding agent CLI for local LLMs and private gateways",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,7 +51,7 @@
51
51
  "verify": "node test/no-bundle.test.js && node test/network.test.js && node test/web.test.js",
52
52
  "bench": "node test/edit-bench.js",
53
53
  "demo": "node test/demo.js",
54
- "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/oneshot.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/agent/effort.js && node --check src/agent/modes.js && node --check src/agent/route.js && node --check src/agent/models.js && node --check src/agent/grade.js && node --check src/agent/mention.js && node --check src/agent/project.js && node --check src/tools/encoding.js && node --check src/tools/excel-com.js && node --check src/tools/excel.js && node --check src/tools/xlsx.js && node --check src/tools/docs.js && node --check src/agent/compact.js && node --check src/agent/store.js && node --check src/agent/sessionui.js && node --check src/backend/adapter.js && node --check src/backend/ctxsize.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/backend/scan.js && node --check src/backend/scanui.js && node --check src/tools/index.js && node --check src/tools/desc.en.js && node --check src/tools/lsp.js && node --check src/lsp/rpc.js && node --check src/lsp/servers.js && node --check src/lsp/client.js && node --check src/lsp/diag.js && node --check src/tools/fsutil.js && node --check src/tools/edit-match.js && node --check src/tools/webfetch.js && node --check src/tools/todo.js && node --check src/tools/jobs.js && node --check src/skills/discover.js && node --check src/plugins/manage.js && node --check src/pack/zip.js && node --check src/pack/tar.js && node --check src/pack/selfpack.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/safety/network.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/ui/diff.js && node --check src/ui/ansi.js && node --check src/ui/status.js && node --check src/ui/level.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && node --check src/ui/motion.js && node --check src/ui/notify.js && node --check src/ui/intro.js && node --check src/ui/banner.js && node --check src/ui/export.js && node --check src/i18n/index.js && node --check src/i18n/ko.js && node --check src/i18n/en.js && node --check src/preview/serve.js && node --check src/ui/working.js && node --check src/ui/inputbox.js && node --check src/ui/md.js && node --check src/agent/threads.js && node --check src/agent/evolve.js && node --check src/agent/pins.js && node --check src/agent/card.js && node --check src/agent/preset.js && node --check src/agent/evidence.js && node --check src/acp/jsonrpc.js && node --check src/acp/map.js && node --check src/acp/serve.js && node --check src/pack/sbom.js && node --check src/safety/secrets.js && echo OK",
54
+ "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/oneshot.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/agent/effort.js && node --check src/agent/modes.js && node --check src/agent/route.js && node --check src/agent/models.js && node --check src/agent/grade.js && node --check src/agent/mention.js && node --check src/agent/project.js && node --check src/tools/encoding.js && node --check src/tools/excel-com.js && node --check src/tools/excel.js && node --check src/tools/xlsx.js && node --check src/tools/docs.js && node --check src/agent/compact.js && node --check src/agent/store.js && node --check src/agent/sessionui.js && node --check src/backend/adapter.js && node --check src/backend/ctxsize.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/backend/scan.js && node --check src/backend/scanui.js && node --check src/tools/index.js && node --check src/tools/desc.en.js && node --check src/tools/lsp.js && node --check src/lsp/rpc.js && node --check src/lsp/servers.js && node --check src/lsp/client.js && node --check src/lsp/diag.js && node --check src/tools/fsutil.js && node --check src/tools/edit-match.js && node --check src/tools/webfetch.js && node --check src/tools/todo.js && node --check src/tools/jobs.js && node --check src/skills/discover.js && node --check src/plugins/manage.js && node --check src/pack/zip.js && node --check src/pack/tar.js && node --check src/pack/selfpack.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/safety/network.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/ui/diff.js && node --check src/ui/ansi.js && node --check src/ui/status.js && node --check src/ui/level.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && node --check src/ui/motion.js && node --check src/ui/office.js && node --check src/ui/notify.js && node --check src/ui/intro.js && node --check src/ui/banner.js && node --check src/ui/export.js && node --check src/i18n/index.js && node --check src/i18n/ko.js && node --check src/i18n/en.js && node --check src/preview/serve.js && node --check src/ui/working.js && node --check src/ui/inputbox.js && node --check src/ui/wrap.js && node --check src/ui/md.js && node --check src/agent/threads.js && node --check src/agent/evolve.js && node --check src/agent/pins.js && node --check src/agent/card.js && node --check src/agent/preset.js && node --check src/agent/evidence.js && node --check src/acp/jsonrpc.js && node --check src/acp/map.js && node --check src/acp/serve.js && node --check src/pack/sbom.js && node --check src/safety/secrets.js && echo OK",
55
55
  "coverage": "node test/coverage.mjs",
56
56
  "prepublishOnly": "npm run check && npm test"
57
57
  },
package/src/i18n/en.js CHANGED
@@ -129,6 +129,7 @@ export const en = {
129
129
  'head.shiftTab': 'Shift+Tab',
130
130
  'head.shiftTabHint': ' changes it · ',
131
131
  'head.tabHint': 'Tab completes the / command you are typing',
132
+ 'head.newlineHint': 'New line: Alt+Enter (if your terminal sends it), or end a line with space + ` then Enter',
132
133
  'head.thisPC': 'found',
133
134
  'head.skills': 'skills {n}',
134
135
  'head.commands': 'commands {n}',
package/src/i18n/ko.js CHANGED
@@ -116,6 +116,7 @@ export const ko = {
116
116
  'head.shiftTab': 'Shift+Tab',
117
117
  'head.shiftTabHint': '으로 바꿉니다 · ',
118
118
  'head.tabHint': 'Tab 은 치던 / 명령을 채웁니다',
119
+ 'head.newlineHint': '줄바꿈: Alt+Enter(터미널이 지원하면) 또는 줄 끝에 빈칸+` 를 붙이고 Enter',
119
120
  'head.thisPC': '이 PC',
120
121
  'head.skills': '스킬 {n}',
121
122
  'head.commands': '명령 {n}',
package/src/repl.js CHANGED
@@ -121,6 +121,23 @@ const DIFF_LINES = { 쉬움: 14, 개발자: 40 };
121
121
  // 모델의 말 앞에 세우는 세로줄. 도구 줄과 눈으로 갈리게 하는 유일한 표시다.
122
122
  const 답표시 = c.hcyan('▌');
123
123
 
124
+ /**
125
+ * 줄 끝 백틱이 "이어쓰기" 표시인가.
126
+ *
127
+ * 예전에는 백틱 하나로 끝나기만 하면 표시로 봤다. 그런데 코딩 도구에서
128
+ * "read `config.json`" · "echo `date`" 처럼 **인라인 코드로 끝나는 줄**은
129
+ * 아주 흔하다. 그 줄들이 전부 안 보내지고, 닫는 백틱까지 뜯긴 채 이어쓰기
130
+ * 모드에 갇혔다 — 사람 눈에는 Enter 가 그냥 안 먹는 것으로 보인다.
131
+ *
132
+ * 그래서 조건을 둘로 좁혔다. 백틱 앞이 **빈칸**이고(또는 백틱만 있는 줄이고),
133
+ * 그 줄에 백틱이 **그것 하나뿐**일 때만 표시로 본다. 인라인 코드는 백틱이
134
+ * 둘이라 안 걸리고, ``` 울타리는 앞이 백틱이라 안 걸린다.
135
+ *
136
+ * 검사에서 표를 만들어 재려고 밖으로 뺐다.
137
+ */
138
+ export const 이어쓰기표시 = (l) =>
139
+ /(^|\s)`$/.test(l) && (String(l).match(/`/g) ?? []).length === 1;
140
+
124
141
  export async function chatLoop(opts = {}) {
125
142
  const cfg = load();
126
143
  const prof = activeProfile(cfg);
@@ -322,17 +339,54 @@ export async function chatLoop(opts = {}) {
322
339
  let closed = false;
323
340
  const echo = !process.stdin.isTTY; // 파이프·기록용일 때는 입력을 되비춘다
324
341
 
342
+ /*
343
+ * 줄 끝에 ` (백틱) 을 붙이면 "아직 안 끝났다" 는 뜻이다 — 여러 줄을 한
344
+ * 메시지로 묶어 보낼 때 쓴다. 확정된 줄은 여기 쌓아 두다가, ` 없이 끝나는
345
+ * 줄이 오면 그때 \n 으로 이어붙여 한 메시지로 내보낸다.
346
+ *
347
+ * 실제 개행을 상자(rl.line) 안에 넣어서는 **안 된다**. 화면은 멀쩡히
348
+ * 그려지지만, Enter 로 보낼 때 readline 의 history 가 그 줄을 \n 에서
349
+ * 쪼개 거꾸로 쌓고 \r 로 다시 붙여 돌려준다. "안녕\n반가워" 를 보내면
350
+ * 'line' 이벤트에 오는 값은 "반가워\r안녕" 이다 — 사람이 쓴 순서가
351
+ * 뒤집혀서 모델에게 간다. historySize 를 0 으로 두면 안 그러니 history 가
352
+ * 하는 짓이 맞다. 그래서 개행은 rl.line 밖, 여기서만 다룬다.
353
+ *
354
+ * 백슬래시(\) 가 아니라 백틱을 고른 것은 일부러다 — 이 프로그램은 윈도우
355
+ * 경로(`C:\Users\...`)를 늘 다루는데, 그 끝은 흔히 백슬래시로 끝난다.
356
+ * 백슬래시를 표로 쓰면 평범한 경로를 붙여넣기만 해도 계속 이어 치는 것으로
357
+ * 잘못 읽힌다. 백틱은 그럴 일이 없다.
358
+ */
359
+ let 이어쓰기줄들 = null;
360
+
361
+ /** 줄을 쌓아 두고 "다음 줄" 이라고 알린다. 백틱 표시와 Alt+Enter 가 같이 쓴다. */
362
+ const 줄쌓기 = (줄) => {
363
+ const 첫줄 = 이어쓰기줄들 === null;
364
+ (이어쓰기줄들 ??= []).push(줄);
365
+ 화면.입력지움();
366
+ say(`${첫줄 ? ` ${c.hcyan('❯')} ` : ' '}${c.white(줄)}`);
367
+ say(` ${c.gray('… 다음 줄. 그냥 Enter 로 보냅니다 · Ctrl+C 로 취소')}`);
368
+ };
369
+
325
370
  rl.on('line', (l) => {
326
371
  if (echo) say(c.gray(l));
372
+ let 보낼것 = l;
327
373
  if (상자쓰나) {
328
374
  if (묻는중 !== null) {
329
375
  // 되묻는 자리: 답을 그 줄에 남긴 채 줄만 넘긴다.
330
376
  process.stdout.write(`\r\x1b[2K${묻는중}${c.white(l)}\n`);
331
377
  } else if (입력기다림) {
378
+ // 표시 자체와 그 앞 빈칸은 사람이 친 글이 아니라 문법이다. 같이 뗀다.
379
+ if (이어쓰기표시(l)) {
380
+ 줄쌓기(l.slice(0, -1).replace(/\s+$/, ''));
381
+ return;
382
+ }
332
383
  // 상자를 걷어내고, 사람이 보낸 글을 대화에 남긴다. 안 남기면 스크롤을
333
384
  // 올렸을 때 답만 있고 무엇을 물었는지가 없다.
385
+ const 이었나 = 이어쓰기줄들 !== null;
386
+ 보낼것 = 이었나 ? [...이어쓰기줄들, l].join('\n') : l;
387
+ 이어쓰기줄들 = null;
334
388
  화면.입력지움();
335
- if (l.trim()) say(` ${c.hcyan('❯')} ${c.white(l)}`);
389
+ if (l.trim() || 이었나) say(`${이었나 ? ' ' : ` ${c.hcyan('❯')} `}${c.white(l)}`);
336
390
  } else if (l.trim()) {
337
391
  /*
338
392
  * 일하는 도중에 미리 쳐 둔 것.
@@ -344,8 +398,8 @@ export async function chatLoop(opts = {}) {
344
398
  화면.대기갱신('', queue.length + 1);
345
399
  }
346
400
  }
347
- if (waiter) { const w = waiter; waiter = null; w(l); }
348
- else queue.push(l);
401
+ if (waiter) { const w = waiter; waiter = null; w(보낼것); }
402
+ else queue.push(보낼것);
349
403
  });
350
404
  rl.on('close', () => {
351
405
  closed = true;
@@ -432,7 +486,36 @@ export async function chatLoop(opts = {}) {
432
486
  * 보이고, 그게 제일 못 미더운 화면이다.
433
487
  */
434
488
  if (!상자쓰나) return;
435
- if (key && key.name === 'return') return; // 줄이 끝나는 것은 'line' 이 맡는다
489
+ if (key && key.name === 'return') {
490
+ /*
491
+ * Alt+Enter(맥은 Option+Enter) 만 여기서 잡을 수 있다 — 나머지는 못 잡는다.
492
+ *
493
+ * 평범한 Enter 도, 대부분 터미널에서의 Shift+Enter·Ctrl+Enter 도 여기
494
+ * 닿기 전에 이미 끝나 있다. readline 도 우리와 같은 keypress 를 듣고
495
+ * 있는데(이 리스너보다 먼저 등록됐다), 터미널이 그 조합들을 평범한
496
+ * Enter 와 **똑같은 바이트**(\r, 드물게 \n)로 보내는 한 readline 이
497
+ * 우리보다 먼저 줄을 끝내 버린다 — 그 뒤에 우리가 뭘 해도 늦다.
498
+ * (readline 코드를 흉내 내서 직접 확인했다: \r·\n 은 keypress 가
499
+ * 이 리스너에 닿기도 전에 'line' 이벤트부터 쏜다.)
500
+ *
501
+ * ESC 로 시작하는 조합(Alt/Option+Enter)은 다르다 — readline 이 ESC
502
+ * 뒤에 더 올 것을 기다리는 동안 우리 keypress 가 먼저 온다. 그래서
503
+ * key.meta 로 여기서 골라낼 수 있다. rl.write('\n') 은 못 쓴다 —
504
+ * 그것도 내부적으로 줄을 끝내는 처리를 그대로 타 버린다(직접 확인).
505
+ *
506
+ * 커서 앞은 확정해서 쌓고, 뒤는 상자에 남긴다. rl.line 에 \n 을
507
+ * 넣어 이어붙이지 **않는다** — 그러면 화면은 맞는데 보낼 때
508
+ * history 가 줄 순서를 뒤집는다(이어쓰기줄들 선언부 참고).
509
+ */
510
+ if (key.meta && 입력기다림 && 묻는중 === null) {
511
+ const 뒤 = (rl.line ?? '').slice(rl.cursor ?? 0);
512
+ 줄쌓기((rl.line ?? '').slice(0, rl.cursor ?? 0));
513
+ rl.line = 뒤;
514
+ rl.cursor = 0;
515
+ } else {
516
+ return; // 줄이 끝나는 것은 'line' 이 맡는다
517
+ }
518
+ }
436
519
  if (묻는중 !== null) {
437
520
  const 앞 = 묻는중;
438
521
  setImmediate(() => {
@@ -488,6 +571,7 @@ export async function chatLoop(opts = {}) {
488
571
  * y 를 쳐도 화면에 아무것도 안 나타난다 — 먹은 건지 안 먹은 건지 모른다.
489
572
  */
490
573
  묻는중 = 상자쓰나 ? 앞 : null;
574
+ 이어쓰기줄들 = null; // 되묻는 사이에 걸쳐 있던 미완성 이어쓰기는 버린다
491
575
  try {
492
576
  const a = await nextLine();
493
577
  if (a === null) return o.def ?? '';
@@ -679,7 +763,7 @@ export async function chatLoop(opts = {}) {
679
763
  }
680
764
 
681
765
  // ── 머리말 ────────────────────────────────────────────────────────────
682
- 화면.머리말(headerLines(session, found));
766
+ 화면.머리말(headerLines(session, found, 상자쓰나));
683
767
  const warn = [];
684
768
  // 홈 폴더에서 켠 경우. 작업 범위가 집 전체가 된다.
685
769
  //
@@ -739,6 +823,17 @@ export async function chatLoop(opts = {}) {
739
823
  let interrupted = false;
740
824
  let turn = null; // 지금 도는 턴의 AbortController
741
825
  rl.on('SIGINT', () => {
826
+ /*
827
+ * 쌓아 둔 줄이 있으면 먼저 버린다. 이게 없으면 이어쓰기를 **취소할 길이
828
+ * 없다** — 표를 하나 붙여 놓고 마음이 바뀌어도, 다음에 치는 것이 무엇이든
829
+ * 앞줄에 붙어서 나간다. `/help` 같은 슬래시 명령까지 앞줄에 붙는 순간
830
+ * 더는 '/' 로 시작하지 않아 명령으로 안 읽히고 모델에게 넘어간다.
831
+ */
832
+ if (이어쓰기줄들 !== null) {
833
+ 이어쓰기줄들 = null;
834
+ say('');
835
+ say(` ${c.gray('이어쓰던 것을 버렸습니다.')}`);
836
+ }
742
837
  if (turn && !turn.signal.aborted) {
743
838
  turn.abort();
744
839
  return; // 화면 정리는 루프 쪽 'aborted' 이벤트가 한다
@@ -759,6 +854,8 @@ export async function chatLoop(opts = {}) {
759
854
  * 코드가 안 하는 것을 화면이 약속하고 있었던 셈이다.
760
855
  */
761
856
  let 이어갈것 = null; // 이어서 보낼 말 (null 이면 사람 입력을 기다린다)
857
+ // 지금 도는 하위 작업 수. 사무실이 자리를 이만큼 채운다.
858
+ let 도는하위 = 0;
762
859
  let 이어갈모드 = null; // 그때 쓸 작업 모드. 다시 고르지 않는다.
763
860
 
764
861
  /*
@@ -1031,6 +1128,9 @@ export async function chatLoop(opts = {}) {
1031
1128
  // 같이 돈 것은 이름을 다시 적어 준다. 안 그러면 어느 결과인지 모른다.
1032
1129
  if (ev.parallel || ev.showLabel) say(` ${toolLabel(ev.name, ev.args)}`);
1033
1130
  if (ev.name === 'TodoWrite' && ev.result?.todos) {
1131
+ // 사무실 화이트보드도 이걸 본다. 할 일은 세션이 아니라 턴 문맥에
1132
+ // 있어서 상자가 스스로 못 읽는다 — 아는 자리에서 넣어 준다.
1133
+ 화면.할일갱신(ev.result.todos);
1034
1134
  for (const t of ev.result.todos) {
1035
1135
  const 표 = t.state === 'done' ? c.green('☑') : t.state === 'doing' ? c.hyellow('▶') : c.gray('☐');
1036
1136
  const 글 = t.state === 'done' ? c.gray(t.text) : t.state === 'doing' ? c.white(t.text) : c.gray(t.text);
@@ -1129,6 +1229,9 @@ export async function chatLoop(opts = {}) {
1129
1229
  clearThinking();
1130
1230
  if (streamed) { 답비우기(); say(''); streamed = false; }
1131
1231
  화면.일바꿈('하위', clip(ev.목적, 24));
1232
+ // 사무실은 도는 하위 작업 수만큼 자리를 채운다.
1233
+ 도는하위 += 1;
1234
+ 화면.하위갱신(도는하위);
1132
1235
  say('');
1133
1236
  say(` ${c.hmagenta('⌥')} ${c.bold('하위 작업')} ${c.white(clip(ev.목적, 60))}`
1134
1237
  + ` ${c.gray(`· ${보일이름(ev.모드)} · 최대 ${ev.steps}걸음`)}`);
@@ -1150,6 +1253,8 @@ export async function chatLoop(opts = {}) {
1150
1253
  case 'task_done': {
1151
1254
  clearThinking();
1152
1255
  if (streamed) { 답비우기(); say(''); streamed = false; }
1256
+ 도는하위 = Math.max(0, 도는하위 - 1);
1257
+ 화면.하위갱신(도는하위);
1153
1258
  const 끝 = ev.끝 ?? {};
1154
1259
  const 잘됨 = 끝.type === 'done';
1155
1260
  const 왜 = { done: '끝냈습니다', limit: '걸음 수를 다 써서 멈췄습니다 — 다 못 했습니다',
@@ -37,6 +37,9 @@ import { c, width, cursor, clip } from './ansi.js';
37
37
  import { isLocalHost } from '../safety/network.js';
38
38
  import { statusLine, contextWarning } from './status.js';
39
39
  import { 접어쓰기 } from './wrap.js';
40
+ import {
41
+ 켜나 as 사무실켜나, 사무실줄들, 상태만들기 as 사무실상태,
42
+ } from './office.js';
40
43
  import { 문구고르기, 걸린시간, 느긋해질때, 문구주기 } from './working.js';
41
44
  import { 그림고르기 } from './motion.js';
42
45
  import { 최대추천 } from './complete.js';
@@ -56,6 +59,14 @@ const 줄끝지움 = '\x1b[K';
56
59
  const 위로 = (n) => (n > 0 ? `\x1b[${n}A` : '');
57
60
  // 돌아가는 표시를 한 칸 넘기는 주기. 사람 눈에 부드럽고, 다시 그리는 값은 싸다.
58
61
  const 돌림주기 = 90;
62
+ /*
63
+ * 견주기를 접고 통째로 덮어써서 화면을 되살리는 주기(ms).
64
+ *
65
+ * 판 수가 아니라 **시계**로 센다. 일이 도는 동안에는 90ms 마다 그리니 판
66
+ * 수로 세도 같지만, 놀고 있을 때는 키를 칠 때만 그린다 — 그때 판으로 세면
67
+ * '45타 뒤' 가 되고, 가만히 두면 깨진 화면이 영영 안 낫는다.
68
+ */
69
+ const 자가치유주기 = 4000;
59
70
 
60
71
  // 상자 안쪽 왼쪽에 붙는 것: ' ' + '│' + ' ' + '❯' + ' ' → 5칸
61
72
  const 앞머리 = 5;
@@ -76,7 +87,7 @@ export const 안쪽최대 = 8;
76
87
  * @returns {{줄들: string[], 커서: {위: number, 열: number}}}
77
88
  * 커서.위 = 마지막 줄에서 몇 줄 위인가, 커서.열 = 1부터 세는 칸
78
89
  */
79
- export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상태 = '', 경고 = '', 곁말 = '', 일감 = null, 추천 = [], 바깥 = false } = {}) {
90
+ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상태 = '', 경고 = '', 곁말 = '', 일감 = null, 추천 = [], 바깥 = false, 사무실 = null } = {}) {
80
91
  const 칸 = Math.max(20, 폭);
81
92
  /*
82
93
  * ── 테두리가 곧 경계선이다 ───────────────────────────────────────────
@@ -96,6 +107,16 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
96
107
  const 안쪽 = Math.max(4, 칸 - 앞머리 - 2); // 오른쪽 ' │' 두 칸
97
108
  const 줄들 = [];
98
109
 
110
+ /*
111
+ * 사무실은 **상태줄 위**에 얹힌다.
112
+ *
113
+ * 여기 넣는 것이 중요하다. 상자가 이미 '아래 몇 줄을 붙박아 두고 그 자리에서
114
+ * 다시 그리기' 를 하고 있으므로, 사무실은 그 붙박이 자리가 몇 줄 길어지는
115
+ * 것일 뿐이다. 새 화면 방식을 안 들이는 것 — 예전 전체화면(tui.js)이 대화
116
+ * 흐름과 다투다 죽었다.
117
+ */
118
+ if (사무실?.length) 줄들.push(...사무실);
119
+
99
120
  if (상태) 줄들.push(상태);
100
121
  if (경고) 줄들.push(` ${경고}`);
101
122
 
@@ -240,6 +261,22 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
240
261
  export class InputBox {
241
262
  constructor() {
242
263
  this.그린줄 = 0; // 지금 화면에 붙어 있는 상자의 줄 수
264
+
265
+ /*
266
+ * 마지막으로 그릴 때의 터미널 크기.
267
+ *
268
+ * 폭이 바뀌는 순간 터미널은 **이미** 제 손으로 줄을 다시 접는다. 그런데
269
+ * 그린줄·커서위·앞줄들 은 옛 폭에서 센 값이라, 그 뒤로 커서를 올리는 수가
270
+ * 전부 틀어진다 — 틀어진 자리에 다음 판을 덮어쓰니 대화가 지워진다.
271
+ * 줄 수가 우연히 달라질 때까지 아무도 이걸 안 고쳐 줬다.
272
+ *
273
+ * 'resize' 이벤트를 듣는 길도 있는데 안 쓴다. 상자마다 리스너를 하나씩
274
+ * 걸면 걷을 자리가 필요하고(안 걷으면 EventEmitter 경고가 뜬다), 이벤트가
275
+ * 안 오는 환경도 있다. 어차피 크기가 틀어져서 손해를 보는 순간은 **다음에
276
+ * 그릴 때**이므로, 그 자리에서 견주면 그것으로 충분하다.
277
+ */
278
+ this.그린폭 = null;
279
+ this.그린높이 = null;
243
280
  /*
244
281
  * 커서를 상자 맨 아랫줄에서 몇 줄 위에 놓아 뒀나.
245
282
  *
@@ -278,6 +315,56 @@ export class InputBox {
278
315
  * 그래서 규칙을 하나로 못 박는다 — **줄 중간에서는 아무것도 안 그린다.**
279
316
  */
280
317
  this.줄중간 = false;
318
+
319
+ /*
320
+ * 지난번에 화면에 찍어 둔 줄들. **안 바뀐 줄을 안 보내려고** 들고 있다.
321
+ *
322
+ * 상자는 90ms 마다 다시 그려지는데, 그때마다 열두 줄을 통째로 보내면
323
+ * 대부분이 지난번과 똑같은 글자다. 재 보니 한 판에 실제로 바뀌는 것은
324
+ * 한 줄뿐이었다 — 나머지 열한 줄은 같은 것을 다시 보내는 셈이다.
325
+ * 줄마다 견줘서 바뀐 것만 보내면 터미널로 나가는 양이 91% 줄어든다.
326
+ *
327
+ * null 이면 견줄 것이 없다는 뜻이다(막 지웠거나 줄 수가 달라졌거나).
328
+ */
329
+ this.앞줄들 = null;
330
+ /*
331
+ * 몇 판째 견주기로만 그렸나.
332
+ *
333
+ * 견주기는 '화면이 내가 아는 그대로다' 를 믿는다. 그런데 이 저장소에는
334
+ * 화면 객체를 안 거치고 stdout 에 바로 쓰는 자리가 몇 군데 있다(그 때문에
335
+ * 예전 전체화면이 죽었다). 그런 글이 상자를 덮으면 견주기는 그 줄을
336
+ * '안 바뀌었다' 며 안 고치고, 깨진 화면이 그대로 남는다.
337
+ *
338
+ * 통째로 그리던 예전 방식은 매번 덮어써서 저절로 나았다. 그 자가 치유를
339
+ * 잃지 않으려고, 가끔 한 번은 통째로 그린다.
340
+ *
341
+ * 처음에는 '판 45번마다' 로 셌다. 90ms 마다 도는 동안에는 그것이 4초였지만,
342
+ * **일이 안 도는 동안에는 그리기가 키를 칠 때만 일어난다.** 그래서 그때는
343
+ * 4초가 아니라 '45타' 가 됐다 — 놀고 있을 때 화면이 깨지면 마흔다섯 번을
344
+ * 더 쳐야 나았고, 가만히 두면 영영 안 나았다. 시계로 세야 맞다.
345
+ */
346
+ this.마지막통째로 = 0;
347
+
348
+ /*
349
+ * 사무실 화이트보드에 붙일 할 일.
350
+ *
351
+ * 할 일은 세션이 아니라 턴 문맥(ctx.todos)에 있어서 상자가 스스로 못
352
+ * 읽는다. 그래서 아는 쪽에서 넣어 준다(할일갱신). 안 넣어 주면 빈 보드로
353
+ * 둔다 — **모르는 것을 그럴듯하게 채우지 않는다.**
354
+ */
355
+ this.할일들 = [];
356
+ // 지금 도는 하위 작업 수 (사무실 자리 수). 모르면 0 이다.
357
+ this.도는하위 = 0;
358
+ }
359
+
360
+ /** 할 일이 바뀌었다. 사무실 화이트보드가 이걸 본다. */
361
+ 할일갱신(할일들) {
362
+ this.할일들 = Array.isArray(할일들) ? 할일들 : [];
363
+ }
364
+
365
+ /** 도는 하위 작업 수. 사무실이 자리를 이만큼 채운다. */
366
+ 하위갱신(n) {
367
+ this.도는하위 = Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
281
368
  }
282
369
 
283
370
  /** 대화 글이 줄 중간까지 쓰였다. 줄이 끝날 때까지 상자를 안 세운다. */
@@ -374,6 +461,8 @@ export class InputBox {
374
461
  process.stdout.write(out);
375
462
  this.그린줄 = 0;
376
463
  this.커서위 = 0;
464
+ // 지운 자리를 '지난번에 찍어 둔 것' 으로 믿으면 안 된다.
465
+ this.앞줄들 = null;
377
466
  }
378
467
 
379
468
  /**
@@ -398,12 +487,53 @@ export class InputBox {
398
487
  if (this.줄중간) {
399
488
  process.stdout.write('\n');
400
489
  this.줄중간 = false;
490
+ // 화면이 한 줄 밀렸다 — 지난번 줄들은 이제 견줄 것이 못 된다.
491
+ this.앞줄들 = null;
492
+ }
493
+
494
+ /*
495
+ * 사무실은 켠 사람에게만, 그리고 화면이 넉넉할 때만 그린다.
496
+ * 켜나() 가 그 판단을 다 한다 — 여기서는 켜졌으면 줄을 받아 얹을 뿐이다.
497
+ *
498
+ * 칸수를 **한 번만** 읽어서 둘 다에 쓴다. 따로 읽으면 켜나() 가 본 폭과
499
+ * 실제로 그린 폭이 다를 수 있고, 사무실이 터미널보다 한 칸이라도 넓어지는
500
+ * 순간 줄이 접힌다 — 상자는 접힌 줄을 못 세서 그만큼 어긋난 자리에 다음
501
+ * 판을 덮어쓴다. 화면이 통째로 지워지는 길이 거기서 열린다.
502
+ *
503
+ * 상자는 칸수를 몰라도 그려야 하므로 80 으로 버티지만, 사무실은 모르면
504
+ * 아예 안 켠다. 모르는 채로 켜는 쪽이 훨씬 큰 손해다.
505
+ */
506
+ const 칸수 = process.stdout.columns;
507
+ const 폭 = 칸수 ?? 80;
508
+
509
+ /*
510
+ * 지난번에 그린 크기와 다르면 들고 있던 것을 다 버린다.
511
+ *
512
+ * 터미널이 이미 제 손으로 줄을 다시 접어 놨으므로, 옛 폭에서 센 값으로
513
+ * 커서를 올리면 엉뚱한 줄에 덮어쓴다 — 대화가 지워지는 길이 그것이다.
514
+ *
515
+ * 그린줄을 0 으로 두면 위로 안 올라가고 지금 자리에서 새로 그린다. 창을
516
+ * 줄인 직후 한 번은 낡은 상자가 위에 남을 수 있는데, 그건 스크롤로 지나가는
517
+ * 자국일 뿐이다. **어디 있는지 모르는 줄을 짐작해서 덮어쓰는 것보다,
518
+ * 안 건드리고 새로 그리는 편이 낫다.**
519
+ */
520
+ const 높이 = process.stdout.rows ?? null;
521
+ if (this.그린폭 !== 폭 || this.그린높이 !== 높이) {
522
+ this.앞줄들 = null;
523
+ this.그린줄 = 0;
524
+ this.커서위 = 0;
525
+ this.그린폭 = 폭;
526
+ this.그린높이 = 높이;
401
527
  }
528
+ const 방 = 사무실켜나(process.stdout.rows, 칸수)
529
+ ? 사무실줄들(사무실상태(this.session, this.일감, this.틱, this.할일들, this.도는하위), 폭)
530
+ : null;
402
531
 
403
- const { 줄들, 커서: 자리 } = 프레임({
532
+ const 짜기 = (방줄들) => 프레임({
533
+ 사무실: 방줄들,
404
534
  글,
405
535
  커서: 커서자리,
406
- 폭: process.stdout.columns ?? 80,
536
+ 폭,
407
537
  상태: this.session ? statusLine(this.session) : '',
408
538
  경고: this.session ? (contextWarning(this.session) ?? '') : '',
409
539
  곁말: this.곁말,
@@ -414,6 +544,24 @@ export class InputBox {
414
544
  바깥: this.session ? 바깥으로가나(this.session) : false,
415
545
  });
416
546
 
547
+ /*
548
+ * ── 다 그려 놓고 나서, 화면에 안 들어가면 사무실을 뺀다 ───────────────
549
+ *
550
+ * 켜나() 의 28줄은 '사무실 12 + 상자 5' 로 잡은 값인데, 상자는 그것보다
551
+ * 훨씬 커질 수 있다. 긴 글이 여덟 줄로 접히고 추천 목록 여섯 줄에 '그 밖에
552
+ * N개 더' 와 컨텍스트 경고까지 붙으면 서른두 줄이다. 그러면 붙박이 자리가
553
+ * 화면보다 길어져서, 터미널이 위로 밀어 올린 만큼 커서 셈이 통째로 어긋난다.
554
+ *
555
+ * 관문 숫자를 34쯤으로 올리는 길도 있지만, 그러면 평소에 멀쩡히 쓰던
556
+ * 사람들까지 사무실을 잃는다. 최악의 경우는 드물게 잠깐 오는 것이므로,
557
+ * **그 판만** 사무실을 빼는 편이 낫다. 사무실은 매 판 다시 정하니까
558
+ * 자리가 나면 저절로 돌아온다.
559
+ */
560
+ let 짠것 = 짜기(방);
561
+ const 화면줄수 = process.stdout.rows ?? 0;
562
+ if (방 && 화면줄수 > 0 && 짠것.줄들.length + 1 > 화면줄수) 짠것 = 짜기(null);
563
+ const { 줄들, 커서: 자리 } = 짠것;
564
+
417
565
  /*
418
566
  * ── 지우지 않고 덮어쓴다. 그리고 한 번에 내보낸다 ────────────────────
419
567
  *
@@ -437,9 +585,33 @@ export class InputBox {
437
585
  const 윗줄까지 = 이전줄 ? Math.max(0, 이전줄 - 1 - this.커서위) : 0;
438
586
  const 남은줄 = Math.max(0, 이전줄 - 줄들.length);
439
587
 
588
+ /*
589
+ * 바뀐 줄만 보낸다.
590
+ *
591
+ * 줄 수가 그대로일 때만 견준다 — 줄 수가 달라졌으면 지울 줄까지 생겨서
592
+ * 셈이 얽힌다. 그때는 그냥 통째로 그린다. 어차피 드물다(글이 접히거나
593
+ * 추천 목록이 뜨고 지는 순간뿐이고, 그건 애니메이션이 아니다).
594
+ *
595
+ * 안 보낸 줄에서도 '\n' 은 보내야 한다 — 커서를 다음 줄로 내려야 하기
596
+ * 때문이다. 글자 없이 줄만 넘어간다.
597
+ */
598
+ const 지금때 = Date.now();
599
+ const 통째로 = this.앞줄들 === null
600
+ || this.앞줄들.length !== 줄들.length
601
+ || 남은줄 > 0
602
+ || 지금때 - this.마지막통째로 >= 자가치유주기;
603
+
440
604
  let 나갈것 = cursor.숨김 + '\r' + 위로(윗줄까지);
441
- 나갈것 += 줄들.map((l) => l + 줄끝지움).join('\n') + '\n';
442
- for (let i = 0; i < 남은줄; i++) 나갈것 += 줄끝지움 + '\n';
605
+ if (통째로) {
606
+ 나갈것 += 줄들.map((l) => l + 줄끝지움).join('\n') + '\n';
607
+ for (let i = 0; i < 남은줄; i++) 나갈것 += 줄끝지움 + '\n';
608
+ this.마지막통째로 = 지금때;
609
+ } else {
610
+ for (const [i, l] of 줄들.entries()) {
611
+ if (l !== this.앞줄들[i]) 나갈것 += l + 줄끝지움;
612
+ 나갈것 += '\n';
613
+ }
614
+ }
443
615
  /*
444
616
  * 방금 찍은 마지막 줄 **다음** 줄에 커서가 있다(남은 줄을 지웠으면 그만큼
445
617
  * 더 아래). 거기서 되짚어 올라간다.
@@ -456,5 +628,6 @@ export class InputBox {
456
628
 
457
629
  this.그린줄 = 줄들.length;
458
630
  this.커서위 = 자리.위;
631
+ this.앞줄들 = 줄들;
459
632
  }
460
633
  }