deel-local-cli 1.8.0 → 1.12.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.
Files changed (61) hide show
  1. package/README.ko.md +10 -25
  2. package/README.md +43 -51
  3. package/bin/deel.js +56 -13
  4. package/package.json +60 -60
  5. package/src/acp/serve.js +810 -732
  6. package/src/agent/budget.js +156 -153
  7. package/src/agent/compact.js +420 -296
  8. package/src/agent/effort.js +308 -177
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/evolve.js +263 -213
  11. package/src/agent/filemem.js +155 -0
  12. package/src/agent/grade.js +51 -1
  13. package/src/agent/loop.js +1707 -1349
  14. package/src/agent/memory.js +156 -152
  15. package/src/agent/mention.js +210 -210
  16. package/src/agent/session.js +1049 -832
  17. package/src/agent/store.js +186 -9
  18. package/src/agent/threads.js +26 -1
  19. package/src/backend/adapter.js +1159 -615
  20. package/src/backend/cachemark.js +150 -0
  21. package/src/backend/detect.js +327 -288
  22. package/src/backend/learn.js +24 -0
  23. package/src/backend/mcp.js +96 -5
  24. package/src/backend/probe.js +131 -61
  25. package/src/backend/quota.js +250 -133
  26. package/src/backend/retry.js +30 -6
  27. package/src/backend/tokens.js +37 -0
  28. package/src/backend/toolfit.js +352 -0
  29. package/src/backend/wire.js +600 -0
  30. package/src/commands.js +3135 -2918
  31. package/src/config.js +47 -2
  32. package/src/i18n/en.js +544 -466
  33. package/src/i18n/index.js +18 -0
  34. package/src/i18n/ja.js +497 -417
  35. package/src/i18n/ko.js +591 -466
  36. package/src/i18n/zh.js +497 -417
  37. package/src/lsp/client.js +49 -5
  38. package/src/oneshot.js +620 -471
  39. package/src/pack/sbom.js +30 -4
  40. package/src/pack/selfpack.js +25 -9
  41. package/src/pack/sheet.en.js +288 -0
  42. package/src/pack/tar.js +65 -2
  43. package/src/plugins/manage.js +46 -9
  44. package/src/providers/bedrock.js +17 -0
  45. package/src/repl.js +2602 -2286
  46. package/src/safety/audit.js +92 -8
  47. package/src/safety/authcmd.js +14 -3
  48. package/src/safety/guard.js +143 -0
  49. package/src/safety/keystore.js +62 -39
  50. package/src/safety/undo.js +24 -6
  51. package/src/tools/fsutil.js +265 -250
  52. package/src/tools/index.js +239 -34
  53. package/src/tools/jobs.js +158 -29
  54. package/src/tools/verify.js +358 -328
  55. package/src/tools/webfetch.js +92 -9
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/src/ui/status.js +613 -610
@@ -56,17 +56,43 @@ const MAX_BYTES = 2 * 1024 * 1024; // 2MB 넘게 받지 않는다
56
56
  const 집줄 = new Map(); // origin → 그 집의 마지막 차례가 끝나는 약속
57
57
  const 집간격 = 400; // 같은 집을 다시 두드리기 전에 쉬는 시간
58
58
 
59
- const 잠깐 = (ms) => new Promise((r) => setTimeout(r, ms));
59
+ /**
60
+ * 잠깐 잔다. 멈추라고 하면 자다가도 일어난다.
61
+ *
62
+ * 이 파일에는 사람을 최대 10초까지 붙잡는 잠이 두 군데 있다 — 줄 서는 사이와
63
+ * 429 뒤에 쉬는 사이다. 그냥 setTimeout 이면 ESC 를 눌러도 그 10초는 그대로
64
+ * 흐른다. 화면에는 「멈추는 중…」 이 10초 내내 떠 있고, 사람 눈에는 ESC 가
65
+ * 안 먹은 것과 똑같이 보인다 — 제보받은 그 증상이다.
66
+ */
67
+ const 잠깐 = (ms, signal = null) => new Promise((풀기) => {
68
+ const t = setTimeout(풀기, ms);
69
+ signal?.addEventListener?.('abort', () => { clearTimeout(t); 풀기(); }, { once: true });
70
+ });
60
71
 
61
- function 한집씩(origin, ) {
72
+ function 한집씩(origin, 일, signal = null) {
62
73
  const 앞사람 = 집줄.get(origin);
63
74
  const 내차례 = (앞사람 ?? Promise.resolve())
64
- .then(async () => { if (앞사람) await 잠깐(집간격); return 일(); });
75
+ .then(async () => {
76
+ if (앞사람) await 잠깐(집간격, signal);
77
+ // 줄을 서 있는 사이에 멈췄으면 두드리지 않는다. 안 두드려도 잃을 것이 없다 —
78
+ // 아직 나간 것이 하나도 없는 자리다.
79
+ if (signal?.aborted) throw new Error('중단했습니다');
80
+ return 일();
81
+ });
65
82
  // 다음 사람이 기다리는 것은 '내가 끝났다' 뿐이다. 내가 실패해도 줄은 넘어간다.
66
83
  집줄.set(origin, 내차례.then(() => {}, () => {}));
67
84
  return 내차례;
68
85
  }
69
86
 
87
+ /**
88
+ * 멈췄을 때 돌려줄 모양.
89
+ *
90
+ * 다른 도구들과 **같은 모양**이어야 한다(tools/index.js 의 runTool). 멈춤은
91
+ * 실패가 아니라서 중단됨 을 따로 단다 — 실패로 세면 되풀이 감지가 엉뚱하게
92
+ * 걸려서 다음에 같은 주소를 부르는 것까지 막힌다.
93
+ */
94
+ const 중단결과 = () => ({ error: '중단했습니다. 웹을 읽다 말았습니다.', 끝났다: true, 중단됨: true });
95
+
70
96
  /*
71
97
  * 잠시 뒤에 다시 하면 되는 것들.
72
98
  *
@@ -127,8 +153,45 @@ function 태그벗기기(html) {
127
153
  * allowPrivate 는 검사용이다. 도구 스키마에 없으므로 모델은 이 값을 줄 수 없다.
128
154
  * (환경변수로 열어 두면 실제 사용 중에도 열려 버린다 — 그래서 인자로만 둔다)
129
155
  */
130
- export async function webFetch(args, { allowPrivate = false, 모델컨텍스트 = null } = {}) {
156
+ /**
157
+ * 되돌림(redirect)을 따라가도 되는 곳인가. 던지면 안 따라간다.
158
+ *
159
+ * ── 왜 이름을 붙여 꺼냈나 ───────────────────────────────────────────────
160
+ *
161
+ * 이 판단이 webFetch 안의 이름 없는 함수로 묻혀 있었다. 그래서 **어떤 검사도
162
+ * 여기를 안 지났다** — 두 줄을 통째로 지워도 검사가 전부 초록이었다.
163
+ * 직접 친 주소가 사내망인 것은 재고 있었는데, **바깥 주소가 302 로 사내망을
164
+ * 가리키는** 길은 아무도 안 지났다. 위험한 쪽은 이쪽이다.
165
+ *
166
+ * 이름을 붙이면 재 볼 수 있다. `플러그인되돌림`(plugins/manage.js)이 같은
167
+ * 까닭으로 먼저 그렇게 되어 있다.
168
+ *
169
+ * ── 무엇을 막나 ─────────────────────────────────────────────────────────
170
+ *
171
+ * 첫째, http·https 가 아닌 곳. `file:///etc/passwd` 로 되돌리면 남의 서버가
172
+ * 우리 디스크를 읽어 제 화면에 실어 보낼 수 있다.
173
+ * 둘째, 이 컴퓨터·사내망 주소. 바깥에서 시작한 요청이 302 한 번으로 사내망
174
+ * 안쪽에 닿으면, 「소스가 어디로도 안 나간다」 와 짝을 이루는 문장이 깨진다.
175
+ */
176
+ export function 웹되돌림(다음, { allowPrivate = false } = {}) {
177
+ if (다음.protocol !== 'http:' && 다음.protocol !== 'https:') {
178
+ throw new Error(`${다음.protocol} 로 되돌립니다 — 따라가지 않습니다`);
179
+ }
180
+ if (isLocalHost(다음.hostname) && !allowPrivate) {
181
+ throw new Error(`이 컴퓨터·사내망 주소(${다음.hostname})로 되돌립니다 — 따라가지 않습니다`);
182
+ }
183
+ }
184
+
185
+ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트 = null, signal = null } = {}) {
131
186
  const raw = String(args?.url ?? '').trim();
187
+ /*
188
+ * 이미 멈췄으면 아예 안 나간다.
189
+ *
190
+ * 여럿을 함께 부를 때(loop.js 의 Promise.all) 앞엣것이 도는 사이 ESC 를
191
+ * 누르면, 뒤엣것들은 아직 아무 데도 안 두드렸는데 그대로 나갔다. 나가면
192
+ * 상대 서버에는 기록이 남는다 — 멈춘 뒤에 남기는 발자국은 설명할 길이 없다.
193
+ */
194
+ if (signal?.aborted) return 중단결과();
132
195
  /*
133
196
  * 얼마나 가져올지는 **모델에 맞춰** 정한다 (agent/budget.js).
134
197
  *
@@ -164,19 +227,28 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
164
227
  method: 'GET', // 보내는 건 없다
165
228
  headers: { 'User-Agent': 'deel/cli', Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5' },
166
229
  timeout: 30000,
230
+ /*
231
+ * 사람이 누른 ESC 를 여기까지 데려온다.
232
+ *
233
+ * 여태 이 자리에는 시한(30초)만 있었다. 그래서 안 답하는 서버를 하나
234
+ * 물면 ESC 를 눌러도 30초를 꼬박 기다렸다 — 「ESC 를 눌러도 안 멈춘다」
235
+ * 는 제보의 한 갈래가 정확히 이것이다. http.js 의 신호() 가 시한과
236
+ * 이 신호를 AbortSignal.any 로 묶어 준다.
237
+ */
238
+ signal,
167
239
  stream: true, // 상한까지만 받는다 — 다 받아 놓고 버리지 않는다
168
240
  되돌림: (다음) => {
169
- if (다음.protocol !== 'http:' && 다음.protocol !== 'https:') throw new Error(`${다음.protocol} 로 되돌립니다 — 따라가지 않습니다`);
170
- if (isLocalHost(다음.hostname) && !allowPrivate) throw new Error(`이 컴퓨터·사내망 주소(${다음.hostname})로 되돌립니다 — 따라가지 않습니다`);
241
+ 웹되돌림(다음, { allowPrivate });
171
242
  열어둔.push(allowTemporarily(다음.origin));
172
243
  },
173
- }));
244
+ }), signal);
174
245
  방문기록.push({ url: u.href, status: res.status, at: new Date().toISOString() });
175
246
  if (res.ok || !다시할것.has(res.status) || 회차 >= 다시횟수) break;
176
247
  await res.버리기?.();
177
248
  const 초 = 얼마나쉬라나(res.headers.get('retry-after'), 회차);
178
249
  쉰시간 += 초;
179
- await 잠깐(초 * 1000);
250
+ await 잠깐(초 * 1000, signal);
251
+ if (signal?.aborted) return 중단결과();
180
252
  }
181
253
 
182
254
  if (!res.ok) {
@@ -282,6 +354,15 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
282
354
  };
283
355
  } catch (err) {
284
356
  const m = String(err?.message ?? err);
357
+ /*
358
+ * 멈춤이 먼저다.
359
+ *
360
+ * 신호가 끊으면 fetch 는 AbortError 를 던지는데, 그 말은 「이 작업이
361
+ * 중단되었습니다」 라는 영어 한 줄이다. 그대로 오류로 올리면 화면에는
362
+ * 사람이 누른 ESC 가 남의 서버 잘못처럼 찍힌다. 시한 초과와도 구별해야
363
+ * 한다 — 시한은 상대가 늦은 것이고, 이건 우리가 그만둔 것이다.
364
+ */
365
+ if (signal?.aborted) return 중단결과();
285
366
  if (err?.name === 'TimeoutError') return { error: '시간 초과 — 응답이 없습니다.' };
286
367
  if (/ENOTFOUND|getaddrinfo/i.test(m)) return { error: '주소를 찾을 수 없습니다 (DNS).' };
287
368
  return { error: m };
@@ -306,5 +387,7 @@ export const WEB_FETCH_TOOL = {
306
387
  required: ['url'],
307
388
  },
308
389
  },
309
- run: (args, ctx) => webFetch(args, { 모델컨텍스트: ctx?.모델컨텍스트 ?? null }),
390
+ // signal 같이 넘긴다. 넘기면 ESC 눌러도 이 도구만 최대 30초를 더 산다
391
+ // — 화면은 「멈추는 중…」 인데 실제로는 남의 서버를 계속 붙들고 있는 상태다.
392
+ run: (args, ctx) => webFetch(args, { 모델컨텍스트: ctx?.모델컨텍스트 ?? null, signal: ctx?.signal ?? null }),
310
393
  };
package/src/ui/md.js CHANGED
@@ -28,6 +28,7 @@
28
28
  // 실제로는 제목·목록·코드펜스가 다 짧은 줄이라 거의 다 그려진다. 안 그려지는
29
29
  // 것은 긴 산문뿐인데, 산문은 원래 꾸밀 것이 없다.
30
30
  import { c, width } from './ansi.js';
31
+ import { 접어쓰기 } from './wrap.js';
31
32
 
32
33
  const 굵게 = (s) => c.bold(s);
33
34
  const 코드 = (s) => c.hyellow(s);
@@ -40,6 +41,10 @@ const 코드 = (s) => c.hyellow(s);
40
41
  const 회색앞 = c.gray('·').replace(/·[\s\S]*$/, '');
41
42
  const 이어흐리게 = (s) => c.gray(String(s).replace(/\x1b\[0m/g, `\x1b[0m${회색앞}`));
42
43
 
44
+ // 굵게도 같은 문제를 겪는다 — 칸 안의 코드가 색을 끝내면서 굵기까지 꺼 버린다.
45
+ const 굵게앞 = c.bold('·').replace(/·[\s\S]*$/, '');
46
+ const 이어굵게 = (s) => c.bold(String(s).replace(/\x1b\[0m/g, `\x1b[0m${굵게앞}`));
47
+
43
48
  /**
44
49
  * 한 줄 안쪽 꾸밈. `코드` → **굵게** → *기울임* → ~~취소~~ → [글](주소) 차례.
45
50
  *
@@ -146,6 +151,143 @@ export function 한줄(줄, 상태 = { 펜스: false }, 폭 = 80) {
146
151
  return { 글: 인라인(s), 상태 };
147
152
  }
148
153
 
154
+ /*
155
+ * ── 표 ──────────────────────────────────────────────────────────────────
156
+ *
157
+ * 표만은 **모아서** 그린다. 칸 너비는 그 열에서 제일 긴 칸이 정하는데, 그건
158
+ * 마지막 줄까지 봐야 안다. 줄마다 바로 그리면 칸이 안 맞아서 세로줄이
159
+ * 지그재그로 흐른다 — 여태 나오던 모양이 그것이다.
160
+ *
161
+ * 다 모으는 것이 아니라 **표인 동안만** 모은다. 표가 아닌 줄이 하나 오면 그
162
+ * 자리에서 표를 그려 내보내고 하던 대로 돌아간다. 그래서 산문이 늦어지는 일은
163
+ * 없고, 표는 몇 줄 안 되고 붙어서 오므로 사람 눈에는 한 덩이로 뜬다.
164
+ *
165
+ * 모으다가 표가 아니었던 것으로 드러나면(가름줄이 없으면) 모아 둔 줄을 옛
166
+ * 방식으로 한 줄씩 내보낸다. 글이 사라지는 것이 제일 나쁘다.
167
+ */
168
+
169
+ /*
170
+ * 붙들기의 상한 둘.
171
+ *
172
+ * 표라고 우기는 것이 몇백 줄이거나 한 줄이 끝없이 길면, 그동안 화면은 멈춘
173
+ * 것처럼 보인다. 모양보다 '살아 있다' 가 먼저라는 이 파일의 규칙은 표에도
174
+ * 그대로 적용된다.
175
+ */
176
+ const 표붙들상한 = 200; // 몇 줄까지 모아 두나
177
+ const 표줄최대배 = 8; // 한 줄이 창 폭의 몇 배까지면 줄 끝을 기다리나
178
+
179
+ /** `| 가 | 나 |` 모양인가. 바깥 세로줄을 요구한다 — 산문에 든 `|` 를 안 삼키려고. */
180
+ const 표줄인가 = (s) => /^\s*\|.*\|\s*$/.test(String(s ?? '').replace(/\r+$/, ''));
181
+
182
+ /** `---` `:---` `---:` `:---:` — 가름줄의 한 칸인가. */
183
+ const 가름칸인가 = (칸) => /^:?-+:?$/.test(String(칸).trim());
184
+
185
+ /**
186
+ * 한 줄을 칸으로 가른다.
187
+ *
188
+ * 그냥 split('|') 로 하면 두 자리에서 깨진다. `\|` 로 적은 세로줄(칸 안에
189
+ * 세로줄을 쓰려면 이 방법뿐이다)과, `` `a | b` `` 처럼 인라인 코드 안에 든
190
+ * 세로줄이다. 둘 다 칸을 가르면 안 되는 자리다.
191
+ */
192
+ function 칸가르기(줄) {
193
+ const s = String(줄 ?? '').replace(/\r+$/, '').trim().replace(/^\|/, '').replace(/\|$/, '');
194
+ const 칸들 = [];
195
+ let 지금 = '';
196
+ let 코드안 = false;
197
+ for (let i = 0; i < s.length; i += 1) {
198
+ const ch = s[i];
199
+ if (ch === '\\' && s[i + 1] === '|') { 지금 += '|'; i += 1; continue; }
200
+ if (ch === '`') { 코드안 = !코드안; 지금 += ch; continue; }
201
+ if (ch === '|' && !코드안) { 칸들.push(지금); 지금 = ''; continue; }
202
+ 지금 += ch;
203
+ }
204
+ 칸들.push(지금);
205
+ return 칸들.map((x) => x.trim());
206
+ }
207
+
208
+ /** 가름줄이 알려 주는 정렬. `---:` 는 오른쪽, `:---:` 는 가운데. 숫자 칸에 쓴다. */
209
+ function 기울기(칸) {
210
+ const t = String(칸).trim();
211
+ if (t.startsWith(':') && t.endsWith(':')) return '가운데';
212
+ if (t.endsWith(':')) return '오른쪽';
213
+ return '왼쪽';
214
+ }
215
+
216
+ /** 칸을 제 너비까지 밀어 준다. width() 가 제어문자를 안 세므로 색이 끼어도 맞는다. */
217
+ function 채우기(글, 폭, 쪽) {
218
+ const 남은 = Math.max(0, 폭 - width(글));
219
+ if (쪽 === '오른쪽') return ' '.repeat(남은) + 글;
220
+ if (쪽 === '가운데') {
221
+ const 왼 = Math.floor(남은 / 2);
222
+ return ' '.repeat(왼) + 글 + ' '.repeat(남은 - 왼);
223
+ }
224
+ return 글 + ' '.repeat(남은);
225
+ }
226
+
227
+ /**
228
+ * 모아 둔 표 줄들을 칸 맞춰 그린다. 표가 아니면 null 을 돌려준다.
229
+ *
230
+ * 창보다 넓으면 **제일 넓은 칸부터** 한 칸씩 줄인다. 골고루 줄이면 `번호` 같은
231
+ * 짧은 칸까지 같이 좁아져서 거기부터 글자가 접힌다 — 정작 줄여야 할 것은 긴
232
+ * 설명 칸이다. 줄인 칸은 잘라 버리지 않고 접는다(접어쓰기). 표에서 글자가
233
+ * 사라지면 그 줄은 아예 못 읽는다.
234
+ */
235
+ export function 표그리기(줄들, 폭 = 80) {
236
+ const 행들 = (줄들 ?? []).map(칸가르기);
237
+ // 가름줄이 있어야 표다. 첫 줄일 수는 없다 — 머리줄이 그 위에 있어야 한다.
238
+ const 가름 = 행들.findIndex((행) => 행.length > 1 && 행.every(가름칸인가));
239
+ if (가름 < 1) return null;
240
+
241
+ const 열수 = Math.max(...행들.map((행) => 행.length));
242
+ const 쪽들 = Array.from({ length: 열수 }, (_, i) => 기울기(행들[가름][i] ?? ''));
243
+
244
+ // 가름줄은 자리만 알려 주고 화면에는 안 나간다 — 우리가 진짜 선을 긋는다.
245
+ const 몸 = 행들
246
+ .filter((_, i) => i !== 가름)
247
+ .map((행) => Array.from({ length: 열수 }, (_, i) => 인라인(행[i] ?? '')));
248
+ if (!몸.length) return null;
249
+
250
+ /*
251
+ * 치레가 먹는 폭: 칸마다 세로줄 하나와 좌우 한 칸씩(3), 그리고 맨 오른쪽
252
+ * 세로줄 하나. 이걸 빼고 남는 것이 글자가 쓸 수 있는 폭이다.
253
+ */
254
+ const 치레 = 3 * 열수 + 1;
255
+ const 쓸수있는 = Math.max(열수, 폭 - 치레);
256
+ const 너비 = Array.from({ length: 열수 }, (_, i) => Math.max(1, ...몸.map((행) => width(행[i]))));
257
+ let 합 = 너비.reduce((a, b) => a + b, 0);
258
+ while (합 > 쓸수있는) {
259
+ let 큰 = 0;
260
+ for (let i = 1; i < 너비.length; i += 1) if (너비[i] > 너비[큰]) 큰 = i;
261
+ if (너비[큰] <= 1) break;
262
+ 너비[큰] -= 1;
263
+ 합 -= 1;
264
+ }
265
+
266
+ const 가로 = (왼, 사이, 오른) =>
267
+ c.gray(왼 + 너비.map((w) => '─'.repeat(w + 2)).join(사이) + 오른);
268
+ const 세로 = c.gray('│');
269
+
270
+ const 한행 = (행) => {
271
+ // 접힌 칸이 여럿이면 그 행은 여러 줄이 된다. 제일 높은 칸에 키를 맞춘다.
272
+ const 접힌 = 행.map((칸, i) => (칸 === '' ? [''] : 접어쓰기(칸, 너비[i])));
273
+ const 높이 = Math.max(1, ...접힌.map((x) => x.length));
274
+ const 나갈것 = [];
275
+ for (let k = 0; k < 높이; k += 1) {
276
+ const 칸들 = 접힌.map((x, i) => 채우기(x[k] ?? '', 너비[i], 쪽들[i]));
277
+ 나갈것.push(`${세로} ${칸들.join(` ${세로} `)} ${세로}`);
278
+ }
279
+ return 나갈것;
280
+ };
281
+
282
+ const 화면 = [가로('┌', '┬', '┐')];
283
+ 몸.forEach((행, i) => {
284
+ // 머리줄만 굵게. 어디까지가 이름이고 어디부터가 값인지가 한눈에 갈린다.
285
+ 화면.push(...한행(i === 0 ? 행.map(이어굵게) : 행));
286
+ 화면.push(i === 몸.length - 1 ? 가로('└', '┴', '┘') : 가로('├', '┼', '┤'));
287
+ });
288
+ return 화면;
289
+ }
290
+
149
291
  /**
150
292
  * 흘러오는 토막을 받아 그릴 수 있게 된 줄만 내놓는다.
151
293
  *
@@ -164,15 +306,50 @@ export class 마크다운 {
164
306
  this.상태 = { 펜스: false };
165
307
  // 이 줄은 이미 날것으로 흘려보내기 시작했다 — 뒤늦게 꾸미면 앞뒤가 안 맞는다.
166
308
  this.날것중 = false;
309
+ // 그리려고 붙들고 있는 표 줄들. 표가 끝나는 순간 칸을 맞춰 한꺼번에 나간다.
310
+ this.표줄들 = [];
311
+ }
312
+
313
+ /** 붙들고 있던 표를 내보낸다. 표가 아니었으면 옛 방식으로 한 줄씩 내보낸다. */
314
+ #표비우기() {
315
+ const 줄들 = this.표줄들;
316
+ this.표줄들 = [];
317
+ if (!줄들.length) return [];
318
+ const 그린것 = 표그리기(줄들, this.폭);
319
+ if (그린것) return 그린것;
320
+ const 나갈것 = [];
321
+ for (const 줄 of 줄들) {
322
+ const r = 한줄(줄, this.상태, this.폭);
323
+ this.상태 = r.상태;
324
+ 나갈것.push(r.글);
325
+ }
326
+ return 나갈것;
167
327
  }
168
328
 
169
329
  #그리기(줄) {
170
330
  // 이 줄은 이미 날것으로 나가기 시작했다. 뒤늦게 꾸미면 앞뒤가 안 맞으므로
171
331
  // 남은 만큼을 그대로 이어 붙이고 줄만 끝낸다.
172
- if (this.날것중) { this.날것중 = false; return { 이어붙임: String(줄).replace(/\r/g, ''), 끝났나: true }; }
332
+ if (this.날것중) {
333
+ this.날것중 = false;
334
+ return [{ 이어붙임: String(줄).replace(/\r/g, ''), 끝났나: true }];
335
+ }
336
+
337
+ /*
338
+ * 표 줄은 붙든다. 다만 **코드 울타리 안에서는 안 한다** — 거기서는 세로줄도
339
+ * 그냥 글자다. SQL 이나 아스키 그림을 표로 읽어 다시 그리면 코드가 망가진다.
340
+ *
341
+ * 끝없이 붙들지는 않는다. 표라고 우기는 것이 몇백 줄이면 화면이 그동안
342
+ * 멈춘 것처럼 보인다. 그때는 붙들기를 그만두고 흘려보낸다.
343
+ */
344
+ if (!this.상태.펜스 && 표줄인가(줄)) {
345
+ this.표줄들.push(줄);
346
+ return this.표줄들.length >= 표붙들상한 ? this.#표비우기() : [];
347
+ }
348
+
349
+ const 앞 = this.#표비우기();
173
350
  const r = 한줄(줄, this.상태, this.폭);
174
351
  this.상태 = r.상태;
175
- return r.글;
352
+ return [...앞, r.글];
176
353
  }
177
354
 
178
355
  넣기(토막) {
@@ -185,13 +362,31 @@ export class 마크다운 {
185
362
  // \r\n 으로 온 줄은 여기서 \r 를 뗀다. 남겨서 내보내면 터미널에서 커서가
186
363
  // 줄 맨 앞으로 돌아가 앞에 세운 세로줄 위에 덮어쓴다 — 글자가 먹힌 것처럼
187
364
  // 보인다. 토막이 \r 와 \n 사이에서 끊겨 와도 여기로 모여서 걸린다.
188
- 나갈것.push(this.#그리기(this.담긴것.slice(0, i)));
365
+ 나갈것.push(...this.#그리기(this.담긴것.slice(0, i)));
189
366
  this.담긴것 = this.담긴것.slice(i + 1);
190
367
  continue;
191
368
  }
369
+ /*
370
+ * 표 줄은 길어도 안 흘려보낸다.
371
+ *
372
+ * 표 한 줄은 창보다 넓은 것이 보통이다(칸이 셋만 돼도 넘는다). 여기서
373
+ * 날것으로 내보내면 그 줄은 표로 모이지도 못하고 세로줄이 그대로 뜬다 —
374
+ * 고치려던 바로 그 모양이다. 그래서 세로줄로 시작하는 줄은 줄이 끝날
375
+ * 때까지 기다린다.
376
+ *
377
+ * 그래도 상한은 둔다. 표가 아닌데 세로줄로 시작하는 긴 글(로그 한 덩이
378
+ * 같은 것)이 오면 화면이 멈춘 것처럼 보이면 안 된다.
379
+ */
380
+ const 표기다림 = !this.상태.펜스
381
+ && (this.표줄들.length > 0 || /^\s*\|/.test(this.담긴것))
382
+ && width(this.담긴것) <= this.폭 * 표줄최대배;
383
+ if (표기다림) break;
384
+
192
385
  // 줄이 안 끝나는데 너무 길어졌다. 여기까지 날것으로 흘려보낸다 —
193
386
  // 멈춘 것처럼 보이는 것보다 안 꾸며진 글이 흐르는 편이 낫다.
194
387
  if (width(this.담긴것) > this.폭) {
388
+ // 붙들고 있던 표부터 내보낸다. 안 그러면 표가 뒤에 나와서 순서가 뒤집힌다.
389
+ 나갈것.push(...this.#표비우기());
195
390
  // 날것으로 나가는 길에는 한줄() 이 안 끼므로 여기서 \r 를 턴다.
196
391
  나갈것.push({ 이어붙임: this.담긴것.replace(/\r/g, ''), 첫조각: !this.날것중, 끝났나: false });
197
392
  this.담긴것 = '';
@@ -208,12 +403,13 @@ export class 마크다운 {
208
403
  if (!this.담긴것) {
209
404
  const 닫을것 = this.날것중 ? [{ 이어붙임: '', 끝났나: true }] : [];
210
405
  this.날것중 = false;
211
- return 닫을것;
406
+ // 표로 끝나는 답이 흔하다(마지막이 정리 표). 여기서 안 비우면 통째로 사라진다.
407
+ return [...(닫을것.length ? 닫을것 : []), ...this.#표비우기()];
212
408
  }
213
409
  const 남은것 = this.#그리기(this.담긴것);
214
410
  this.담긴것 = '';
215
411
  this.날것중 = false;
216
- return [남은것];
412
+ return [...남은것, ...this.#표비우기()];
217
413
  }
218
414
  }
219
415
 
package/src/ui/motion.js CHANGED
@@ -228,7 +228,6 @@ export const 틀 = Object.fromEntries(
228
228
  * 그린다 — 세로로 선 정면 모습은 4점 안에서 절대 안 읽힌다.
229
229
  */
230
230
  export const 넓은칸수 = 6; // 점자 여섯 칸
231
- export const 넓은가로 = 넓은칸수 * 2; // 점 12개
232
231
 
233
232
  /*
234
233
  * 중세 기사 — 옆모습. 왼쪽에 몸이 서 있고 오른쪽에서 칼이 움직인다.
@@ -21,6 +21,7 @@
21
21
  // 바이트 그대로다. 되돌릴 수 없는 접기는 접기가 아니라 잘라내기다.
22
22
  import { 접어쓰기 } from './wrap.js';
23
23
  import { 안쪽최대 } from './inputbox.js';
24
+ import { 말, 말모두 } from '../i18n/index.js';
24
25
 
25
26
  /** 사람이 읽는 크기. 47줄이 몇 KB 인지 알아야 뭘 붙였는지 감이 온다. */
26
27
  export function 크기말(바이트) {
@@ -40,6 +41,24 @@ export function 크기말(바이트) {
40
41
  export function 접을까(글, { 폭 = 80, 최대 = 안쪽최대 } = {}) {
41
42
  const 원 = String(글 ?? '');
42
43
  if (!원) return false;
44
+
45
+ /*
46
+ * 줄이 둘 이상이면 무조건 접는다 — 상자가 그것밖에 못 보여 주기 때문이다.
47
+ *
48
+ * readline 의 `rl.line` 에는 개행을 못 넣는다. 넣으면 보낼 때 줄 순서가
49
+ * 뒤집힌다(newline.test.js 가 지키는 자리다). 그래서 붙여넣은 나머지 줄은
50
+ * 상자 밖 버퍼에 따로 두는데, 상자는 `rl.line` 만 그린다. 결과는 이렇다.
51
+ *
52
+ * 넉 줄을 붙인다 → 「4줄을 붙여넣었습니다」 가 뜨고
53
+ * 상자에는 **맨 밑 한 줄만** 보인다
54
+ *
55
+ * 여덟 줄까지는 들어가니까 안 접어도 된다고 여겼던 것이 틀렸다. 들어갈
56
+ * 자리가 있느냐가 아니라 **그릴 수 있느냐**가 갈림이다. 못 그릴 바에는
57
+ * 표 한 줄로 접는 편이 낫다 — 몇 줄 몇 바이트인지는 그대로 보이고,
58
+ * 앞뒤에 덧붙인 말도 안 가려진다.
59
+ */
60
+ if (원.includes('\n')) return true;
61
+
43
62
  const 쓸폭 = Math.max(4, Number(폭) || 80);
44
63
  let 줄수 = 0;
45
64
  for (const 한줄 of 원.split('\n')) {
@@ -49,10 +68,30 @@ export function 접을까(글, { 폭 = 80, 최대 = 안쪽최대 } = {}) {
49
68
  return false;
50
69
  }
51
70
 
52
- /** `[붙여넣기 #1 · 47줄 · 2.1KB]` */
71
+ /**
72
+ * 대화에 **남길** 글. 표는 작을 때만 펴서 남긴다.
73
+ *
74
+ * 상자에 그릴 때와 대화에 남길 때는 셈이 다르다. 상자는 `rl.line` 이라
75
+ * 여러 줄을 못 그리니 몇 줄이든 접어야 한다. 대화는 그냥 찍는 자리라
76
+ * 못 그릴 것이 없다 — 오히려 접으면 나중에 스크롤을 올렸을 때 무엇을
77
+ * 보냈는지가 사라진다.
78
+ *
79
+ * 그렇다고 마흔 줄을 다 찍으면 화면이 붙인 글로 뒤덮인다. 그래서 상자에
80
+ * 들어갈 만한 크기(안쪽최대)까지만 펴고, 그보다 크면 표를 남긴다 — 표에는
81
+ * 줄 수와 크기가 적혀 있어서 그 자체가 기록이 된다.
82
+ */
83
+ export function 남길글(글, 붙인것들) {
84
+ const 원 = String(글 ?? '');
85
+ const 펴진것 = 펼치기(원, 붙인것들);
86
+ if (펴진것 === 원) return 원;
87
+ return 펴진것.split('\n').length <= 안쪽최대 ? 펴진것 : 원;
88
+ }
89
+
90
+ /** `[붙여넣기 #1 · 47줄 · 2.1KB]` — 낱말은 고른 말을 따른다. */
53
91
  export function 표만들기(번호, 글) {
54
92
  const 원 = String(글 ?? '');
55
- return `[붙여넣기 #${번호} · ${원.split('\n').length}줄 · ${크기말(Buffer.byteLength(원, 'utf8'))}]`;
93
+ const = 말('paste.chipLines', { n: 원.split('\n').length });
94
+ return `[${말('paste.chip')} #${번호} · ${줄} · ${크기말(Buffer.byteLength(원, 'utf8'))}]`;
56
95
  }
57
96
 
58
97
  /*
@@ -64,7 +103,15 @@ export function 표만들기(번호, 글) {
64
103
  * /g 를 안 붙인다 — 붙이면 lastIndex 가 남아서 같은 무늬로 test() 를
65
104
  * 두 번 부를 때 두 번째가 조용히 거짓이 된다. 훑는 자리에서만 새로 만든다.
66
105
  */
67
- export const 표무늬 = /\[붙여넣기 #(\d+) · [^[\]]*\]/;
106
+ /*
107
+ * 낱말은 **네 말을 다 받는다.**
108
+ *
109
+ * 표를 찍은 뒤에 `/lang en` 을 치면, 지금 말로만 찾는 무늬는 앞서 찍어 둔
110
+ * 표를 못 알아본다. 그러면 사람이 붙인 40줄이 표 글자 그대로 모델에게
111
+ * 가고, 아무 데도 안 적히는 채로 그렇게 된다. 어느 말로 찍힌 표든 편다.
112
+ */
113
+ const 낱말들 = 말모두('paste.chip').map((w) => String(w).replace(/[.*+?^${}()|[\]\\]/g, (ch) => `\\${ch}`));
114
+ export const 표무늬 = new RegExp(`\\[(?:${낱말들.join('|')}) #(\\d+) · [^[\\]]*\\]`);
68
115
 
69
116
  /** 글 안의 표를 원문으로 되돌린다. 모르는 번호는 사람이 친 글로 보고 둔다. */
70
117
  export function 펼치기(글, 붙인것들) {
package/src/ui/pick.js ADDED
@@ -0,0 +1,115 @@
1
+ // 고른 번호 + 하고 싶은 말, 한 줄에서 갈라 읽기.
2
+ //
3
+ // ── 사람 말 ─────────────────────────────────────────────────────────────
4
+ //
5
+ // 「사용자에게 계획 물어볼 때 번호만 입력하는 게 아니라, 번호를 고르고
6
+ // 의견이 있으면 의견도 입력할 수 있게」
7
+ //
8
+ // 여태는 둘 중 하나였다. 숫자만 치면 그 줄을 고른 것이고, 그 밖의 글은
9
+ // **고른 것 없이** 통째로 자유 답이었다. 그래서 「2번인데 파일은 나눠서」
10
+ // 라고 치면 2번을 고른 사실이 사라진다. 사람은 골랐다고 생각하는데
11
+ // 모델에게는 고른 것이 없는 문장 하나만 간다.
12
+ //
13
+ // 실제로는 이렇게 답하고 싶어진다 —
14
+ //
15
+ // 2 파일은 나눠서 해줘 (고르고 + 덧붙이고)
16
+ // 2: 파일은 나눠서 해줘
17
+ // 2, 파일은 나눠서 해줘
18
+ // 2 - 파일은 나눠서 해줘
19
+ // 1,3 (둘 다)
20
+ // 1 3 둘 다 하되 순서는 반대로
21
+ //
22
+ // ── 어디서 멈추나 ───────────────────────────────────────────────────────
23
+ //
24
+ // 숫자로 읽는 것은 **머리에 붙은, 목록 안에 있는 수**뿐이다. 그 뒤로는 다
25
+ // 사람 말이다. 이 선이 없으면 「3개만 해줘」 가 3번을 고른 것이 된다 —
26
+ // 사람이 고른 적 없는 것을 골랐다고 하는 셈이라, 안 받느니만 못하다.
27
+ // 그래서 수 바로 뒤에 글자가 붙어 있으면(3개) 수로 안 읽는다.
28
+ //
29
+ // 목록에 없는 수도 안 읽는다. 선택지가 둘인데 「5분만 기다려」 라고 치면
30
+ // 그건 고른 것이 아니라 하는 말이다.
31
+
32
+ /** 수 뒤에 올 수 있는 것 — 끝, 빈칸, 그리고 흔한 구분표. `1.5` 는 수가 아니다. */
33
+ const 수뒤 = /^(\d{1,3})(?:\s*[.)::\]](?!\d)|\s*[,,、·]|\s|$)/;
34
+
35
+ /** 수와 말 사이에 사람이 흔히 넣는 것들. 말 앞머리에서 떼어 낸다. */
36
+ const 사이표 = /^[\s,,、·::\-–—>]+/;
37
+
38
+ /**
39
+ * 답 한 줄을 「고른 번호들」과 「덧붙인 말」로 가른다.
40
+ *
41
+ * @param {string} 답 사람이 친 그대로
42
+ * @param {string[]} 고를것 보여 준 선택지
43
+ * @returns {{번호들: number[], 덧말: string, 글: string}}
44
+ * 글 — 모델에게 보낼 한 줄. 고른 것이 없으면 친 글 그대로다.
45
+ */
46
+ export function 고른것풀기(답, 고를것 = []) {
47
+ const 원 = String(답 ?? '').trim();
48
+ const 목록 = Array.isArray(고를것) ? 고를것 : [];
49
+ if (!원) return { 번호들: [], 덧말: '', 글: '' };
50
+
51
+ const 번호들 = [];
52
+ let 남은 = 원;
53
+ for (;;) {
54
+ const m = 수뒤.exec(남은);
55
+ if (!m) break;
56
+ const n = Number(m[1]);
57
+ // 목록 밖의 수는 고른 것이 아니라 하는 말이다.
58
+ if (!Number.isInteger(n) || n < 1 || n > 목록.length) break;
59
+ // 같은 것을 두 번 쳐도 한 번만 센다 — 「1 1」 을 두 갈래로 읽을 까닭이 없다.
60
+ if (!번호들.includes(n)) 번호들.push(n);
61
+ 남은 = 남은.slice(m[0].length).replace(사이표, '');
62
+ }
63
+
64
+ const 덧말 = 남은.trim();
65
+ // 아무 수도 못 읽었으면 여태와 똑같다 — 친 글이 그대로 답이다.
66
+ if (!번호들.length) return { 번호들: [], 덧말: '', 글: 원 };
67
+
68
+ const 고른 = 번호들.map((n) => String(목록[n - 1] ?? '').trim()).filter(Boolean);
69
+ const 글 = 고른.join(' · ') + (덧말 ? ` — ${덧말}` : '');
70
+ return { 번호들, 덧말, 글 };
71
+ }
72
+
73
+ /**
74
+ * 계획 상자의 답을 읽는다.
75
+ *
76
+ * 갈래는 셋(진행·그만·다시)이고, 들어오는 모양은 다섯이다.
77
+ * · 그냥 진행 — ⏎ · y · yes · ㅇ · 진행
78
+ * · 진행하되 한마디 — `y 검사도 같이 넣어줘`
79
+ * · 그만 — n · no · ㄴ
80
+ * · 그 밖의 글 — 그 말대로 다시 짜라 (여태 그대로)
81
+ * · `3 이건 빼자` — 3단계에 대한 말로 붙여서 다시 짜라
82
+ *
83
+ * ⏎ 하나로 진행하는 것은 안 건드린다. 계획을 읽고 「그래」 하는 데
84
+ * 두 글자를 더 치게 하면, 그 두 글자 때문에 안 읽고 넘기게 된다.
85
+ *
86
+ * @param {string} 답
87
+ * @param {number} 단계수 계획이 몇 단계인가 (번호를 가려내는 데 쓴다)
88
+ * @returns {{갈래: '진행'|'그만'|'다시', 덧말: string, 단계: number|null}}
89
+ */
90
+ export function 계획답풀기(답, 단계수 = 0) {
91
+ const 원 = String(답 ?? '').trim();
92
+ if (!원) return { 갈래: '진행', 덧말: '', 단계: null };
93
+
94
+ const 첫낱말 = 원.split(/\s+/)[0].toLowerCase();
95
+ const 나머지 = 원.slice(원.split(/\s+/)[0].length).replace(사이표, '').trim();
96
+
97
+ if (['n', 'no', 'ㄴ', '아니', '아니요', '취소'].includes(첫낱말)) {
98
+ return { 갈래: '그만', 덧말: '', 단계: null };
99
+ }
100
+ if (['y', 'yes', 'ㅇ', '네', '예', '진행', 'ok', 'go'].includes(첫낱말)) {
101
+ return { 갈래: '진행', 덧말: 나머지, 단계: null };
102
+ }
103
+
104
+ // 「3 이건 빼자」 — 그 단계에 대고 하는 말이다.
105
+ const m = 수뒤.exec(원);
106
+ if (m) {
107
+ const n = Number(m[1]);
108
+ if (Number.isInteger(n) && n >= 1 && n <= 단계수) {
109
+ const 말 = 원.slice(m[0].length).replace(사이표, '').trim();
110
+ if (말) return { 갈래: '다시', 덧말: 말, 단계: n };
111
+ }
112
+ }
113
+
114
+ return { 갈래: '다시', 덧말: 원, 단계: null };
115
+ }
package/src/ui/screen.js CHANGED
@@ -31,6 +31,7 @@
31
31
  * 그대로 맞다. 스크롤·복사·`| tee` 도 그대로다.
32
32
  */
33
33
  import { c, say, cursor, box } from './ansi.js';
34
+ import { 말 } from '../i18n/index.js';
34
35
  import { statusLine, contextWarning, quotaWarning } from './status.js';
35
36
  import { spin } from './spinner.js';
36
37
 
@@ -81,6 +82,8 @@ export class LineScreen {
81
82
  * 이벤트가 들고 온 깊이를 화면에 알려 주는 쪽이 안 빠뜨린다.
82
83
  */
83
84
  this.깊이 = 0;
85
+ // 이번 턴에 '멈추는 중' 을 이미 적었나. 같은 줄을 되풀이해 찍지 않는다.
86
+ this.멈춘다고했나 = false;
84
87
  }
85
88
 
86
89
  // ── 흘러가는 글 ────────────────────────────────────────────────────────
@@ -190,9 +193,26 @@ export class LineScreen {
190
193
  * 대신 기다림()·생각() 이 그 자리를 이미 맡고 있다 — 여기서 또 그리면
191
194
  * 같은 말이 두 번 나간다. 파이프·기록에서는 돌아가는 표시 자체가 잡음이다.
192
195
  */
193
- 일시작() { }
194
- 일바꿈() { }
195
- 일끝() { }
196
+ 일시작() { this.멈춘다고했나 = false; }
197
+ /*
198
+ * 다만 '멈춤' 은 예외다 — 여기만 한 줄을 낸다.
199
+ *
200
+ * ESC 를 눌러도 줄 화면(`--no-tui`·파이프)에서는 **아무것도 안 나왔다.**
201
+ * 멈추는 데는 잠깐이 걸리는데(돌던 자식을 죽이고, 안 돈 도구 자리를 채우고,
202
+ * 대화를 성하게 닫는다) 그 사이 화면이 아까 하던 말을 그대로 하고 있으면
203
+ * 사람은 안 먹었다고 판단한다. 그러면 또 누르고, 그러다 Ctrl+C 로 손이
204
+ * 가고, 두 번 누르면 대화가 통째로 닫힌다 — 상자 화면에서 문구를 붙박이로
205
+ * 둔 것과 똑같은 이유다.
206
+ *
207
+ * 한 번만 낸다. ESC 를 세 번 누르면 같은 줄이 세 번 찍히는데, 그건
208
+ * 신호가 아니라 잡음이다.
209
+ */
210
+ 일바꿈(갈래) {
211
+ if (갈래 !== '멈춤' || this.멈춘다고했나) return;
212
+ this.멈춘다고했나 = true;
213
+ this.줄(` ${c.gray(말('run.stopping'))}`);
214
+ }
215
+ 일끝() { this.멈춘다고했나 = false; }
196
216
  // 줄 화면에는 사무실이 없다 — 받아만 두고 아무것도 안 한다.
197
217
  할일갱신() { }
198
218
  하위갱신() { }