deel-local-cli 1.20.10 → 1.20.12

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.
@@ -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/intro.js CHANGED
@@ -35,6 +35,7 @@
35
35
  import { c, cursor, width } from './ansi.js';
36
36
  import { 말 } from '../i18n/index.js';
37
37
  import * as 큰이름 from './banner.js';
38
+ import { 끄라는값인가 } from './office.js';
38
39
 
39
40
  const 앞 = 'deel';
40
41
  const 꼬리 = '-local';
@@ -42,7 +43,7 @@ const 이름폭 = 앞.length + 꼬리.length; // 10
42
43
  const 들여 = ' ';
43
44
 
44
45
  /** 모션을 끄는 스위치. 그림 돌림표(motion.js)와 같은 스위치를 함께 본다. */
45
- export const 끔 = () => !!(process.env.DEEL_NO_INTRO || process.env.DEEL_NO_MOTION);
46
+ export const 끔 = () => 끄라는값인가(process.env.DEEL_NO_INTRO) || 끄라는값인가();
46
47
 
47
48
  /**
48
49
  * 한 틀에 얼마나 머무나.
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/motion.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * 그림을 점자 코드로 직접 적으면 나중에 아무도 못 고친다. 그래서 눈에 보이는
29
29
  * 대로 '#' 과 '.' 으로 적고, 켜질 때 한 번 점자로 바꾼다.
30
30
  */
31
- import { 켜달라했나 as 사무실켜달라했나 } from './office.js';
31
+ import { 켜달라했나 as 사무실켜달라했나, 끄라는값인가 } from './office.js';
32
32
 
33
33
  // 점 번호와 자리. 점자는 이렇게 번호가 매겨져 있다 —
34
34
  // 1 4
@@ -183,7 +183,11 @@ export const 그림들 = {
183
183
  * 한쪽으로만 지나가는 것과 왔다 갔다 하는 것은 느낌이 다르다. 앞의 것은
184
184
  * '진행 중' 으로 읽히고, 뒤의 것은 '기다리는 중' 으로 읽힌다. 45초가 넘은
185
185
  * 자리에서 진행 중인 척하면 곧 끝날 것처럼 보여서 오히려 답답해진다.
186
- * 열 장이라 한 바퀴가 0.9초 — 다른 것들보다 느긋하다.
186
+ *
187
+ * 여덟 장이라 한 바퀴가 0.72초 — 이 테마에서 제일 길다(다음이 다섯 장).
188
+ * 기사·동물 쪽 느긋은 열 장(0.9초)인데, 그쪽은 넘어졌다 일어나는 그림이라
189
+ * 한 동작에 장이 더 든다. 세 테마가 같을 이유는 없고, 검사가 재는 것도
190
+ * 「제 테마 안에서 느긋이 제일 길다」 이지 장수 자체가 아니다.
187
191
  */
188
192
  느긋: [
189
193
  ['......', '##....', '##....', '......'],
@@ -510,13 +514,31 @@ const 이름표 = {
510
514
  */
511
515
  let 고른그림 = null;
512
516
  export function 그림고르기설정(값) {
513
- 고른그림 = 이름표[String(값 ?? '').trim().toLowerCase()] ?? 이름표[String(값 ?? '').trim()] ?? null;
517
+ // 소문자로 한 번만 찾는다. 이름표에는 대문자 열쇠가 하나도 없어서, 원본
518
+ // 그대로 한 번 더 찾던 자리는 어떤 입력에도 안 걸리는 죽은 규칙이었다.
519
+ 고른그림 = 이름표[String(값 ?? '').trim().toLowerCase()] ?? null;
520
+ }
521
+
522
+ /**
523
+ * `DEEL_MOTION` 이 **실제로 이기고 있나.**
524
+ *
525
+ * `/motion` 은 「환경변수가 켜져 있어 그쪽이 이깁니다」 를 `process.env.DEEL_MOTION`
526
+ * 이 비어 있지 않다는 것만 보고 띄우고 있었다. 그런데 `DEEL_MOTION=cat` 은
527
+ * 우리가 아는 이름이 아니라 **그냥 무시되고 설정 파일이 그대로 쓰인다.**
528
+ * 그러면 화면이 하는 말이 사실과 반대다 — 사람은 설정을 바꿔도 소용없다고
529
+ * 믿고 손을 떼는데, 정작 이기고 있는 것은 그 설정이다.
530
+ *
531
+ * @returns {null|{값: string, 이름: string|null, 이김: boolean}}
532
+ * 안 적혀 있으면 null. 적혀 있으면 그 값과, 우리가 아는 이름인지.
533
+ */
534
+ export function 환경그림() {
535
+ const 값 = String(process.env.DEEL_MOTION ?? '').trim();
536
+ if (!값) return null;
537
+ const 이름 = 이름표[값.toLowerCase()] ?? null;
538
+ return { 값, 이름, 이김: 이름 !== null };
514
539
  }
515
540
 
516
- export const 테마이름 = () => 이름표[String(process.env.DEEL_MOTION ?? '').trim().toLowerCase()]
517
- ?? 이름표[String(process.env.DEEL_MOTION ?? '').trim()]
518
- ?? 고른그림
519
- ?? '기본';
541
+ export const 테마이름 = () => 환경그림()?.이름 ?? 고른그림 ?? '기본';
520
542
  export const 테마 = () => 테마들[테마이름()] ?? 테마들.기본;
521
543
 
522
544
  /*
@@ -529,7 +551,15 @@ export const 테마 = () => 테마들[테마이름()] ?? 테마들.기본;
529
551
  let 설정으로껐나 = false;
530
552
  /** `/motion 끔` 이 여기에 넣는다. 환경변수는 따로 살아 있고, 둘 중 하나면 꺼진다. */
531
553
  export function 끔설정(값) { 설정으로껐나 = !!값; }
532
- export const 끔 = () => !!process.env.DEEL_NO_MOTION || 설정으로껐나;
554
+ /**
555
+ * 환경변수 쪽이 꺼 놨나. `/motion` 이 「바꿨습니다」 라고 말해도 되는지 여기서 본다.
556
+ *
557
+ * 끔() 하나로는 모자란다 — 그건 「설정으로 껐다」 와 「환경변수가 껐다」 를
558
+ * 뭉쳐서 준다. 앞엣것은 방금 사람이 고른 것이고, 뒤엣것은 사람이 무엇을 골라도
559
+ * 안 바뀌는 것이다. 둘을 뭉치면 「고른 것이 안 먹힌다」 를 말해 줄 수가 없다.
560
+ */
561
+ export const 환경으로껐나 = () => 끄라는값인가();
562
+ export const 끔 = () => 환경으로껐나() || 설정으로껐나;
533
563
 
534
564
  // 못 끄는 자리에서 쓸 한 칸짜리. working.js 의 돌림틀과 같은 것이다.
535
565
  const 한칸 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -542,7 +572,8 @@ const 한칸 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '
542
572
  *
543
573
  * @param {string} 갈래 working.js 의 갈래 이름
544
574
  * @param {number} 틱 90ms 마다 하나씩 오르는 수
545
- * @returns {string} 점자 세 글자 (끈 상태면 한 글자)
575
+ * @returns {string} 그 테마의 칸수만큼 점자 — 기본은 세 글자, 기사·동물은
576
+ * 여섯 글자다(넓은칸수). 끈 상태·사무실이면 한 글자.
546
577
  */
547
578
  export function 그림고르기(갈래, 틱 = 0) {
548
579
  const n = Math.trunc(틱) || 0;
package/src/ui/office.js CHANGED
@@ -555,9 +555,30 @@ export function 켜달라했나() {
555
555
  return 설정으로켰나;
556
556
  }
557
557
 
558
+ /*
559
+ * `DEEL_NO_MOTION` 을 「끄라는 말」 로 읽어도 되나.
560
+ *
561
+ * `!!process.env.DEEL_NO_MOTION` 로 두고 있었다. 그러면 `DEEL_NO_MOTION=0` 도
562
+ * 끈다 — **끄지 말라고 적은 값이 끄는 것이다.** 바로 위 켜달라했나() 는 값을
563
+ * 읽어서 `DEEL_OFFICE=0` 을 껐다로 보므로, 같은 .env 에 둘을 나란히 적으면
564
+ * 하나만 말을 듣고 다른 하나는 반대로 간다. 그런데 화면에는 아무 말도 안 뜬다.
565
+ *
566
+ * 그래서 여기도 값을 읽는다. 안 적혀 있거나 0·false·off·no·빈 값이면 안 끈다.
567
+ * 그 밖의 값(1·true·yes·아무 글자)은 다 끄는 것으로 본다 — 끄려고 적었는데
568
+ * 철자가 달라서 안 꺼지는 쪽이 더 나쁘다.
569
+ *
570
+ * 이 자리에 두는 까닭: 이 값을 읽는 데가 셋(여기·motion.js·intro.js)인데,
571
+ * motion.js 가 이 파일을 가져다 쓰므로 반대로 가져오면 고리가 생긴다.
572
+ */
573
+ const 안끄는값 = new Set(['', '0', 'false', 'off', 'no']);
574
+ export function 끄라는값인가(raw = process.env.DEEL_NO_MOTION) {
575
+ if (raw === undefined || raw === null) return false;
576
+ return !안끄는값.has(String(raw).trim().toLowerCase());
577
+ }
578
+
558
579
  export function 켜나(rows = process.stdout.rows, cols = process.stdout.columns) {
559
580
  if (!켜달라했나()) return false;
560
- if (process.env.DEEL_NO_MOTION) return false;
581
+ if (끄라는값인가()) return false;
561
582
  if (!색충분한가()) return false;
562
583
  if ((cols ?? 0) < 최소폭) return false;
563
584
  return (rows ?? 0) >= 최소높이;