deel-local-cli 1.20.11 → 1.20.13

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/src/setup.js CHANGED
@@ -12,7 +12,7 @@ import { 보관방식 } from './safety/keystore.js';
12
12
  import { 애저풀기, 애저base } from './backend/azure.js';
13
13
  import { allowEndpoint } from './safety/network.js';
14
14
  import { 인증서설정, 인증서등록 } from './backend/clientcert.js';
15
- import { 바깥인가 } from './safety/runmode.js';
15
+ import { 바깥인가, 봉인됐나 } from './safety/runmode.js';
16
16
  import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭 } from './providers/index.js';
17
17
  import { writeFileSync } from 'node:fs';
18
18
 
@@ -107,8 +107,21 @@ async function chooseModel(found) {
107
107
  return items[i].label;
108
108
  }
109
109
 
110
- // 진단을 돌리고 결과를 화면에 그린다.
111
- export async function runProbe(conn, { out = null } = {}) {
110
+ /*
111
+ * 진단을 돌리고 결과를 화면에 그린다.
112
+ *
113
+ * **봉인은 진단에도 걸린다.** 여기가 빠져 있었다 — 관리자가 정책으로 밖을
114
+ * 막아 둔 PC 에서도 `deel diagnose` 는 그냥 나갔다. 자물쇠가 명령 하나로
115
+ * 열리면 자물쇠가 아니다. 사내망 주소는 봉인에서도 그대로 간다.
116
+ */
117
+ export async function runProbe(conn, { out = null, 봉인 = false } = {}) {
118
+ if (봉인 && 바깥인가(conn.base)) {
119
+ say('');
120
+ say(` ${mark.no} ${c.red('봉인되어 있어 이 주소는 안 두드립니다.')} ${c.gray(conn.base)}`);
121
+ say(` ${c.gray('관리 정책이나 설정에 offline 이 켜져 있습니다. 사내망 주소는 그대로 됩니다.')}`);
122
+ say('');
123
+ return { facts: null, results: null, v: null, 봉인: true };
124
+ }
112
125
  allowEndpoint(conn.base); // 진단도 이 주소 하나로만 나간다
113
126
  renderHeader({ shape: conn.kind, base: conn.base, auth: conn.auth, model: conn.model });
114
127
  const { facts, results } = await probe(conn, renderLine);
@@ -344,9 +357,11 @@ export async function runSetup() {
344
357
  export async function runDiagnose(flags) {
345
358
  banner();
346
359
  let conn;
360
+ let 쓴설정 = null;
361
+ let 쓴프로필 = null;
347
362
 
348
363
  if (flags.url) {
349
- load(); // 설정의 api-version 을 먼저 읽는다 (runSetup 과 같은 이유)
364
+ 쓴설정 = load(); // 설정의 api-version 을 먼저 읽는다 (runSetup 과 같은 이유)
350
365
  const key = flags.key ?? process.env.DEEL_API_KEY ?? '';
351
366
  const found = await connect(flags.url, key);
352
367
  if (!found) return 1;
@@ -356,6 +371,8 @@ export async function runDiagnose(flags) {
356
371
  } else {
357
372
  const cfg = load();
358
373
  const prof = activeProfile(cfg);
374
+ 쓴설정 = cfg;
375
+ 쓴프로필 = prof;
359
376
  if (!prof) {
360
377
  say(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 을 먼저 실행하세요.`);
361
378
  say(` ${c.gray('또는')} deel diagnose --url <주소> --key <키> --model <모델>`);
@@ -377,7 +394,11 @@ export async function runDiagnose(flags) {
377
394
  };
378
395
  }
379
396
 
380
- await runProbe(conn, { out: flags.out ?? null });
397
+ /*
398
+ * 봉인은 --url 로 주소를 직접 준 갈래에도 걸린다. 오히려 그쪽이 더 위험하다
399
+ * — 설정에 없는 주소를 그 자리에서 두드리는 길이기 때문이다.
400
+ */
401
+ await runProbe(conn, { out: flags.out ?? null, 봉인: 봉인됐나({ 깃발: flags.offline, prof: 쓴프로필, cfg: 쓴설정 }) });
381
402
  return 0;
382
403
  }
383
404
 
@@ -11,6 +11,8 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs';
11
11
  import { join, basename, dirname, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { homedir } from 'node:os';
14
+ import { homeDir } from '../config.js';
15
+ import { pluginsDir } from '../plugins/manage.js';
14
16
  // 저장소에 딸려 온 스킬·명령이 시스템 글에 실리지 않게 한다 (discover 안구절 머리말).
15
17
  import { 믿나 } from '../safety/trust.js';
16
18
 
@@ -163,6 +165,7 @@ function countSkillDirs(rootDir) {
163
165
  */
164
166
  export function discover(root, opts = {}) {
165
167
  const home = opts.home ?? homedir();
168
+ const 살림 = opts.살림 ?? (opts.home ? join(opts.home, '.deel') : homeDir());
166
169
  const caps = { skills: opts.maxSkills ?? 400, commands: opts.maxCommands ?? 400 };
167
170
  const skills = [];
168
171
  const commands = [];
@@ -177,7 +180,13 @@ export function discover(root, opts = {}) {
177
180
  // 같은 플러그인이 cache/ 와 marketplaces/ 양쪽에 있을 수 있다.
178
181
  // 이름이 같으면 알맹이가 더 많은 쪽 하나만 쓴다.
179
182
  const roots = [];
180
- for (const base of [join(home, '.deel', 'plugins'), join(home, '.claude', 'plugins')]) {
183
+ /*
184
+ * 우리 플러그인은 **살림 자리** 밑이다 (`~/.deel` 또는 `DEEL_HOME`).
185
+ * OS 집 폴더에 `.deel` 을 직접 붙이면 `DEEL_HOME` 을 쓰는 휴대용 설치에서
186
+ * 깔아 놓은 플러그인의 스킬이 안 보인다 — 깔리는 자리(plugins/manage.js)와
187
+ * 여기가 갈리기 때문이다. 클로드 것(.claude)은 그쪽 규칙이라 집 폴더 그대로다.
188
+ */
189
+ for (const base of [pluginsDir(살림), join(home, '.claude', 'plugins')]) {
181
190
  if (existsSync(base)) roots.push(...findPluginRoots(base));
182
191
  }
183
192
  const best = new Map();
package/src/tools/fig.js CHANGED
@@ -241,7 +241,7 @@ function 가지풀기(뿌리, 자식표, 줄들, 깊이, 셈) {
241
241
  * @returns {{ok:true, 갈래:'fig', 덩이들:[{이름,문단들}], 말:string[], 노드수:number}
242
242
  * |{ok:false, error:string, 끝났다?:boolean}}
243
243
  */
244
- export function readFig(경로또는버퍼) {
244
+ export function readFig(경로또는버퍼, { 최대노드 = 노드최대 } = {}) {
245
245
  let buf;
246
246
  try {
247
247
  buf = Buffer.isBuffer(경로또는버퍼) ? 경로또는버퍼 : readFileSync(경로또는버퍼);
@@ -341,7 +341,8 @@ export function readFig(경로또는버퍼) {
341
341
  const 문서 = 뿌리들.find((n) => n.type === 'DOCUMENT') ?? 뿌리들[0];
342
342
  const 쪽들 = (자식표.get(열쇠(문서?.guid)) ?? []).filter((n) => n.type === 'CANVAS');
343
343
 
344
- const 셈 = { 남은: 노드최대 };
344
+ const 상한노드 = Math.max(1, Number(최대노드) || 노드최대);
345
+ const 셈 = { 남은: 상한노드 };
345
346
  const 덩이들 = [];
346
347
 
347
348
  const 개요 = [];
@@ -355,11 +356,24 @@ export function readFig(경로또는버퍼) {
355
356
 
356
357
  if (쪽들.length) {
357
358
  for (const 쪽 of 쪽들) {
359
+ const 아이들 = 자식표.get(열쇠(쪽.guid)) ?? [];
358
360
  const 줄들 = [];
359
- for (const 아이 of 자식표.get(열쇠(쪽.guid)) ?? []) 가지풀기(아이, 자식표, 줄들, 0, 셈);
361
+ for (const 아이 of 아이들) 가지풀기(아이, 자식표, 줄들, 0, 셈);
362
+ /*
363
+ * 상한에 걸려 못 편 쪽을 「비어 있습니다」 라고 적으면 안 된다.
364
+ *
365
+ * 앞쪽 쪽에서 상한을 다 써 버리면 뒤쪽은 한 줄도 안 나온다. 그때 「비어
366
+ * 있다」 고 적으면, 시안에 멀쩡히 들어 있는 화면을 사람도 모델도 없는
367
+ * 것으로 읽는다 — 없는 화면을 새로 그리게 된다. 안 폈다고 말한다.
368
+ */
369
+ const 못편것 = !줄들.length && 아이들.length > 0;
360
370
  덩이들.push({
361
371
  이름: String(쪽.name ?? '').trim() || '이름 없는 쪽',
362
- 문단들: 줄들.length ? 줄들 : ['(이 쪽은 비어 있습니다.)'],
372
+ 문단들: 줄들.length
373
+ ? 줄들
374
+ : [못편것
375
+ ? `(도형 ${상한노드.toLocaleString('en-US')}개 상한에 걸려 이 쪽은 못 폈습니다 — 비어 있는 것이 아닙니다.)`
376
+ : '(이 쪽은 비어 있습니다.)'],
363
377
  });
364
378
  }
365
379
  } else {
@@ -370,7 +384,7 @@ export function readFig(경로또는버퍼) {
370
384
  }
371
385
 
372
386
  if (셈.남은 <= 0) {
373
- 말.push(`도형이 ${노드최대.toLocaleString('en-US')}개를 넘어 거기까지만 폈습니다 — 뒷부분은 안 실렸습니다`);
387
+ 말.push(`도형이 ${상한노드.toLocaleString('en-US')}개를 넘어 거기까지만 폈습니다 — 뒷부분은 안 실렸습니다`);
374
388
  }
375
389
 
376
390
  return { ok: true, 갈래: 'fig', 덩이들, 말, 노드수: 노드들.length };
@@ -65,8 +65,13 @@ const 집간격 = 400; // 같은 집을 다시 두드리기 전에
65
65
  * 안 먹은 것과 똑같이 보인다 — 제보받은 그 증상이다.
66
66
  */
67
67
  const 잠깐 = (ms, signal = null) => new Promise((풀기) => {
68
- const t = setTimeout(풀기, ms);
69
- signal?.addEventListener?.('abort', () => { clearTimeout(t); 풀기(); }, { once: true });
68
+ // 이미 멈춘 신호를 들고 오면 abort 는 **다시 안 터진다.** 그 판에서
69
+ // 그냥 잠들면 ESC 를 누른 뒤에도 이 잠을 끝까지 잔다 — 고치려던 그 증상이
70
+ // 그대로 돌아온다.
71
+ if (signal?.aborted) return 풀기();
72
+ const 깨우기 = () => { clearTimeout(t); 풀기(); };
73
+ const t = setTimeout(() => { signal?.removeEventListener?.('abort', 깨우기); 풀기(); }, ms);
74
+ signal?.addEventListener?.('abort', 깨우기, { once: true });
70
75
  });
71
76
 
72
77
  function 한집씩(origin, 일, signal = null) {
@@ -79,8 +84,15 @@ function 한집씩(origin, 일, signal = null) {
79
84
  if (signal?.aborted) throw new Error('중단했습니다');
80
85
  return 일();
81
86
  });
82
- // 다음 사람이 기다리는 것은 '내가 끝났다' 뿐이다. 내가 실패해도 줄은 넘어간다.
83
- 집줄.set(origin, 내차례.then(() => {}, () => {}));
87
+ /*
88
+ * 다음 사람이 기다리는 것은 '내가 끝났다' 뿐이다. 내가 실패해도 줄은 넘어간다.
89
+ *
90
+ * 끝나면 **줄에서 뺀다.** 안 빼면 한참 뒤에 혼자 부르는 요청도 앞사람이
91
+ * 있는 것으로 보여 400ms 를 그냥 잔다 — 줄이 비어 있는데 서 있는 셈이다.
92
+ */
93
+ const 끝나면 = 내차례.then(() => {}, () => {});
94
+ 집줄.set(origin, 끝나면);
95
+ 끝나면.then(() => { if (집줄.get(origin) === 끝나면) 집줄.delete(origin); });
84
96
  return 내차례;
85
97
  }
86
98
 
@@ -133,15 +145,26 @@ function 태그벗기기(html) {
133
145
  if (글 === 전) break;
134
146
  }
135
147
  return 글
136
- .replace(/<\/(p|div|section|article|li|tr|h[1-6]|br)>/gi, '\n')
137
- .replace(/<br\s*\/?>/gi, '\n')
148
+ // 닫는 태그만 줄로 바꾸면, 닫는 태그를 안 적은 페이지가 통째로 한 줄이
149
+ // 된다 — `<p>첫째<p>둘째` 나 `<ul><li>하나<li>둘</ul>` 이 흔하다.
150
+ // HTML 이 그것을 허락하므로 옛 사내 페이지에는 정말로 그렇게 적혀 있다.
151
+ // 여는 태그도 같이 줄로 바꾼다.
152
+ .replace(/<\/?(p|div|section|article|li|tr|h[1-6]|br)\b[^>]*>/gi, '\n')
138
153
  .replace(/<[^>]+>/g, ' ')
139
154
  // 한 번에 찾아서 한 번에 바꾼다 — 차례로 바꾸면(`&amp;` 를 먼저 `&` 로
140
155
  // 풀고 나서 `&lt;` 를 다시 찾는 식) `&amp;lt;` 처럼 두 겹 씌운 것이
141
156
  // 두 번 풀려서 `<` 로 튀어나온다(글자로 남아야 하는데 태그처럼 보이게 됨).
142
- .replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (_, 이름) => ({
143
- nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'",
144
- }[이름]))
157
+ // 숫자 꼴(`&#48712;` · `&#x27;`)과 `&apos;` 도 같은 한 판에서 푼다.
158
+ // 안 풀면 한글 페이지 하나가 통째로 숫자 나열로 모델에게 간다.
159
+ .replace(/&(nbsp|amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-f]{1,6});/gi, (온것, 이름) => {
160
+ const 표 = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
161
+ const 키 = 이름.toLowerCase();
162
+ if (표[키] !== undefined) return 표[키];
163
+ const 번호 = 키.startsWith('#x') ? parseInt(키.slice(2), 16) : parseInt(키.slice(1), 10);
164
+ // 못 읽을 번호면 **손대지 않는다.** 지어낸 글자를 넣느니 원문이 낫다.
165
+ if (!Number.isFinite(번호) || 번호 < 32 || 번호 > 0x10ffff) return 온것;
166
+ try { return String.fromCodePoint(번호); } catch { return 온것; }
167
+ })
145
168
  .replace(/[ \t]+/g, ' ')
146
169
  .replace(/\n\s*\n\s*\n+/g, '\n\n')
147
170
  .trim();
@@ -243,6 +266,15 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
243
266
  // 되돌림(redirect)으로 옮겨 간 집도 그 한 번만 연다. 한 홉마다 문지기를 지나므로
244
267
  // 여기서 열어 주지 않으면 막힌다 — 그리고 사내망으로 되돌리는 것은 열지 않는다.
245
268
  const 열어둔 = [];
269
+ /*
270
+ * 되돌림을 따라가면 **정말 답한 곳**은 처음 주소가 아니다.
271
+ *
272
+ * 여태 기록에도 오류 문구에도 처음 주소만 적혔다. 그래서 짧은주소 하나가
273
+ * 302 로 딴 집에 보내 놓고 429 를 뱉으면, 화면에는 짧은주소가 힘들어한다고
274
+ * 뜬다 — 사람도 모델도 엉뚱한 쪽을 붙잡는다. 심사서에 남는 「어디로 나갔나」
275
+ * 도 마찬가지로 처음 주소만 남아서, 실제로 통신한 집이 빠진다.
276
+ */
277
+ let 닿은곳 = u;
246
278
  try {
247
279
  // 같은 집이면 줄을 서고, 잠시 뒤 되는 오류면 쉬었다 다시 부른다.
248
280
  let res = null;
@@ -264,10 +296,19 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
264
296
  stream: true, // 상한까지만 받는다 — 다 받아 놓고 버리지 않는다
265
297
  되돌림: async (다음) => {
266
298
  await 웹되돌림검사(다음, { allowPrivate });
299
+ 닿은곳 = 다음;
300
+ 방문기록.push({ url: 다음.href, status: 0, at: new Date().toISOString(), 되돌림: true });
267
301
  열어둔.push(allowTemporarily(다음.origin));
268
302
  },
269
303
  }), signal);
270
- 방문기록.push({ url: u.href, status: res.status, at: new Date().toISOString() });
304
+ 방문기록.push({
305
+ url: u.href,
306
+ status: res.status,
307
+ at: new Date().toISOString(),
308
+ // 되돌림을 탔으면 **정말 답한 곳**도 같이 적는다. 처음 주소만 남기면
309
+ // 심사서의 「어디로 나갔나」 에서 실제로 통신한 집이 빠진다.
310
+ ...(닿은곳.href === u.href ? {} : { 닿은곳: 닿은곳.href }),
311
+ });
271
312
  if (res.ok || !다시할것.has(res.status) || 회차 >= 다시횟수) break;
272
313
  await res.버리기?.();
273
314
  const 초 = 얼마나쉬라나(res.headers.get('retry-after'), 회차);
@@ -282,14 +323,14 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
282
323
  * 오류를 그냥 번호로만 던지면 모델은 할 수 있는 게 없다. 실제로 그랬다 —
283
324
  * `✗ HTTP 429` 만 보고 그 자료를 포기했다. 무엇을 하면 되는지 같이 준다.
284
325
  */
285
- const 집 = u.hostname;
326
+ const 집 = 닿은곳.hostname;
286
327
  if (res.status === 429) {
287
328
  return { error: `HTTP 429 — ${집} 가 "너무 자주 부른다" 고 합니다.`
288
329
  + `\n ${다시횟수}번 쉬었다 다시 불러 봤습니다(${쉰시간}초). 그래도 같습니다.`
289
330
  + '\n 한꺼번에 여러 개를 부르지 말고 하나씩 부르거나, 잠시 뒤에 다시 해 보세요.'
290
331
  + '\n 키가 있는 API 면 키를 붙인 주소를 쓰면 한도가 늘어납니다.' };
291
332
  }
292
- if (res.status === 404) return { error: `HTTP 404 — 그런 쪽이 없습니다: ${u.href}\n 주소를 다시 확인하세요.` };
333
+ if (res.status === 404) return { error: `HTTP 404 — 그런 쪽이 없습니다: ${닿은곳.href}\n 주소를 다시 확인하세요.` };
293
334
  if (res.status === 401 || res.status === 403) {
294
335
  return { error: `HTTP ${res.status} — ${집} 가 접근을 막았습니다.\n 로그인이나 키가 있어야 하는 쪽입니다. 이 도구로는 못 읽습니다.` };
295
336
  }
@@ -297,11 +338,23 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
297
338
  return { error: `HTTP ${res.status} — ${집} 가 지금 힘들어합니다.`
298
339
  + `\n ${다시횟수}번 다시 불러 봤습니다(${쉰시간}초). 잠시 뒤에 다시 해 보세요.` };
299
340
  }
300
- return { error: `HTTP ${res.status} — ${u.href}` };
341
+ return { error: `HTTP ${res.status} — ${닿은곳.href}` };
301
342
  }
302
343
 
303
344
  const type = (res.headers.get('content-type') ?? '').toLowerCase();
304
- if (!/text|json|xml|javascript/.test(type)) {
345
+ /*
346
+ * 갈래는 **낱말로** 본다.
347
+ *
348
+ * 그냥 `/text|json|xml|javascript/` 로 보면 두 쪽에서 틀린다.
349
+ * `application/octet-stream; name="context.bin"` 은 'context' 안의 text 에
350
+ * 걸려 통과하고(내려받은 바이너리가 모델에게 간다), `application/yaml` 이나
351
+ * `application/toml` 같은 멀쩡한 글은 낱말이 없어서 거절당한다.
352
+ */
353
+ const 갈래 = type.split(';')[0].trim();
354
+ const 뒷말 = 갈래.split('/')[1] ?? '';
355
+ const 글인가 = /^text\//.test(갈래)
356
+ || /(^|[-+.])(json|xml|javascript|ecmascript|yaml|toml|csv|ndjson|graphql)$/.test(뒷말);
357
+ if (!글인가) {
305
358
  await res.버리기?.();
306
359
  return { error: `글이 아닌 내용입니다 (${type || '알 수 없음'}). 이 도구는 글만 읽습니다.` };
307
360
  }
@@ -323,12 +376,23 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
323
376
  const 머리글 = /charset=["']?([\w-]+)/i.exec(type)?.[1]?.toLowerCase() ?? null;
324
377
  let text = 웹글읽기(buf, 머리글);
325
378
  if (/html/.test(type)) text = 태그벗기기(text);
326
- // <meta charset> 이 머리글과 다르게 적혀 있는 페이지가 있다. 깨졌으면 그걸 믿고 다시 읽는다.
327
- if (!머리글 && text.includes('�')) {
379
+ /*
380
+ * `<meta charset>` 이 머리글과 **다르게** 적혀 있는 페이지가 있다.
381
+ *
382
+ * 여기 `!머리글` 이 붙어 있어서, 정작 그 판 — 머리글이 있는데 틀린 판 —
383
+ * 에서는 한 번도 안 돌았다. 서버가 기본값으로 `charset=utf-8` 을 붙이고
384
+ * 본문은 EUC-KR 인 옛 사내 위키가 딱 그렇다. 깨진 글이 그대로 모델에게
385
+ * 가고, 모델은 깨진 채로 요약한다.
386
+ *
387
+ * 다시 읽어서 **덜 깨진 쪽**을 쓴다. 세어 보고 고르니 더 나빠질 일이 없다.
388
+ */
389
+ const 깨진수 = (그것) => (그것.match(/�/g) ?? []).length;
390
+ if (깨진수(text)) {
328
391
  const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(buf.toString('latin1').slice(0, 2000))?.[1]?.toLowerCase();
329
- if (meta) {
330
- text = 웹글읽기(buf, meta);
331
- if (/html/.test(type)) text = 태그벗기기(text);
392
+ if (meta && meta !== 머리글) {
393
+ let 다시 = 웹글읽기(buf, meta);
394
+ if (/html/.test(type)) 다시 = 태그벗기기(다시);
395
+ if (깨진수(다시) < 깨진수(text)) text = 다시;
332
396
  }
333
397
  }
334
398
  /*
@@ -365,7 +429,7 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
365
429
  ? `\n\n(여기서 잘렸습니다 — ${남은것}자가 더 있습니다.`
366
430
  + '\n 잘린 JSON 은 그대로 읽을 수 없습니다. 다음 중 하나를 하세요:'
367
431
  + '\n · 범위를 좁혀 다시 부른다 (per_page·ids·days 같은 조건을 붙인다)'
368
- + `\n · 같은 주소를 max_chars 를 올려 다시 부른다 (지금 ${max.toLocaleString()}, 최대 100,000))`
432
+ + `\n · 같은 주소를 max_chars 를 올려 다시 부른다 (지금 ${max.toLocaleString()}, 최대 120,000)`
369
433
  : `\n\n(뒤쪽 ${남은것}자는 잘렸습니다. 더 필요하면 max_chars 를 올려 다시 부르세요.)`;
370
434
  }
371
435
 
package/src/ui/md.js CHANGED
@@ -57,13 +57,22 @@ export function 인라인(글) {
57
57
  // 자리표시자로 읽혀 엉뚱한 글자로 바뀐다 — 모델이 한 말과 화면이 달라진다.
58
58
  let s = String(글 ?? '').replace(/\x00/g, '');
59
59
  const 뺀것 = [];
60
- s = s.replace(/`([^`]+)`/g, (_, 안) => {
60
+ // 백틱은 **같은 수만큼** 짝을 짓는다. 하나만 보면 ``a`` 나 ```x``` 처럼 겹쳐
61
+ // 적은 코드가 안쪽만 걸려서 바깥 백틱이 화면에 그대로 남는다.
62
+ s = s.replace(/(`+)([\s\S]+?)\1/g, (_, __, 안) => {
61
63
  뺀것.push(코드(안));
62
64
  return `\x00${뺀것.length - 1}\x00`;
63
65
  });
64
66
 
65
- s = s.replace(/\*\*([^*]+)\*\*/g, (_, 안) => 굵게(안));
66
- s = s.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=$|[\s.,)])/g, (_, 앞, 안) => `${앞}${c.italic(안)}`);
67
+ // 별표 안쪽이 공백으로 시작하거나 끝나면 굵게가 아니다. 안 막으면 파이썬의
68
+ // 거듭제곱을 적은 `2 ** 3 ** 4` 가 화면에서 `2 3 4` 가 된다 — 별표가
69
+ // 소리 없이 사라지고 사람은 모델이 그렇게 말한 줄 안다.
70
+ s = s.replace(/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*/g, (_, 안) => 굵게(안));
71
+ // 닫는 별표 뒤는 별표만 아니면 된다. `[\s.,)]` 로 좁혀 두면 조사가 붙는
72
+ // 한국어(`*중요*입니다`)와 `*주의*!` 에서 별표가 화면에 그대로 남는다.
73
+ // 굵게와 같이, 안쪽이 공백으로 끝나면 기울임이 아니다 — `5 *x * y` 의 별표를
74
+ // 먹으면 화면에서 별표가 사라진다.
75
+ s = s.replace(/(^|[\s(])\*([^*\s](?:[^*]*[^*\s])?)\*(?=$|[^*])/g, (_, 앞, 안) => `${앞}${c.italic(안)}`);
67
76
  s = s.replace(/~~([^~]+)~~/g, (_, 안) => c.gray(안));
68
77
  // 링크는 글만 보이고 주소는 흐리게. 주소를 지우면 따라갈 수가 없다.
69
78
  s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, 글자, 주소) => `${c.under(글자)} ${c.gray(주소)}`);
@@ -86,13 +95,30 @@ export function 한줄(줄, 상태 = { 펜스: false }, 폭 = 80) {
86
95
  const s = String(줄 ?? '').replace(/\r+$/, '');
87
96
 
88
97
  // 코드 울타리. 여는 줄에 언어가 붙어 있으면 그걸 알려 준다.
89
- const 울타리 = s.match(/^\s*```(.*)$/);
98
+ //
99
+ // 언어 자리에 백틱이 또 들어 있으면 울타리가 아니다 — 마크다운 규칙이 그렇고,
100
+ // 안 막으면 ```` ```js console.log(1)``` ```` 처럼 한 줄에 다 적은 코드가
101
+ // 울타리를 **열기만** 한다. 그 뒤로 오는 답 전체가 코드로 그려진다 — 제목도
102
+ // 굵기도 다 사라지고, 화면은 멀쩡해 보인다.
103
+ const 울타리 = s.match(/^\s*(`{3,})[ \t]*([^`]*?)[ \t]*$/);
90
104
  if (울타리) {
91
- const 안쪽 = 울타리[1].trim();
105
+ const 길이 = 울타리[1].length;
106
+ const 안쪽 = 울타리[2];
107
+ const 줄긋기 = (앞) => c.gray(앞 + '─'.repeat(Math.max(2, Math.min(30, 폭 - 6))));
108
+ if (상태.펜스) {
109
+ /*
110
+ * 닫는 울타리는 언어를 못 갖고, 연 것보다 짧을 수도 없다.
111
+ *
112
+ * 마크다운 문서를 보여 주려고 백틱 넷으로 열고 그 안에 ```js 를 적는 일이
113
+ * 흔하다. 안 막으면 그 ```js 가 상자를 일찍 닫아 버린다 — 나머지 코드가
114
+ * 코드가 아닌 것으로 그려지고, 상자는 열자마자 닫힌 빈 상자가 된다.
115
+ */
116
+ if (안쪽 || 길이 < (상태.울타리길이 ?? 길이)) return { 글: `${c.gray('│')} ${c.white(s)}`, 상태 };
117
+ return { 글: 줄긋기('└'), 상태: { ...상태, 펜스: false, 울타리길이: 0 } };
118
+ }
92
119
  return {
93
- 글: 상태.펜스 ? c.gray('└' + '─'.repeat(Math.max(2, Math.min(30, 폭 - 6))))
94
- : c.gray('┌' + '─'.repeat(Math.max(2, Math.min(30, 폭 - 6))) + (안쪽 ? ` ${안쪽}` : '')),
95
- 상태: { ...상태, 펜스: !상태.펜스 },
120
+ 글: 줄긋기('┌') + (안쪽 ? c.gray(` ${안쪽}`) : ''),
121
+ 상태: { ...상태, 펜스: true, 울타리길이: 길이 },
96
122
  };
97
123
  }
98
124
 
@@ -108,9 +134,11 @@ export function 한줄(줄, 상태 = { 펜스: false }, 폭 = 80) {
108
134
  const 제목 = s.match(/^(#{1,6})\s+(.*)$/);
109
135
  if (제목) {
110
136
  const 깊이 = 제목[1].length;
111
- const 글자 = 인라인(제목[2]);
112
- if (깊이 <= 2) return { 글: `${c.hcyan('▍')} ${c.bold(글자)}`, 상태 };
113
- return { 글: c.bold(글자), 상태 };
137
+ // 이어굵게 로 감싼다. c.bold 로 감싸면 제목 안의 코드가 제 색을 끝내면서
138
+ // 굵기까지 꺼 버려서, 코드 뒷부분만 가늘어진다.
139
+ const 글자 = 이어굵게(인라인(제목[2]));
140
+ if (깊이 <= 2) return { 글: `${c.hcyan('▍')} ${글자}`, 상태 };
141
+ return { 글: 글자, 상태 };
114
142
  }
115
143
 
116
144
  // 인용
@@ -118,10 +146,14 @@ export function 한줄(줄, 상태 = { 펜스: false }, 폭 = 80) {
118
146
  if (인용) return { 글: `${c.gray('│')} ${이어흐리게(인라인(인용[1]))}`, 상태 };
119
147
 
120
148
  // 할 일 목록은 목록보다 먼저 본다 — `- [ ]` 가 그냥 목록으로 먹히면 안 된다.
121
- const 할일 = s.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/);
149
+ // 뒤에 글이 없는 빈 칸(`- [ ]`)도 할 일이다. 뒷글을 요구하면 그것만 글머리표로
150
+ // 빠져서 화면에 `• [ ]` 라고 대괄호가 그대로 뜬다. 다만 대괄호에 글이 바로 붙은
151
+ // `- [ ]foo` 는 할 일이 아니다 — 마크다운도 그렇게 안 읽는다.
152
+ const 할일 = s.match(/^(\s*)[-*+]\s+\[([ xX])\](\s+.*)?$/);
122
153
  if (할일) {
123
154
  const 표 = 할일[2] === ' ' ? c.gray('☐') : c.green('☑');
124
- return { 글: `${할일[1]}${표} ${인라인(할일[3])}`, 상태 };
155
+ const 뒤 = 할일[3]?.trim() ? ` ${인라인(할일[3].trim())}` : '';
156
+ return { 글: `${할일[1]}${표}${뒤}`, 상태 };
125
157
  }
126
158
 
127
159
  // 글머리표
@@ -132,8 +164,9 @@ export function 한줄(줄, 상태 = { 펜스: false }, 폭 = 80) {
132
164
  const 번호 = s.match(/^(\s*)(\d+)([.)])\s+(.*)$/);
133
165
  if (번호) return { 글: `${번호[1]}${c.hcyan(번호[2] + 번호[3])} ${인라인(번호[4])}`, 상태 };
134
166
 
135
- // 표는 세로줄만 흐리게 한다. 칸을 맞추려면 표 전체를 들고 있어야 하는데,
136
- // 그러면 표가 끝날 때까지 화면에 아무것도 안 나온다.
167
+ // 여기는 **표가 아니었던 것**이 오는 자리다. 진짜 표는 마크다운 클래스가
168
+ // 모아서 표그리기() 로 칸을 맞춰 그린다. 여기서는 칸 너비를 모르므로
169
+ // 세로줄만 흐리게 해서 내보낸다.
137
170
  if (/^\s*\|.*\|\s*$/.test(s)) {
138
171
  // 가름줄(`|---|---|`). 그대로 두면 화면에 작대기가 그대로 뜬다.
139
172
  // 바로 앞 머리줄의 칸 너비를 기억해 뒀다가 그 자리에 맞춰 긋는다 —
@@ -182,6 +215,10 @@ const 표줄인가 = (s) => /^\s*\|.*\|\s*$/.test(String(s ?? '').replace(/\r+$/
182
215
  /** `---` `:---` `---:` `:---:` — 가름줄의 한 칸인가. */
183
216
  const 가름칸인가 = (칸) => /^:?-+:?$/.test(String(칸).trim());
184
217
 
218
+ /** 가름줄이 몇 번째 줄인가. 머리줄이 위에 있어야 하므로 1 보다 작으면 표가 아니다. */
219
+ const 가름찾기 = (행들) =>
220
+ 행들.findIndex((행) => 행.length > 0 && 행.every(가름칸인가));
221
+
185
222
  /**
186
223
  * 한 줄을 칸으로 가른다.
187
224
  *
@@ -232,10 +269,27 @@ function 채우기(글, 폭, 쪽) {
232
269
  * 설명 칸이다. 줄인 칸은 잘라 버리지 않고 접는다(접어쓰기). 표에서 글자가
233
270
  * 사라지면 그 줄은 아예 못 읽는다.
234
271
  */
235
- export function 표그리기(줄들, 폭 = 80) {
272
+ export function 표그리기(줄들, 폭 = 80, 어떻게 = {}) {
273
+ return 짜기(줄들, 폭, 어떻게)?.화면 ?? null;
274
+ }
275
+
276
+ /**
277
+ * 그 표를 그렸다면 칸 너비가 얼마였을까. 표가 아니면 null.
278
+ *
279
+ * 상한에 걸려 표를 나눠 그릴 때, 뒤 묶음에 앞 묶음의 너비를 그대로 물려 주려고
280
+ * 쓴다. 묶음마다 제 폭을 다시 재면 같은 표인데 위아래의 세로줄이 안 맞는다.
281
+ */
282
+ export function 표칸너비(줄들, 폭 = 80) {
283
+ return 짜기(줄들, 폭)?.너비 ?? null;
284
+ }
285
+
286
+ function 짜기(줄들, 폭 = 80, { 너비고정 = null } = {}) {
236
287
  const 행들 = (줄들 ?? []).map(칸가르기);
237
288
  // 가름줄이 있어야 표다. 첫 줄일 수는 없다 — 머리줄이 그 위에 있어야 한다.
238
- const 가름 = 행들.findIndex((행) => 행.length > 1 && 행.every(가름칸인가));
289
+ //
290
+ // 칸이 하나뿐인 표(`| 항목 |`)도 표다. 칸 둘을 요구하면 그것만 표로 안 읽혀
291
+ // 테두리 없이 세로줄만 그어진 채 나가고, 가름줄의 작대기도 화면에 남는다.
292
+ const 가름 = 가름찾기(행들);
239
293
  if (가름 < 1) return null;
240
294
 
241
295
  const 열수 = Math.max(...행들.map((행) => 행.length));
@@ -253,9 +307,14 @@ export function 표그리기(줄들, 폭 = 80) {
253
307
  */
254
308
  const 치레 = 3 * 열수 + 1;
255
309
  const 쓸수있는 = Math.max(열수, 폭 - 치레);
256
- const 너비 = Array.from({ length: 열수 }, (_, i) => Math.max(1, ...몸.map((행) => width(행[i]))));
310
+ // 앞 묶음이 쓰던 너비가 있으면 그대로 쓴다. 다시 재면 같은 표의 위아래가
311
+ // 서로 다른 폭으로 그려진다 — 세로줄이 이음매에서 어긋난다.
312
+ const 물려받나 = Array.isArray(너비고정) && 너비고정.length === 열수;
313
+ const 너비 = 물려받나
314
+ ? 너비고정.map((w) => Math.max(1, w))
315
+ : Array.from({ length: 열수 }, (_, i) => Math.max(1, ...몸.map((행) => width(행[i]))));
257
316
  let 합 = 너비.reduce((a, b) => a + b, 0);
258
- while (합 > 쓸수있는) {
317
+ while (!물려받나 && 합 > 쓸수있는) {
259
318
  let 큰 = 0;
260
319
  for (let i = 1; i < 너비.length; i += 1) if (너비[i] > 너비[큰]) 큰 = i;
261
320
  if (너비[큰] <= 1) break;
@@ -285,7 +344,7 @@ export function 표그리기(줄들, 폭 = 80) {
285
344
  화면.push(...한행(i === 0 ? 행.map(이어굵게) : 행));
286
345
  화면.push(i === 몸.length - 1 ? 가로('└', '┴', '┘') : 가로('├', '┼', '┤'));
287
346
  });
288
- return 화면;
347
+ return { 화면, 너비 };
289
348
  }
290
349
 
291
350
  /**
@@ -308,6 +367,10 @@ export class 마크다운 {
308
367
  this.날것중 = false;
309
368
  // 그리려고 붙들고 있는 표 줄들. 표가 끝나는 순간 칸을 맞춰 한꺼번에 나간다.
310
369
  this.표줄들 = [];
370
+ // 상한에 걸려 끊은 표의 머리줄(가름줄까지)과 그때 쓰던 칸 너비.
371
+ // 다음 묶음이 같은 표로 이어 그려지도록 물려준다.
372
+ this.이어갈머리 = null;
373
+ this.표너비 = null;
311
374
  }
312
375
 
313
376
  /** 붙들고 있던 표를 내보낸다. 표가 아니었으면 옛 방식으로 한 줄씩 내보낸다. */
@@ -315,8 +378,23 @@ export class 마크다운 {
315
378
  const 줄들 = this.표줄들;
316
379
  this.표줄들 = [];
317
380
  if (!줄들.length) return [];
318
- const 그린것 = 표그리기(줄들, this.폭);
319
- if (그린것) return 그린것;
381
+ let 쓴것 = 줄들;
382
+ let 그린것 = 표그리기(줄들, this.폭, { 너비고정: this.표너비 });
383
+ /*
384
+ * 제 가름줄이 없으면 상한에 걸려 끊긴 표의 뒷동강이다. 그때만 앞 묶음의
385
+ * 머리줄을 얹어 다시 그린다. 무조건 얹으면, 상한 직후에 진짜 새 표가
386
+ * 시작될 때 남의 머리줄이 그 표에 섞여 들어간다.
387
+ */
388
+ if (!그린것 && this.이어갈머리) {
389
+ 쓴것 = [...this.이어갈머리, ...줄들];
390
+ 그린것 = 표그리기(쓴것, this.폭, { 너비고정: this.표너비 });
391
+ }
392
+ if (그린것) {
393
+ // 상한에 걸려 끊기는 묶음일 때만 너비를 기억해 둔다. 여느 표는 한 번에
394
+ // 다 나가므로 물려줄 데가 없다 — 그때까지 재면 헛일을 한 번 더 하는 것이다.
395
+ if (this.표너비 === null && 줄들.length >= 표붙들상한) this.표너비 = 표칸너비(쓴것, this.폭);
396
+ return 그린것;
397
+ }
320
398
  const 나갈것 = [];
321
399
  for (const 줄 of 줄들) {
322
400
  const r = 한줄(줄, this.상태, this.폭);
@@ -343,9 +421,22 @@ export class 마크다운 {
343
421
  */
344
422
  if (!this.상태.펜스 && 표줄인가(줄)) {
345
423
  this.표줄들.push(줄);
346
- return this.표줄들.length >= 표붙들상한 ? this.#표비우기() : [];
424
+ if (this.표줄들.length < 표붙들상한) return [];
425
+ /*
426
+ * 여기서 끊는다. 다음 묶음이 표로 안 읽히지 않도록 머리줄을 기억해 둔다 —
427
+ * 세 번째 묶음까지 갈 수 있으므로, 이 묶음에 제 머리줄이 없으면 앞서
428
+ * 기억해 둔 것을 그대로 들고 간다.
429
+ */
430
+ const 가름 = 가름찾기(this.표줄들.map(칸가르기));
431
+ const 머리 = 가름 >= 1 ? this.표줄들.slice(0, 가름 + 1) : this.이어갈머리;
432
+ const 나갈것 = this.#표비우기();
433
+ this.이어갈머리 = 머리;
434
+ return 나갈것;
347
435
  }
348
436
 
437
+ // 표가 끝났다. 다음 표는 제 폭을 처음부터 다시 잰다.
438
+ this.이어갈머리 = null;
439
+ this.표너비 = null;
349
440
  const 앞 = this.#표비우기();
350
441
  const r = 한줄(줄, this.상태, this.폭);
351
442
  this.상태 = r.상태;
@@ -377,8 +468,11 @@ export class 마크다운 {
377
468
  * 그래도 상한은 둔다. 표가 아닌데 세로줄로 시작하는 긴 글(로그 한 덩이
378
469
  * 같은 것)이 오면 화면이 멈춘 것처럼 보이면 안 된다.
379
470
  */
471
+ // 지금 담긴 것이 세로줄로 시작할 때만 기다린다. 「표를 붙들고 있으면
472
+ // 무조건 기다린다」 로 두면, 표 뒤에 이어지는 긴 산문이 줄이 끝날 때까지
473
+ // 화면에 한 글자도 안 나온다 — 사람 눈에는 답이 멈춘 것으로 보인다.
380
474
  const 표기다림 = !this.상태.펜스
381
- && (this.표줄들.length > 0 || /^\s*\|/.test(this.담긴것))
475
+ && /^\s*\|/.test(this.담긴것)
382
476
  && width(this.담긴것) <= this.폭 * 표줄최대배;
383
477
  if (표기다림) break;
384
478
 
@@ -415,9 +509,13 @@ export class 마크다운 {
415
509
 
416
510
  /** 통째로 있는 글을 한 번에 그린다 (스트리밍이 아닌 자리). */
417
511
  export function 그리기(글, 폭 = 80) {
512
+ const s = String(글 ?? '');
418
513
  const md = new 마크다운({ 폭 });
419
514
  const 줄들 = [];
420
- for (const x of md.넣기(String(글 ?? ''))) 줄들.push(typeof x === 'string' ? x : x.이어붙임);
515
+ // 마지막 줄에 개행을 얹어 준다. 흘려보내는 자리가 아니므로 줄이 안 끝날 일이
516
+ // 없는데, 안 얹으면 마지막 줄이 폭을 넘는 순간 「아직 안 끝난 줄」 로 보여
517
+ // 그 줄만 날것으로 나간다 — 별표와 우물정자가 거기서만 화면에 남는다.
518
+ for (const x of md.넣기(s && !s.endsWith('\n') ? `${s}\n` : s)) 줄들.push(typeof x === 'string' ? x : x.이어붙임);
421
519
  for (const x of md.끝()) 줄들.push(typeof x === 'string' ? x : x.이어붙임);
422
520
  return 줄들;
423
521
  }
package/src/ui/screen.js CHANGED
@@ -210,6 +210,19 @@ export class LineScreen {
210
210
  * 오히려 그때그때 상태를 알 수 있어 낫다.
211
211
  */
212
212
  입력자리(session) {
213
+ /*
214
+ * 곧 지워질 글부터 걷는다.
215
+ *
216
+ * 이 줄이 없었다. 상자화면 쪽 같은 이름 메서드는 임시지움() 을 먼저
217
+ * 부르는데 여기만 안 불렀다 — 그런데 아래 say('') 는 이 화면의 줄() 이
218
+ * 아니라 ansi.js 의 say 라, 임시글을 걷는 문이 통째로 안 열렸다.
219
+ *
220
+ * 기다림() 은 `\r` 로 커서만 앞으로 보내 놓는다. 그 상태에서 say('') 가
221
+ * 줄바꿈을 내면 커서만 아래로 내려가고 **「생각 중…」 은 화면에 그대로
222
+ * 남는다.** 그 아래 상태줄이 붙으므로, 끝난 일의 문구가 입력칸 위에
223
+ * 붙박인 것처럼 보인다.
224
+ */
225
+ this.임시지움();
213
226
  say('');
214
227
  say(statusLine(session));
215
228
  const w = contextWarning(session);