deel-local-cli 0.9.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,22 @@
12
12
  // · 오프라인이면 아예 거절.
13
13
  // · 받은 것은 글자만 뽑고 길이를 자른다.
14
14
  import { allowTemporarily, isOffline, isLocalHost } from '../safety/network.js';
15
+ import { decode as decodeBytes } from './encoding.js';
16
+
17
+ /**
18
+ * 받아 온 바이트를 글로. 머리글에 적힌 인코딩이 있으면 그것부터 믿는다.
19
+ *
20
+ * 파일을 읽을 때 쓰는 것과 같은 판단기(encoding.js)를 쓴다. 두 자리에 서로 다른
21
+ * 잣대를 두면, 같은 CP949 글이 파일로는 읽히고 웹으로는 깨지는 상태가 된다.
22
+ */
23
+ function 웹글읽기(buf, 머리글) {
24
+ if (머리글 && !/^utf-?8$/.test(머리글)) {
25
+ try { return new TextDecoder(머리글, { fatal: false }).decode(buf); }
26
+ catch { /* 이 Node 가 모르는 이름이면 아래에서 알아서 본다 */ }
27
+ }
28
+ // euc-kr 인 페이지를 위해 힌트를 준다. 내용이 분명하면 내용이 이긴다.
29
+ return decodeBytes(buf, { fallback: 'euc-kr' }).text;
30
+ }
15
31
 
16
32
  export const 방문기록 = [];
17
33
 
@@ -74,8 +90,28 @@ export async function webFetch(args, { allowPrivate = false } = {}) {
74
90
  const buf = Buffer.from(await res.arrayBuffer());
75
91
  if (buf.length > MAX_BYTES) return { error: `너무 큽니다 (${(buf.length / 1024 / 1024).toFixed(1)}MB).` };
76
92
 
77
- let text = buf.toString('utf8');
93
+ /*
94
+ * 무엇으로 쓰여 있는지 알아보고 읽는다.
95
+ *
96
+ * 전에는 무조건 UTF-8 이었다. 사내 위키·공공기관 페이지는 아직 EUC-KR 이
97
+ * 흔한데, 그걸 UTF-8 로 읽으면 한글이 통째로 깨진다. 그 깨진 글이 그대로
98
+ * 모델에게 가고, 모델은 깨진 채로 요약한다 — 사용자는 왜 엉뚱한 답이
99
+ * 나오는지 알 수 없다. 파일을 읽을 때는 이미 알아보고 읽는데(encoding.js)
100
+ * 웹만 안 하고 있었다.
101
+ *
102
+ * 머리글(charset)이 있으면 그게 답이다. 없으면 내용을 보고 짐작한다.
103
+ */
104
+ const 머리글 = /charset=["']?([\w-]+)/i.exec(type)?.[1]?.toLowerCase() ?? null;
105
+ let text = 웹글읽기(buf, 머리글);
78
106
  if (/html/.test(type)) text = 태그벗기기(text);
107
+ // <meta charset> 이 머리글과 다르게 적혀 있는 페이지가 있다. 깨졌으면 그걸 믿고 다시 읽는다.
108
+ if (!머리글 && text.includes('�')) {
109
+ const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(buf.toString('latin1').slice(0, 2000))?.[1]?.toLowerCase();
110
+ if (meta) {
111
+ text = 웹글읽기(buf, meta);
112
+ if (/html/.test(type)) text = 태그벗기기(text);
113
+ }
114
+ }
79
115
  const cut = text.length > max;
80
116
  if (cut) text = text.slice(0, max);
81
117
 
package/src/ui/ansi.js CHANGED
@@ -59,6 +59,12 @@ export const cursor = {
59
59
  // 한글·한자·가나는 터미널에서 두 칸을 차지한다. 표 정렬이 이걸 모르면 어긋난다.
60
60
  export function width(str) {
61
61
  let w = 0;
62
+ // 없는 값은 빈 글자로 본다.
63
+ //
64
+ // String(null) 은 'null' 이라 폭이 4 로 나온다. 그러면 상태줄이 네 칸씩
65
+ // 어긋나고, 화면에는 'null' 이라는 글자가 그대로 찍힌다. 모델 이름이나
66
+ // 곁말은 없을 수 있는 값이라 실제로 여기로 들어온다.
67
+ if (str === null || str === undefined) return 0;
62
68
  for (const ch of String(str).replace(/\x1b\[[0-9;]*m/g, '')) {
63
69
  const cp = ch.codePointAt(0);
64
70
  if (
@@ -124,7 +130,13 @@ export function bar(used, total, cells = 32) {
124
130
 
125
131
  // 상태줄용 얇은 막대.
126
132
  export function gauge(ratio, cells = 10) {
127
- const r = Math.min(1, Math.max(0, ratio));
133
+ // 숫자가 아니면 0 으로 본다.
134
+ //
135
+ // Math.min/max 는 NaN 을 그대로 흘린다. 그러면 repeat(NaN) 이 빈 글자가 되어
136
+ // 막대가 통째로 사라지고, 그만큼 상태줄이 밀린다. 컨텍스트 총량이 0 일 때
137
+ // used/total 이 실제로 NaN 이 된다 — 새 연결에서 드물게 나온다.
138
+ const n = Number(ratio);
139
+ const r = Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
128
140
  const filled = Math.round(r * cells);
129
141
  const tone = r > 0.85 ? c.hred : r > 0.6 ? c.hyellow : c.hgreen;
130
142
  return tone('▰'.repeat(filled)) + c.gray('▱'.repeat(cells - filled));
package/src/ui/diff.js ADDED
@@ -0,0 +1,255 @@
1
+ // 바뀐 자리를 줄 단위로 보여 준다.
2
+ //
3
+ // 왜 있어야 하나:
4
+ // auto 모드는 안 물어보고 고친다. 그게 이 도구의 속도인데, 화면에 "3군데"
5
+ // 만 남으면 사람은 무엇이 바뀐지 모른 채 넘어간다. 되돌리기가 안전망이라도
6
+ // 뭐가 바뀐지 모르면 되돌릴지 말지조차 못 정한다. 그래서 고친 자리는
7
+ // 반드시 눈에 보여야 한다.
8
+ //
9
+ // 조심한 것:
10
+ // 1) 큰 파일. LCS 표는 줄 수의 곱만큼 자리를 먹는다 — 1만 줄짜리 둘이면
11
+ // 1억 칸이다. 그래서 앞뒤로 똑같은 부분을 먼저 잘라낸다. 파일 한 줄만
12
+ // 고치는 흔한 경우는 이것만으로 표가 1×1 이 된다.
13
+ // 그러고도 크면 LCS 를 포기하고 '이만큼이 통째로 바뀌었다' 로 물러선다.
14
+ // 느린 것보다 대충이라도 빨리 보이는 편이 낫다.
15
+ // 2) 줄 끝 표시(CRLF/LF). 눈에는 똑같은 줄이 전부 바뀐 것으로 나오면
16
+ // 진짜 바뀐 곳을 못 찾는다. 그래서 그 경우를 따로 알아채서 말해 준다.
17
+ // 3) 곁줄은 통째로 만들지 않는다. 20,000줄짜리에서 한 줄 고쳤다고 20,000개를
18
+ // 늘어놓을 이유가 없다 — 필요한 자리만 그때그때 집어 온다.
19
+ import { c, clip, cols } from './ansi.js';
20
+
21
+ // LCS 표를 여기까지만 만든다. 넘으면 대충으로 물러선다. (4M 칸 = 16MB)
22
+ const MAX_CELLS = 4_000_000;
23
+ // 대충으로 물러섰을 때 실제로 만들어 둘 줄 수. 나머지는 세기만 한다.
24
+ const MAX_COARSE = 2000;
25
+ const TAB = ' ';
26
+
27
+ /** 글을 줄로 나눈다. 마지막 개행이 있었는지는 따로 들고 있는다. */
28
+ function 줄나누기(text) {
29
+ if (text === null || text === undefined) return { lines: [], eof: false, none: true };
30
+ const s = String(text);
31
+ if (s === '') return { lines: [], eof: false, none: false };
32
+ const eof = s.endsWith('\n');
33
+ const lines = (eof ? s.slice(0, -1) : s).split('\n');
34
+ return { lines, eof, none: false };
35
+ }
36
+
37
+ const 줄끝뗀것 = (arr) => arr.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
38
+ const 같은가 = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
39
+
40
+ /**
41
+ * 두 글을 줄 단위로 비교한다.
42
+ *
43
+ * @param {string|null} before 없던 파일이면 null
44
+ * @param {string|null} after 지운 파일이면 null
45
+ * @returns {{
46
+ * changed: boolean, added: number, removed: number,
47
+ * isNew: boolean, isGone: boolean, eolOnly: boolean, eofChanged: boolean,
48
+ * tooBig: boolean, omitted: number, hunks: Array<{from:number,to:number}>,
49
+ * opAt: (p:number) => {t:string,a:number,b:number}|null, total: number,
50
+ * linesA: string[], linesB: string[],
51
+ * }}
52
+ */
53
+ export function diffLines(before, after, { context = 3, maxCells = MAX_CELLS } = {}) {
54
+ const A = 줄나누기(before);
55
+ const B = 줄나누기(after);
56
+ const linesA = A.lines;
57
+ const linesB = B.lines;
58
+
59
+ // 줄 끝 표시만 바뀐 것인가 — 내용은 그대로인데 \r 만 붙거나 떨어진 경우.
60
+ const 뗀A = 줄끝뗀것(linesA);
61
+ const 뗀B = 줄끝뗀것(linesB);
62
+ const eolOnly = !같은가(linesA, linesB) && 같은가(뗀A, 뗀B) && A.eof === B.eof;
63
+
64
+ // 앞뒤로 똑같은 부분을 잘라낸다. 여기서 대부분의 일이 끝난다.
65
+ let pre = 0;
66
+ const 짧은쪽 = Math.min(linesA.length, linesB.length);
67
+ while (pre < 짧은쪽 && linesA[pre] === linesB[pre]) pre++;
68
+ let suf = 0;
69
+ while (suf < 짧은쪽 - pre && linesA[linesA.length - 1 - suf] === linesB[linesB.length - 1 - suf]) suf++;
70
+
71
+ const midA = linesA.slice(pre, linesA.length - suf);
72
+ const midB = linesB.slice(pre, linesB.length - suf);
73
+
74
+ let script = [];
75
+ let added = 0;
76
+ let removed = 0;
77
+ let tooBig = false;
78
+ let omitted = 0;
79
+
80
+ if (midA.length === 0 && midB.length === 0) {
81
+ // 줄은 똑같다. 남은 차이는 마지막 개행뿐일 수 있다.
82
+ } else if (midA.length === 0 || midB.length === 0 || midA.length * midB.length > maxCells) {
83
+ // 한쪽이 통째로 비었거나, 표가 너무 크다 — 통째로 바뀐 것으로 본다.
84
+ tooBig = midA.length > 0 && midB.length > 0;
85
+ added = midB.length;
86
+ removed = midA.length;
87
+ const 몫 = tooBig ? Math.max(1, Math.floor(MAX_COARSE / 2)) : Infinity;
88
+ for (let i = 0; i < midA.length && i < 몫; i++) script.push({ t: '-', a: pre + i, b: -1 });
89
+ for (let j = 0; j < midB.length && j < 몫; j++) script.push({ t: '+', a: -1, b: pre + j });
90
+ omitted = Math.max(0, midA.length - 몫) + Math.max(0, midB.length - 몫);
91
+ } else {
92
+ script = LCS대본(midA, midB, pre);
93
+ for (const op of script) {
94
+ if (op.t === '+') added++;
95
+ else if (op.t === '-') removed++;
96
+ }
97
+ }
98
+
99
+ // 줄은 다 같은데 마지막 개행만 다른 경우. 마지막 줄이 바뀐 것으로 그린다.
100
+ let eofChanged = false;
101
+ if (added === 0 && removed === 0 && !A.none && !B.none && A.eof !== B.eof && linesA.length) {
102
+ eofChanged = true;
103
+ added = 1;
104
+ removed = 1;
105
+ const 끝 = linesA.length - 1;
106
+ script = [{ t: '-', a: 끝, b: -1 }, { t: '+', a: -1, b: linesB.length - 1 }];
107
+ // 대본이 맨 끝 한 줄을 대신하므로 꼬리 곁줄은 없다.
108
+ suf = 0;
109
+ pre = 끝;
110
+ }
111
+
112
+ const aTail = linesA.length - suf;
113
+ const bTail = linesB.length - suf;
114
+ const total = pre + script.length + suf;
115
+
116
+ // 자리 하나를 그때그때 집어 온다. 앞뒤 공통 부분은 만들어 두지 않는다.
117
+ const opAt = (p) => {
118
+ if (p < 0 || p >= total) return null;
119
+ if (p < pre) return { t: ' ', a: p, b: p };
120
+ const k = p - pre;
121
+ if (k < script.length) return script[k];
122
+ const t = k - script.length;
123
+ return { t: ' ', a: aTail + t, b: bTail + t };
124
+ };
125
+
126
+ // 바뀐 자리들을 곁줄을 붙여 덩어리로 묶는다.
127
+ const 바뀐자리 = [];
128
+ for (let k = 0; k < script.length; k++) if (script[k].t !== ' ') 바뀐자리.push(pre + k);
129
+
130
+ const hunks = [];
131
+ for (const p of 바뀐자리) {
132
+ const 마지막 = hunks[hunks.length - 1];
133
+ // 곁줄끼리 겹치거나 맞닿으면 한 덩어리로 합친다.
134
+ if (마지막 && p - 마지막.last <= context * 2 + 1) {
135
+ 마지막.last = p;
136
+ 마지막.to = Math.min(total - 1, p + context);
137
+ } else {
138
+ hunks.push({ from: Math.max(0, p - context), to: Math.min(total - 1, p + context), last: p });
139
+ }
140
+ }
141
+ for (const h of hunks) delete h.last;
142
+
143
+ return {
144
+ changed: added > 0 || removed > 0,
145
+ added, removed,
146
+ isNew: A.none && !B.none,
147
+ isGone: !A.none && B.none,
148
+ eolOnly, eofChanged, tooBig, omitted,
149
+ hunks, opAt, total, linesA, linesB,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * 가장 긴 공통 부분 수열로 대본을 만든다.
155
+ *
156
+ * 표를 통째로 잡는다 — 되짚어 올라가려면 표가 있어야 한다. 크기는 부르는 쪽에서
157
+ * 이미 걸러 놨다. Uint32Array 를 쓰는 이유는 보통 배열보다 자리를 덜 먹고
158
+ * 빈칸 채우기가 빨라서다.
159
+ */
160
+ function LCS대본(a, b, base) {
161
+ const n = a.length;
162
+ const m = b.length;
163
+ const W = m + 1;
164
+ const 표 = new Uint32Array((n + 1) * W);
165
+ for (let i = n - 1; i >= 0; i--) {
166
+ const 줄 = i * W;
167
+ const 다음 = (i + 1) * W;
168
+ const ai = a[i];
169
+ for (let j = m - 1; j >= 0; j--) {
170
+ 표[줄 + j] = ai === b[j] ? 표[다음 + j + 1] + 1 : Math.max(표[다음 + j], 표[줄 + j + 1]);
171
+ }
172
+ }
173
+ const out = [];
174
+ let i = 0;
175
+ let j = 0;
176
+ while (i < n && j < m) {
177
+ if (a[i] === b[j]) { out.push({ t: ' ', a: base + i, b: base + j }); i++; j++; }
178
+ else if (표[(i + 1) * W + j] >= 표[i * W + j + 1]) { out.push({ t: '-', a: base + i, b: -1 }); i++; }
179
+ else { out.push({ t: '+', a: -1, b: base + j }); j++; }
180
+ }
181
+ while (i < n) { out.push({ t: '-', a: base + i, b: -1 }); i++; }
182
+ while (j < m) { out.push({ t: '+', a: -1, b: base + j }); j++; }
183
+ return out;
184
+ }
185
+
186
+ const 보기좋게 = (s) => String(s ?? '').replace(/\t/g, TAB).replace(/\r/g, '');
187
+
188
+ /**
189
+ * 화면에 그릴 줄들을 만든다.
190
+ *
191
+ * @param {ReturnType<typeof diffLines>|null} d
192
+ * @param {{maxLines?:number, width?:number, indent?:string}} opts
193
+ */
194
+ export function renderDiff(d, { maxLines = 40, width = 0, indent = ' ' } = {}) {
195
+ if (!d || !d.hunks) return [];
196
+ const 폭 = width > 0 ? width : Math.max(30, cols() - 18);
197
+ const out = [];
198
+
199
+ if (d.eolOnly) {
200
+ out.push(`${indent}${c.yellow('※')} ${c.gray('줄 끝 표시(CRLF/LF)만 바뀌었습니다 — 글자는 그대로입니다.')}`);
201
+ }
202
+ if (d.eofChanged) {
203
+ out.push(`${indent}${c.gray('※ 마지막 줄의 개행만 바뀌었습니다.')}`);
204
+ }
205
+ if (d.tooBig) {
206
+ out.push(`${indent}${c.gray('※ 너무 많이 바뀌어 자세히는 못 맞춥니다 — 통째로 바뀐 것으로 봅니다.')}`);
207
+ }
208
+ if (!d.hunks.length) return out;
209
+
210
+ const 번호폭 = String(Math.max(d.linesA?.length ?? 0, d.linesB?.length ?? 0)).length;
211
+ let 그린줄 = 0;
212
+ let 남은줄 = 0;
213
+ let 끊김 = false;
214
+
215
+ for (let h = 0; h < d.hunks.length; h++) {
216
+ const { from, to } = d.hunks[h];
217
+ if (h > 0) {
218
+ if (그린줄 >= maxLines) { 끊김 = true; }
219
+ else { out.push(`${indent}${c.gray('⋯')}`); }
220
+ }
221
+ for (let p = from; p <= to; p++) {
222
+ if (그린줄 >= maxLines) { 끊김 = true; 남은줄 += to - p + 1; break; }
223
+ const op = d.opAt(p);
224
+ if (!op) continue;
225
+ const 글 = clip(보기좋게(op.t === '-' ? d.linesA[op.a] : d.linesB[op.b]), 폭);
226
+ // 번호는 '지금 파일' 의 번호만 적는다.
227
+ //
228
+ // 없어진 줄에 옛 번호를 달면, 바로 위 곁줄의 새 번호와 같은 숫자가 나란히
229
+ // 찍힌다 — 서로 다른 파일의 번호가 한 줄에 섞여 보인다. 실제로 8번이
230
+ // 두 번 찍히는 화면이 나왔다. 없어진 줄은 지금 파일에 없으니 번호도 없다.
231
+ const n = op.t === '-' ? ' '.repeat(번호폭) : String(op.b + 1).padStart(번호폭);
232
+ if (op.t === '+') out.push(`${indent}${c.hgreen('+')} ${c.gray(n)} ${c.green(글)}`);
233
+ else if (op.t === '-') out.push(`${indent}${c.hred('-')} ${c.gray(n)} ${c.red(글)}`);
234
+ else out.push(`${indent}${c.gray(' ')} ${c.gray(n)} ${c.gray(글)}`);
235
+ 그린줄++;
236
+ }
237
+ if (끊김) {
238
+ for (let k = h + 1; k < d.hunks.length; k++) 남은줄 += d.hunks[k].to - d.hunks[k].from + 1;
239
+ break;
240
+ }
241
+ }
242
+
243
+ 남은줄 += d.omitted ?? 0;
244
+ if (남은줄 > 0) out.push(`${indent}${c.gray(`⋯ 외 ${남은줄.toLocaleString()}줄 더`)}`);
245
+ return out;
246
+ }
247
+
248
+ /** `+3 −1` 한 줄 요약. 안 바뀌었으면 빈 글. */
249
+ export function shortStat(d) {
250
+ if (!d || !d.changed) return '';
251
+ const 조각 = [];
252
+ if (d.added) 조각.push(c.hgreen(`+${d.added}`));
253
+ if (d.removed) 조각.push(c.hred(`−${d.removed}`));
254
+ return 조각.join(' ');
255
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * 입력 상자.
3
+ *
4
+ * 아래처럼 생겼다. **이것만** 다시 그린다 — 위쪽 대화는 안 건드린다.
5
+ *
6
+ * ▏myproject · qwen2.5-coder:7b ▏ ▰▰▱▱▱▱ 22% ▏ ◎ 종합 · auto
7
+ * ╭──────────────────────────────────────────────────────────╮
8
+ * │ ❯ 집계 함수도 줄여줘 │
9
+ * ╰──────────────────────────────────────────────────────────╯
10
+ *
11
+ * ── 왜 이렇게 바꿨나 ────────────────────────────────────────────────────
12
+ *
13
+ * 전에는 터미널을 통째로 빌려(대체 화면) 대화 칸·파일 칸·할 일 칸을 나눠
14
+ * 그렸다. 보기에는 그럴듯했지만 **슬래시 명령이 전부 먹통**이 됐다.
15
+ *
16
+ * commands.js · setup.js · scanui.js · sessionui.js · report.js 는
17
+ * 화면 객체를 안 거치고 stdout 에 바로 쓴다. 전체화면은 매번 화면 전체를
18
+ * 자기 버퍼에서 다시 그리므로, 그 글은 찍히자마자 덮여 사라졌다.
19
+ * 명령이 안 도는 게 아니라 **결과가 안 보이는** 것이라 더 나빴다.
20
+ *
21
+ * 고치려면 stdout 에 쓰는 자리를 전부 화면 객체로 꿰야 한다. 지금 여섯 곳이고
22
+ * 앞으로 늘 것이며, 하나라도 빠뜨리면 같은 증상이 조용히 돌아온다.
23
+ *
24
+ * 그래서 반대로 간다. **대화는 터미널에 그냥 흘려보낸다.** 그러면 stdout 에
25
+ * 쓰는 모든 코드가 손 안 대고 그대로 맞다. 스크롤·복사·`| tee` 도 그대로다.
26
+ * 우리가 관리하는 것은 화면 맨 아래 **입력 상자 몇 줄뿐**이다.
27
+ *
28
+ * ── 지키는 선 ───────────────────────────────────────────────────────────
29
+ *
30
+ * 1) 줄 편집은 readline 이 계속 맡는다. 한글 조합·붙여넣기·위아래 이력·
31
+ * 백스페이스·Ctrl+A 를 우리가 다시 짜지 않는다. 우리는 readline 이 들고
32
+ * 있는 글(rl.line)을 **그리기만** 한다.
33
+ * 2) 그릴 것을 값으로 내놓는다(프레임). 검사는 파이프로 도니 화면을 못 본다 —
34
+ * 값으로 내놓아야 한 칸도 안 어긋나는지 잴 수 있다.
35
+ */
36
+ import { c, width, cursor } from './ansi.js';
37
+ import { statusLine, contextWarning } from './status.js';
38
+ import { 접어쓰기 } from './wrap.js';
39
+
40
+ const 줄끝지움 = '\x1b[K';
41
+ const 위로 = (n) => (n > 0 ? `\x1b[${n}A` : '');
42
+
43
+ // 상자 안쪽 왼쪽에 붙는 것: ' ' + '│' + ' ' + '❯' + ' ' → 5칸
44
+ const 앞머리 = 5;
45
+ // 상자가 차지할 최대 줄 수. 긴 글을 붙여넣어도 화면을 다 먹지 않게.
46
+ export const 안쪽최대 = 8;
47
+
48
+ /**
49
+ * 상자를 이룰 줄들과 커서 자리를 값으로 내놓는다.
50
+ *
51
+ * @param {object} o
52
+ * @param {string} o.글 사람이 지금까지 친 것
53
+ * @param {number} o.커서 글 안에서의 커서 위치(글자 수)
54
+ * @param {number} o.폭 터미널 칸 수
55
+ * @param {string} o.상태 상태줄 (없으면 안 그린다)
56
+ * @param {string} o.경고 컨텍스트 경고 (없으면 안 그린다)
57
+ * @param {string} o.곁말 상자 아래 흐린 한 줄 (도움말 등)
58
+ * @returns {{줄들: string[], 커서: {위: number, 열: number}}}
59
+ * 커서.위 = 마지막 줄에서 몇 줄 위인가, 커서.열 = 1부터 세는 칸
60
+ */
61
+ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상태 = '', 경고 = '', 곁말 = '' } = {}) {
62
+ const 칸 = Math.max(20, 폭);
63
+ const 안쪽 = Math.max(4, 칸 - 앞머리 - 2); // 오른쪽 ' │' 두 칸
64
+ const 줄들 = [];
65
+
66
+ if (상태) 줄들.push(상태);
67
+ if (경고) 줄들.push(` ${경고}`);
68
+
69
+ const 가로 = '─'.repeat(칸 - 3);
70
+ 줄들.push(c.gray(` ╭${가로}╮`));
71
+
72
+ /*
73
+ * 긴 입력은 접는다. 자르면 사람이 친 글이 안 보이는데, 안 보이는 채로
74
+ * Enter 를 치게 되는 것이 제일 나쁘다 — 무엇을 보내는지 모르게 된다.
75
+ */
76
+ const 안쪽줄 = 글 ? 접어쓰기(글, 안쪽) : [''];
77
+ const 넘침 = 안쪽줄.length > 안쪽최대;
78
+ const 보일줄 = 넘침 ? 안쪽줄.slice(안쪽줄.length - 안쪽최대) : 안쪽줄;
79
+
80
+ for (const [i, 한줄] of 보일줄.entries()) {
81
+ const 표 = i === 0 ? (넘침 ? c.gray('…') : c.hcyan('❯')) : ' ';
82
+ const 남 = 안쪽 - width(한줄);
83
+ 줄들.push(` ${c.gray('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 남))} ${c.gray('│')}`);
84
+ }
85
+ 줄들.push(c.gray(` ╰${가로}╯`));
86
+ if (곁말) 줄들.push(` ${c.gray(곁말)}`);
87
+
88
+ /*
89
+ * 커서 자리.
90
+ *
91
+ * 글자 수가 아니라 **폭**으로 세야 한다. 한글은 두 칸이라, 글자 수로 세면
92
+ * '가나다' 를 친 뒤 커서가 세 칸 왼쪽에 가 있게 된다. 백스페이스를 누르면
93
+ * 엉뚱한 자리가 지워지는 것처럼 보인다.
94
+ */
95
+ const 앞글 = 커서자리 == null ? 글 : String(글).slice(0, 커서자리);
96
+ const 앞줄들 = 앞글 ? 접어쓰기(앞글, 안쪽) : [''];
97
+
98
+ // 넘쳐서 앞부분을 잘라 냈으면 그만큼 줄 번호가 밀린다.
99
+ const 숨긴줄 = 안쪽줄.length - 보일줄.length;
100
+ const 커서줄 = Math.min(Math.max(0, 앞줄들.length - 1 - 숨긴줄), 보일줄.length - 1);
101
+
102
+ const 아래 = (곁말 ? 1 : 0) + 1; // 곁말 + 닫는 테두리
103
+ const 위 = 아래 + (보일줄.length - 1 - 커서줄);
104
+ // 1부터 세는 칸. 앞머리(5칸) 다음이 6칸째다 — 거기가 첫 글자 자리다.
105
+ const 열 = Math.min(앞머리 + 1 + width(앞줄들.at(-1) ?? ''), 칸 - 1);
106
+
107
+ return { 줄들, 커서: { 위, 열 } };
108
+ }
109
+
110
+ /**
111
+ * 터미널 맨 아래에 상자를 붙여 두는 화면.
112
+ *
113
+ * 대화는 stdout 으로 그냥 나간다(줄 화면과 같다). 우리가 지우고 다시 그리는
114
+ * 것은 상자 몇 줄뿐이다.
115
+ */
116
+ export class InputBox {
117
+ constructor() {
118
+ this.그린줄 = 0; // 지금 화면에 붙어 있는 상자의 줄 수
119
+ /*
120
+ * 커서를 상자 맨 아랫줄에서 몇 줄 위에 놓아 뒀나.
121
+ *
122
+ * 이걸 안 들고 있으면 지울 때가 어긋난다. 상자가 한 줄짜리일 때는
123
+ * 커서가 늘 아래에서 첫째 줄이라 티가 안 나는데, 긴 글이 접혀 상자가
124
+ * 세 줄이 되거나 아래에 곁말이 붙는 순간 **몇 줄을 내려가야 하는지**가
125
+ * 달라진다. 고정값으로 내려가면 화면 맨 아래에 부딪혀 멈추고, 그 뒤로는
126
+ * 엉뚱한 줄을 지운다 — 대화가 한 줄씩 갉여 나간다.
127
+ */
128
+ this.커서위 = 0;
129
+ this.session = null;
130
+ this.곁말 = '';
131
+ }
132
+
133
+ /** 상자를 지운다. 위쪽 대화는 안 건드린다. */
134
+ 지우기() {
135
+ if (!this.그린줄) return;
136
+ // 커서가 있는 자리에서 맨 아랫줄까지 내려간 뒤, 위로 훑어 올라가며 지운다.
137
+ let out = '\r' + (this.커서위 > 0 ? `\x1b[${this.커서위}B` : '') + '\r';
138
+ for (let i = 0; i < this.그린줄; i++) {
139
+ out += 줄끝지움;
140
+ if (i < this.그린줄 - 1) out += '\x1b[1A';
141
+ }
142
+ out += '\r';
143
+ process.stdout.write(out);
144
+ this.그린줄 = 0;
145
+ this.커서위 = 0;
146
+ }
147
+
148
+ /**
149
+ * 상자를 그린다(있으면 지우고 다시).
150
+ *
151
+ * @param {object} session 상태줄을 그리는 데 쓴다
152
+ * @param {string} 글 readline 이 들고 있는 글
153
+ * @param {number} 커서 그 안에서의 커서 위치
154
+ */
155
+ 그리기(session, 글 = '', 커서자리 = null) {
156
+ this.session = session ?? this.session;
157
+ this.지우기();
158
+
159
+ const { 줄들, 커서: 자리 } = 프레임({
160
+ 글,
161
+ 커서: 커서자리,
162
+ 폭: process.stdout.columns ?? 80,
163
+ 상태: this.session ? statusLine(this.session) : '',
164
+ 경고: this.session ? (contextWarning(this.session) ?? '') : '',
165
+ 곁말: this.곁말,
166
+ });
167
+
168
+ cursor.hide();
169
+ process.stdout.write(줄들.map((l) => l + 줄끝지움).join('\n') + '\n');
170
+ /*
171
+ * 방금 찍은 마지막 줄 **다음** 줄에 커서가 있다. 거기서 되짚어 올라간다.
172
+ *
173
+ * 오른쪽으로 옮길 때 한 칸이 어긋나기 쉽다. \r 은 커서를 1칸째에 놓고,
174
+ * \x1b[nC 는 거기서 n칸 **더** 간다 — 그래서 1부터 세는 목표 칸으로
175
+ * 가려면 열-1 을 보내야 한다. 이걸 틀리면 친 글자가 한 칸씩 밀려 보이고,
176
+ * 백스페이스가 엉뚱한 자리를 지우는 것처럼 보인다.
177
+ */
178
+ const 오른쪽 = Math.max(0, 자리.열 - 1);
179
+ process.stdout.write(위로(자리.위 + 1) + '\r' + (오른쪽 ? `\x1b[${오른쪽}C` : ''));
180
+ cursor.show();
181
+ this.그린줄 = 줄들.length;
182
+ this.커서위 = 자리.위;
183
+ }
184
+ }
package/src/ui/level.js CHANGED
@@ -21,7 +21,9 @@ export const LEVELS = {
21
21
  'help', 'work', 'auto', 'code', 'plan', 'ask',
22
22
  // ctx 를 초보 목록에 넣는다. 컨텍스트가 작게 잡히면 "왜 파일을 조금만 읽지"
23
23
  // 가 되는데, 초보일수록 그 원인을 못 찾는다. 명령 하나로 끝나는 문제다.
24
- 'model', 'ctx', 'scan', 'undo', 'clear', 'sessions', 'cost', 'level', 'exit',
24
+ // diff 초보에게 특히 필요하다. auto 모드는 안 물어보고 고치니,
25
+ // '무엇이 바뀌었나' 를 볼 통로가 없으면 되돌릴지 말지도 못 정한다.
26
+ 'model', 'ctx', 'scan', 'diff', 'undo', 'clear', 'sessions', 'cost', 'level', 'exit',
25
27
  ],
26
28
  // 첫 실행에서 훑어 추천까지 해 준다
27
29
  autoScan: true,
package/src/ui/prompt.js CHANGED
@@ -30,11 +30,23 @@ export function ask(label, { mask = false, def = '' } = {}) {
30
30
  process.stdout.write(prefix + (def ? c.gray(`[${def}] `) : ''));
31
31
  let buf = '';
32
32
  return readKeys((ch, done) => {
33
- for (const ch1 of ch) {
33
+ // 글자 단위로 훑되 자리를 기억한다. 엔터에서 멈출 때 '남은 것' 을 알아야 해서다.
34
+ const 글자들 = [...ch];
35
+ for (let i = 0; i < 글자들.length; i++) {
36
+ const ch1 = 글자들[i];
34
37
  const code = ch1.charCodeAt(0);
35
38
  if (code === 3) { say(''); process.exit(130); } // Ctrl+C
36
39
  if (code === 13 || code === 10) { // Enter
37
40
  say('');
41
+ // 한 덩어리에 여러 줄이 실려 올 수 있다 — 붙여넣기, 파이프 입력, 느린 터미널.
42
+ // 예전에는 엔터를 만나면 그 덩어리의 나머지를 그냥 버렸다. 그러면 이어서
43
+ // 물어보는 쪽이 아무것도 못 받고 멈춘 것처럼 보인다. 무엇이 사라졌는지도
44
+ // 화면에 안 남아서, 사람은 자기가 안 친 줄 안다.
45
+ // 그래서 되돌려 놓는다 — 다음에 읽는 쪽이 받아 간다.
46
+ let 다음 = i + 1;
47
+ if (code === 13 && 글자들[다음] === '\n') 다음++; // CRLF 는 한 번으로 친다
48
+ const 남은것 = 글자들.slice(다음).join('');
49
+ if (남은것) { try { process.stdin.unshift(남은것); } catch { /* 못 돌려놔도 이 줄은 살린다 */ } }
38
50
  return done(buf.length ? buf : def);
39
51
  }
40
52
  if (code === 127 || code === 8) { // Backspace