deel-local-cli 1.2.0 → 1.4.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.
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Def · Refs — 언어 서버에게 "이게 어디 있나 / 어디서 쓰나" 를 묻는다.
3
+ *
4
+ * ── Grep 을 밀어내는 것이 아니다 ────────────────────────────────────────
5
+ *
6
+ * Grep 은 남는다. 언어 서버가 안 깔린 자리가 더 많고(사내망이 대개 그렇다),
7
+ * 깔려 있어도 못 읽는 파일이 있고, 무엇보다 Grep 은 **주석·설정·문서까지**
8
+ * 찾는다. 이름을 바꿀 때 정말 필요한 것은 그쪽까지다.
9
+ *
10
+ * 이 둘이 더해 주는 것은 딱 하나, **틀린 자리를 안 준다는 것**이다.
11
+ * `run` 을 Grep 으로 찾으면 수백 줄이 나오고 그중 진짜는 몇 개다. 모델은 그
12
+ * 수백 줄을 다 읽을 자리가 없어서 앞의 몇 개만 보고 고치기 시작한다. 놓친
13
+ * 자리는 돌려 본 뒤에야 드러나고, 그때는 이미 다른 것도 같이 고쳐 놓은 뒤다.
14
+ *
15
+ * ── 왜 자리(줄·칸)가 아니라 이름을 받나 ─────────────────────────────────
16
+ *
17
+ * LSP 는 "이 파일 이 줄 이 칸에 있는 것" 을 묻는 규약이다. 그런데 모델은
18
+ * 칸 번호를 모른다. 알려면 파일을 먼저 Read 해야 하는데, 그러면 이 도구를
19
+ * 쓰는 값(파일을 안 읽고도 안다)이 통째로 사라진다.
20
+ *
21
+ * 그래서 이름을 받아 **workspace/symbol 로 자리를 먼저 찾고**, 그 자리로
22
+ * 다시 묻는다. 사람이 하는 것과 같은 순서다. 이름이 여럿이면 그 목록을
23
+ * 그대로 보여 주고 고르게 한다 — 하나를 골라 주고 아닌 척하지 않는다.
24
+ */
25
+ import { readFileSync } from 'node:fs';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+ import { 얻기, 색인중일까 } from '../lsp/client.js';
28
+ import { 갈래, 프로젝트갈래 } from '../lsp/servers.js';
29
+ import { 찾을개수 } from '../agent/budget.js';
30
+
31
+ /** 한 자리를 사람이 읽을 한 줄로. 그 줄의 글까지 붙여야 열어 보지 않고도 안다. */
32
+ function 한줄(scope, uri, 범위) {
33
+ let abs;
34
+ try { abs = fileURLToPath(uri); } catch { abs = String(uri); }
35
+ const 줄번호 = (범위?.start?.line ?? 0) + 1;
36
+ let 글 = '';
37
+ try {
38
+ const 줄들 = readFileSync(abs, 'utf8').split(/\r?\n/);
39
+ 글 = (줄들[줄번호 - 1] ?? '').trim();
40
+ } catch { /* 못 읽으면 자리만 준다 */ }
41
+ const 보일 = scope?.show ? (() => { try { return scope.show(abs); } catch { return abs; } })() : abs;
42
+ return { 파일: 보일, 줄: 줄번호, 글: 글.length > 160 ? 글.slice(0, 160) + '…' : 글, abs };
43
+ }
44
+
45
+ /** LSP 의 답은 하나일 수도, 목록일 수도, LocationLink 일 수도 있다. 다 같은 모양으로 편다. */
46
+ function 자리들펴기(값) {
47
+ if (!값) return [];
48
+ const 목록 = Array.isArray(값) ? 값 : [값];
49
+ return 목록.map((it) => {
50
+ if (!it) return null;
51
+ if (it.targetUri) return { uri: it.targetUri, range: it.targetSelectionRange ?? it.targetRange };
52
+ if (it.uri) return { uri: it.uri, range: it.range };
53
+ if (it.location) return { uri: it.location.uri, range: it.location.range };
54
+ return null;
55
+ }).filter(Boolean);
56
+ }
57
+
58
+ /**
59
+ * 이름이 그 줄 어디쯤에 있는지.
60
+ *
61
+ * 낱말 경계를 본다. `run` 을 찾을 때 `runner` 를 짚으면 서버는 runner 의
62
+ * 정의를 준다 — 틀린 답인데 맞는 답처럼 생겨서 제일 나쁘다.
63
+ */
64
+ function 칸찾기(줄글, 이름) {
65
+ if (!줄글 || !이름) return -1;
66
+ let i = 0;
67
+ for (;;) {
68
+ const p = 줄글.indexOf(이름, i);
69
+ if (p < 0) return -1;
70
+ const 앞 = 줄글[p - 1] ?? ' ';
71
+ const 뒤 = 줄글[p + 이름.length] ?? ' ';
72
+ const 낱말 = (ch) => /[\p{L}\p{N}_$]/u.test(ch);
73
+ if (!낱말(앞) && !낱말(뒤)) return p;
74
+ i = p + 1;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 이름으로 자리를 찾는다.
80
+ *
81
+ * @returns {{자리: {uri, position}, 후보: object[]}|{오류: string}}
82
+ */
83
+ async function 자리잡기(서버, scope, { 이름, 파일, 줄 }) {
84
+ // 1) 파일과 줄을 준 경우. 그게 제일 정확하다 — 모델이 방금 Read 했거나
85
+ // Grep 으로 좁혀 온 자리다.
86
+ if (파일) {
87
+ let abs;
88
+ try { abs = scope.resolve(파일); } catch (e) { return { 오류: e.message }; }
89
+ let 줄들;
90
+ try { 줄들 = readFileSync(abs, 'utf8').split(/\r?\n/); } catch { return { 오류: `못 읽었습니다: ${파일}` }; }
91
+ 서버.보여주기(abs, 줄들.join('\n'));
92
+
93
+ const 볼줄 = Number.isFinite(줄) && 줄 > 0 ? [줄 - 1] : 줄들.map((_, i) => i);
94
+ for (const i of 볼줄) {
95
+ const 칸 = 칸찾기(줄들[i] ?? '', 이름);
96
+ if (칸 >= 0) return { 자리: { uri: pathToFileURL(abs).href, position: { line: i, character: 칸 } } };
97
+ }
98
+ return { 오류: `${파일}${Number.isFinite(줄) ? `:${줄}` : ''} 에서 ${이름} 을 못 찾았습니다` };
99
+ }
100
+
101
+ /*
102
+ * 2) 이름만 준 경우. 프로젝트 전체에서 그 이름을 찾는다.
103
+ *
104
+ * 방금 켠 서버는 빈손으로 답한다. 없어서가 아니라 아직 프로젝트를 다 못
105
+ * 훑어서다 — 악수는 몇십 ms 면 끝나지만 색인은 몇 초씩 걸린다. 실제로 켠 지
106
+ * 0.2초 만에 물었더니 없다고 했고, 0.5초 뒤에 물으니 나왔다.
107
+ *
108
+ * 이 둘을 구별 안 하면 "그런 이름 없습니다" 가 되고, 모델은 그 말을 믿고
109
+ * 이미 있는 것을 새로 만든다. 그래서 **켠 지 얼마 안 됐을 때만** 몇 번 더
110
+ * 물어본다. 오래 돈 서버에서 빈손이면 그건 정말 없는 것이라 안 기다린다.
111
+ */
112
+ let 답 = await 서버.물어보기('workspace/symbol', { query: 이름 });
113
+ for (const 쉼 of [400, 800, 1500]) {
114
+ if (답.오류 || (Array.isArray(답.값) && 답.값.length)) break;
115
+ if (!색인중일까(서버)) break;
116
+ // 이 시계는 unref 하지 않는다. 여기는 도구가 도는 한가운데라,
117
+ // 놔 버리면 기다리는 사이에 프로그램이 그냥 끝나 버린다.
118
+ await new Promise((r) => setTimeout(r, 쉼));
119
+ 답 = await 서버.물어보기('workspace/symbol', { query: 이름 });
120
+ }
121
+ if (답.오류) return { 오류: 답.오류 };
122
+ const 것들 = (Array.isArray(답.값) ? 답.값 : []).filter((s) => s?.name);
123
+ // 이름이 똑같은 것만. 서버는 대개 부분 일치까지 준다.
124
+ const 딱맞는 = 것들.filter((s) => s.name === 이름);
125
+ const 쓸것 = 딱맞는.length ? 딱맞는 : 것들;
126
+ if (!쓸것.length) {
127
+ return {
128
+ 오류: `${이름} 을(를) 못 찾았습니다`
129
+ + (색인중일까(서버) ? ' (언어 서버가 아직 프로젝트를 훑는 중일 수 있습니다)' : '')
130
+ + '. Grep 으로 한 번 더 보세요.',
131
+ };
132
+ }
133
+
134
+ const 후보 = 쓸것.map((s) => {
135
+ const loc = s.location ?? {};
136
+ return { 이름: s.name, 갈래: s.kind, uri: loc.uri, range: loc.range ?? null, 컨테이너: s.containerName ?? '' };
137
+ }).filter((x) => x.uri);
138
+ if (!후보.length) return { 오류: `${이름} 의 자리를 못 받았습니다` };
139
+
140
+ const 첫 = 후보[0];
141
+ // WorkspaceSymbol 은 range 없이 오기도 한다. 그럼 파일을 열어 직접 짚는다.
142
+ let position = 첫.range?.start;
143
+ if (!position) {
144
+ try {
145
+ const abs = fileURLToPath(첫.uri);
146
+ const 줄들 = readFileSync(abs, 'utf8').split(/\r?\n/);
147
+ for (let i = 0; i < 줄들.length; i++) {
148
+ const 칸 = 칸찾기(줄들[i], 이름);
149
+ if (칸 >= 0) { position = { line: i, character: 칸 }; break; }
150
+ }
151
+ } catch { /* 아래에서 걸린다 */ }
152
+ }
153
+ if (!position) return { 오류: `${이름} 의 자리를 못 짚었습니다` };
154
+
155
+ // 정확히 이름 글자 위를 짚어야 한다. 정의 줄 맨 앞(`export function`)을 짚으면
156
+ // 서버가 아무것도 못 준다.
157
+ try {
158
+ const abs = fileURLToPath(첫.uri);
159
+ const 줄글 = readFileSync(abs, 'utf8').split(/\r?\n/)[position.line] ?? '';
160
+ const 칸 = 칸찾기(줄글, 이름);
161
+ if (칸 >= 0) position = { line: position.line, character: 칸 };
162
+ 서버.보여주기(abs);
163
+ } catch { /* 그대로 간다 */ }
164
+
165
+ return { 자리: { uri: 첫.uri, position }, 후보 };
166
+ }
167
+
168
+ /** 두 도구가 같은 앞머리를 쓴다 — 서버 얻고 자리 잡는 데까지. */
169
+ async function 채비(args, ctx) {
170
+ const 이름 = String(args.name ?? '').trim();
171
+ if (!이름) return { 오류: 'name 이 비었습니다' };
172
+
173
+ const 파일 = args.file_path ? String(args.file_path) : null;
174
+ /*
175
+ * 어느 언어 서버에게 물을지.
176
+ *
177
+ * 파일을 줬으면 그 파일의 언어다. 안 줬으면 이 폴더에서 제일 많은 언어로
178
+ * 간다 — 모델은 `handleClick 어디 있어` 처럼 이름만 알고 부르는 것이 보통이라,
179
+ * 매번 파일을 요구하면 이 도구를 쓰는 값이 사라진다.
180
+ */
181
+ const 볼것 = 파일 ?? 프로젝트갈래(ctx.scope.root)?.대표파일 ?? null;
182
+ if (!볼것) return { 오류: '이 폴더에서 쓸 수 있는 언어 서버가 없습니다. Grep · Outline 을 쓰세요.' };
183
+ if (!갈래(볼것)) return { 오류: `언어 서버가 없는 갈래입니다: ${볼것}` };
184
+
185
+ const 서버 = await 얻기(ctx.scope.root, 볼것);
186
+ if (!서버) return { 오류: '언어 서버가 이 자리에 없습니다. Grep · Outline 을 쓰세요.' };
187
+
188
+ const 잡음 = await 자리잡기(서버, ctx.scope, {
189
+ 이름, 파일, 줄: Number.isFinite(args.line) ? Number(args.line) : null,
190
+ });
191
+ if (잡음.오류) return { 오류: 잡음.오류 };
192
+ return { 이름, 서버, 자리: 잡음.자리, 후보: 잡음.후보 ?? [] };
193
+ }
194
+
195
+ /** 여러 곳에 같은 이름이 있으면 그대로 알려 준다. 하나를 골라 주고 아닌 척하지 않는다. */
196
+ function 여럿이면(후보, scope) {
197
+ if (!Array.isArray(후보) || 후보.length < 2) return null;
198
+ const 파일들 = new Set(후보.map((c) => { try { return scope.show(fileURLToPath(c.uri)); } catch { return c.uri; } }));
199
+ if (파일들.size < 2) return null;
200
+ return `같은 이름이 ${파일들.size}곳에 있습니다: ${[...파일들].slice(0, 5).join(' · ')}`
201
+ + `${파일들.size > 5 ? ' …' : ''}. 다른 것을 뜻했다면 file_path 로 짚어 주세요.`;
202
+ }
203
+
204
+ export const DEF_TOOL = {
205
+ schema: {
206
+ name: 'Def',
207
+ description:
208
+ '이름 하나가 **어디에 정의돼 있는지** 언어 서버에게 묻는다. 파일을 안 읽고도 자리를 안다.'
209
+ + ' Grep 과 다른 점은 틀린 자리를 안 준다는 것이다 — 주석에 든 같은 이름, 남의 라이브러리의'
210
+ + ' 같은 이름, 문자열 안의 같은 이름을 안 섞어 준다.'
211
+ + ' 남이 쓴 코드를 고치기 전에 이걸 먼저 불러라. 자리를 안 다음에 그 파일만 Read 하면 된다.'
212
+ + ' 이름이 여러 곳에 있으면 그 목록을 준다. file_path 로 어느 것인지 짚어 주면 된다.',
213
+ parameters: {
214
+ type: 'object',
215
+ properties: {
216
+ name: { type: 'string', description: '찾을 이름 (함수·클래스·변수)' },
217
+ file_path: { type: 'string', description: '그 이름이 쓰인 파일. 같은 이름이 여럿일 때 짚어 준다' },
218
+ line: { type: 'number', description: 'file_path 안에서 그 이름이 쓰인 줄 번호 (1부터)' },
219
+ },
220
+ required: ['name'],
221
+ },
222
+ },
223
+
224
+ async run(args, ctx) {
225
+ const 준비 = await 채비(args, ctx);
226
+ if (준비.오류) return { error: 준비.오류 };
227
+ const { 이름, 서버, 자리, 후보 } = 준비;
228
+
229
+ const 답 = await 서버.물어보기('textDocument/definition', {
230
+ textDocument: { uri: 자리.uri },
231
+ position: 자리.position,
232
+ });
233
+ if (답.오류) return { error: `언어 서버: ${답.오류}` };
234
+
235
+ let 곳들 = 자리들펴기(답.값).map((x) => 한줄(ctx.scope, x.uri, x.range));
236
+ // 서버가 정의를 못 주면(선언만 있는 자리 등) 심볼 검색으로 잡은 자리를 준다.
237
+ // 빈손으로 돌려보내는 것보다 낫고, 어디서 온 값인지 같이 말해 준다.
238
+ let 어디서 = 'definition';
239
+ if (!곳들.length && 후보.length) {
240
+ 곳들 = 후보.map((c) => 한줄(ctx.scope, c.uri, c.range));
241
+ 어디서 = 'workspace/symbol';
242
+ }
243
+ if (!곳들.length) return { summary: `${이름}: 정의를 못 찾았습니다. Grep 으로 찾아보세요.`, found: 0 };
244
+
245
+ const 여럿 = 여럿이면(후보, ctx.scope);
246
+ return {
247
+ summary: `${이름} — 정의 ${곳들.length}곳${여럿 ? `\n${여럿}` : ''}`,
248
+ found: 곳들.length,
249
+ source: 어디서,
250
+ locations: 곳들,
251
+ content: 곳들.map((l) => `${l.파일}:${l.줄} ${l.글}`).join('\n'),
252
+ };
253
+ },
254
+ };
255
+
256
+ export const REFS_TOOL = {
257
+ schema: {
258
+ name: 'Refs',
259
+ description:
260
+ '이름 하나를 **어디서 쓰는지** 언어 서버에게 다 묻는다. 이름을 바꾸거나 함수를 고치기 전에'
261
+ + ' 불러라 — 몇 군데를 같이 고쳐야 하는지가 여기서 나온다.'
262
+ + ' Grep 이 주는 수백 줄과 달리 진짜 그것을 쓰는 자리만 나온다. 대신 Grep 은 주석·설정·문서까지'
263
+ + ' 찾으니, 이름을 통째로 바꿀 때는 이걸로 코드를 잡고 Grep 으로 나머지를 훑어라.',
264
+ parameters: {
265
+ type: 'object',
266
+ properties: {
267
+ name: { type: 'string', description: '찾을 이름 (함수·클래스·변수)' },
268
+ file_path: { type: 'string', description: '그 이름이 정의된 파일. 같은 이름이 여럿일 때 짚어 준다' },
269
+ line: { type: 'number', description: 'file_path 안에서 그 이름이 있는 줄 번호 (1부터)' },
270
+ include_declaration: { type: 'boolean', description: '정의한 자리도 넣을지. 기본 false' },
271
+ },
272
+ required: ['name'],
273
+ },
274
+ },
275
+
276
+ async run(args, ctx) {
277
+ const 준비 = await 채비(args, ctx);
278
+ if (준비.오류) return { error: 준비.오류 };
279
+ const { 이름, 서버, 자리, 후보 } = 준비;
280
+
281
+ const 답 = await 서버.물어보기('textDocument/references', {
282
+ textDocument: { uri: 자리.uri },
283
+ position: 자리.position,
284
+ context: { includeDeclaration: args.include_declaration === true },
285
+ });
286
+ if (답.오류) return { error: `언어 서버: ${답.오류}` };
287
+
288
+ const 곳들 = 자리들펴기(답.값).map((x) => 한줄(ctx.scope, x.uri, x.range));
289
+ if (!곳들.length) {
290
+ return {
291
+ summary: `${이름}: 쓰는 자리가 없습니다.`
292
+ + ' 정말 안 쓰는 것일 수도 있고, 언어 서버가 아직 색인 중일 수도 있습니다 —'
293
+ + ' 지우기 전에 Grep 으로 한 번 더 보세요.',
294
+ found: 0,
295
+ };
296
+ }
297
+
298
+ // 창에 맞춰 자른다. 자른 것은 자랐다고 말한다 — 조용히 자르면 모델은
299
+ // 그게 전부인 줄 알고 나머지 자리를 안 고친다.
300
+ const 한도 = 찾을개수(ctx.모델컨텍스트 ?? null);
301
+ const 보일것 = 곳들.slice(0, 한도);
302
+ const 남은 = 곳들.length - 보일것.length;
303
+
304
+ // 파일별로 묶어야 읽힌다. 같은 파일 열 줄이 흩어져 있으면 몇 파일을
305
+ // 고쳐야 하는지가 안 보인다.
306
+ const 묶음 = new Map();
307
+ for (const l of 보일것) {
308
+ if (!묶음.has(l.파일)) 묶음.set(l.파일, []);
309
+ 묶음.get(l.파일).push(l);
310
+ }
311
+ const 글 = [...묶음.entries()]
312
+ .map(([f, 줄들]) => `${f} (${줄들.length})\n` + 줄들.map((l) => ` ${l.줄}: ${l.글}`).join('\n'))
313
+ .join('\n');
314
+
315
+ const 여럿 = 여럿이면(후보, ctx.scope);
316
+ return {
317
+ summary: `${이름} — 쓰는 자리 ${곳들.length}곳 · 파일 ${묶음.size}개`
318
+ + (남은 ? ` (${남은}곳은 자리가 모자라 안 실었습니다)` : '')
319
+ + (여럿 ? `\n${여럿}` : ''),
320
+ found: 곳들.length,
321
+ files: 묶음.size,
322
+ truncated: 남은 > 0,
323
+ locations: 보일것,
324
+ content: 글,
325
+ };
326
+ },
327
+ };
package/src/tools/task.js CHANGED
@@ -59,6 +59,27 @@ export const TASK_TOOL = {
59
59
  type: 'string',
60
60
  description: '하위가 일할 방식: code(만들고 고침) · debug(원인 찾기) · ask(읽고 답만). 안 적으면 code.',
61
61
  },
62
+ /*
63
+ * 다른 모델에게 떼어 주기.
64
+ *
65
+ * 로컬에서는 모델 하나로 다 하기가 늘 아쉽다. 7B 는 계획을 잘 세우는데
66
+ * 파일 열 개를 고치다 창이 차고, 1.5B 는 창은 넉넉한데 무엇을 할지를
67
+ * 못 정한다. 8GB 램이면 둘 다 올려 두고 나눠 쓰는 것이 실제로 되는
68
+ * 유일한 길이다 (7B q4 + 1.5B q4 ≒ 5.7GB).
69
+ *
70
+ * 설명에 '사람이 설정에 적어 둔 이름' 이라고 못을 박는다. 모델이
71
+ * 주소를 지어내면 그 자리로 나갈 뻔하는데, 그건 이 프로그램이 하는
72
+ * 약속을 그 자리에서 깨는 것이다. 실제 판단은 loop.js 가 한다 —
73
+ * 부탁으로 막지 않는다.
74
+ */
75
+ 모델: {
76
+ type: 'string',
77
+ description:
78
+ '이 덩이를 **다른 모델**에게 맡긴다. 사람이 설정에 적어 둔 프로필 이름만 쓸 수 있다'
79
+ + ' (주소를 지어내지 마라 — 안 먹는다). 안 적으면 지금 쓰는 모델 그대로다.'
80
+ + ' 잔일(형식 맞추기·반복 수정·짧은 요약)을 작은 모델에게 떼어 주면 네 창이 안 찬다.'
81
+ + ' 판단이 필요한 일은 네가 직접 해라.',
82
+ },
62
83
  },
63
84
  required: ['목적', '할일'],
64
85
  },
@@ -106,7 +127,7 @@ export function 하위모드(요청, 부모모드) {
106
127
  * 못 끝낸 것을 빼면 안 된다. 부모는 그걸 보고 이어서 할지 정한다 —
107
128
  * 다 됐다고 알면 안 끝난 일 위에 다음 일을 쌓는다.
108
129
  */
109
- export function 하위요약({ 목적, 모드, 끝, 글자수, 보인이름 = (p) => p }) {
130
+ export function 하위요약({ 목적, 모드, 끝, 글자수, 모델 = null, 보인이름 = (p) => p }) {
110
131
  const 줄 = [];
111
132
  const w = workMode(모드);
112
133
  const 왜끝났나 = {
@@ -116,7 +137,14 @@ export function 하위요약({ 목적, 모드, 끝, 글자수, 보인이름 = (p
116
137
  aborted: '사용자가 중단함 — 다 못 했습니다',
117
138
  }[끝?.type] ?? '끝난 이유를 알 수 없음';
118
139
 
119
- 줄.push(`하위 작업 "${목적}" · ${w.name} 모드 · ${끝?.steps ?? 0}걸음 · ${왜끝났나}`);
140
+ /*
141
+ * 다른 모델이 한 일이면 **요약에도 적는다.**
142
+ *
143
+ * 부모 모델이 이 요약만 보고 이어서 일하는데, 누가 한 것인지 모르면 작은
144
+ * 모델이 대충 해 놓은 것을 제가 한 것처럼 믿는다. "1.5B 가 했다" 가 적혀
145
+ * 있으면 미심쩍은 자리를 다시 볼 여지가 생긴다.
146
+ */
147
+ 줄.push(`하위 작업 "${목적}" · ${w.name} 모드${모델 ? ` · ${모델}` : ''} · ${끝?.steps ?? 0}걸음 · ${왜끝났나}`);
120
148
 
121
149
  const 파일들 = (끝?.files ?? []).filter((f) => !f.dir);
122
150
  if (파일들.length) {
package/src/ui/ansi.js CHANGED
@@ -148,6 +148,42 @@ export function gauge(ratio, cells = 10) {
148
148
  return tone('▰'.repeat(filled)) + c.gray('▱'.repeat(cells - filled));
149
149
  }
150
150
 
151
+ /**
152
+ * 눈금이 있는 게이지.
153
+ *
154
+ * 게이지가 차는 것은 보이는데 **언제 무슨 일이 나는지**는 안 보였다. 55% 를
155
+ * 넘으면 오래된 도구 결과를 접기 시작하고, 80% 를 넘으면 대화를 요약한다.
156
+ * 둘 다 사람 눈에는 갑자기 일어나는 일이라 — 어느 날 갑자기 "앞선 대화를
157
+ * 줄였습니다" 가 뜨고, 모델이 방금 읽은 파일을 잊는다.
158
+ *
159
+ * 그래서 그 자리에 눈금을 미리 그어 둔다. 막대가 다가가는 것이 보이면
160
+ * 사람이 먼저 손을 쓸 수 있다 — 못 박아 두거나(/pin), 갈래를 새로 파거나.
161
+ * 지나간 눈금은 안 그린다. 이미 일어난 일을 계속 가리킬 이유가 없고,
162
+ * 남겨 두면 막대가 눈금에 가려 어디까지 찼는지가 흐려진다.
163
+ *
164
+ * @param {number} ratio
165
+ * @param {number} cells
166
+ * @param {number[]} 눈금 0~1 사이 자리들
167
+ */
168
+ export function 눈금게이지(ratio, cells = 10, 눈금 = []) {
169
+ const n = Number(ratio);
170
+ const r = Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
171
+ const filled = Math.round(r * cells);
172
+ const tone = r > 0.8 ? c.hred : r > 0.55 ? c.hyellow : c.hgreen;
173
+ const 눈금칸 = new Set(
174
+ (Array.isArray(눈금) ? 눈금 : [])
175
+ .map((v) => Math.min(cells - 1, Math.max(0, Math.floor(Number(v) * cells))))
176
+ .filter((v) => Number.isFinite(v)),
177
+ );
178
+ let out = '';
179
+ for (let i = 0; i < cells; i++) {
180
+ if (i < filled) out += tone('▰');
181
+ else if (눈금칸.has(i)) out += c.white('┆');
182
+ else out += c.gray('▱');
183
+ }
184
+ return out;
185
+ }
186
+
151
187
  const BOX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' };
152
188
 
153
189
  /**
package/src/ui/approve.js CHANGED
@@ -13,29 +13,33 @@
13
13
  * 그래서 세 가지를 준다 — 눈에 띄는 글자, 사람 말 이름, 무엇을 묻는지 한 줄.
14
14
  */
15
15
  import { c } from './ansi.js';
16
+ import { 말 } from '../i18n/index.js';
17
+
18
+ /*
19
+ * 이름과 한 줄 설명은 화면 말(i18n)에서 읽는다. 글자와 색은 여기 남긴다 —
20
+ * ⏵⏵ 는 어느 말로도 ⏵⏵ 이고, 노랑은 어느 말로도 노랑이다.
21
+ *
22
+ * 게터로 둔다. 켤 때 한 번 읽어 굳히면 /lang 으로 바꿔도 상태줄의 승인
23
+ * 표시만 옛 말로 남는데, 하필 그게 안전 표시라 제일 안 어긋나야 할 자리다.
24
+ */
25
+ /*
26
+ * 펼치기(...)로 붙이면 **그 자리에서 값이 굳는다.** 게터를 펼치면 게터가
27
+ * 아니라 그때 읽은 글자가 복사되기 때문이다. 그러면 /lang 으로 바꿔도
28
+ * 승인 표시만 옛 말로 남는데, 하필 그게 안전 표시라 제일 안 어긋나야 할
29
+ * 자리다. defineProperty 로 게터인 채로 붙인다.
30
+ */
31
+ function 마디(키, 바탕) {
32
+ const 것 = { ...바탕 };
33
+ for (const [자리, 뒤] of [['이름', 'name'], ['짧은이름', 'short'], ['한줄', 'line']]) {
34
+ Object.defineProperty(것, 자리, { get: () => 말(`approve.${키}.${뒤}`), enumerable: true });
35
+ }
36
+ return 것;
37
+ }
16
38
 
17
39
  export const 승인 = {
18
- auto: {
19
- 글자: '⏵⏵',
20
- 이름: '자동 승인',
21
- 짧은이름: '자동',
22
- 한줄: '안 묻고 고칩니다. 되돌리기(/undo)가 안전망입니다',
23
- 색: c.hyellow,
24
- },
25
- confirm: {
26
- 글자: '⏵',
27
- 이름: '위험만 확인',
28
- 짧은이름: '위험만',
29
- 한줄: '되돌릴 수 없는 명령만 물어봅니다. 파일은 안 묻고 고칩니다',
30
- 색: c.white,
31
- },
32
- strict: {
33
- 글자: '⏸',
34
- 이름: '모두 확인',
35
- 짧은이름: '모두',
36
- 한줄: '파일을 고치거나 명령을 돌리기 전에 매번 물어봅니다',
37
- 색: c.hgreen,
38
- },
40
+ auto: 마디('auto', { 글자: '⏵⏵', 색: c.hyellow }),
41
+ confirm: 마디('confirm', { 글자: '', 색: c.white }),
42
+ strict: 마디('strict', { 글자: '⏸', 색: c.hgreen }),
39
43
  };
40
44
 
41
45
  export const 차례 = ['auto', 'confirm', 'strict'];
@@ -34,12 +34,24 @@
34
34
  * 값으로 내놓아야 한 칸도 안 어긋나는지 잴 수 있다.
35
35
  */
36
36
  import { c, width, cursor, clip } from './ansi.js';
37
+ import { isLocalHost } from '../safety/network.js';
37
38
  import { statusLine, contextWarning } from './status.js';
38
39
  import { 접어쓰기 } from './wrap.js';
39
40
  import { 문구고르기, 걸린시간, 느긋해질때, 문구주기 } from './working.js';
40
41
  import { 그림고르기 } from './motion.js';
41
42
  import { 최대추천 } from './complete.js';
42
43
 
44
+ /**
45
+ * 이 연결이 이 컴퓨터 밖으로 나가나.
46
+ *
47
+ * 못 읽는 주소면 **거짓**으로 본다. 여기서 애매한 것을 노랗게 칠해 버리면,
48
+ * 진짜로 밖으로 나갈 때의 노랑이 아무 뜻도 없어진다 — 늘 노란 테두리는
49
+ * 테두리가 회색인 것과 같다.
50
+ */
51
+ function 바깥으로가나(session) {
52
+ try { return !isLocalHost(new URL(session.conn.base).hostname); } catch { return false; }
53
+ }
54
+
43
55
  const 줄끝지움 = '\x1b[K';
44
56
  const 위로 = (n) => (n > 0 ? `\x1b[${n}A` : '');
45
57
  // 돌아가는 표시를 한 칸 넘기는 주기. 사람 눈에 부드럽고, 다시 그리는 값은 싸다.
@@ -64,8 +76,23 @@ export const 안쪽최대 = 8;
64
76
  * @returns {{줄들: string[], 커서: {위: number, 열: number}}}
65
77
  * 커서.위 = 마지막 줄에서 몇 줄 위인가, 커서.열 = 1부터 세는 칸
66
78
  */
67
- export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상태 = '', 경고 = '', 곁말 = '', 일감 = null, 추천 = [] } = {}) {
79
+ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상태 = '', 경고 = '', 곁말 = '', 일감 = null, 추천 = [], 바깥 = false } = {}) {
68
80
  const 칸 = Math.max(20, 폭);
81
+ /*
82
+ * ── 테두리가 곧 경계선이다 ───────────────────────────────────────────
83
+ *
84
+ * 이 상자는 사람이 글을 치는 자리이자, 그 글이 어디까지 가는지의 울타리다.
85
+ * 이 컴퓨터 안으로만 갈 때는 늘 보던 회색 테두리 그대로 두고, 바깥
86
+ * 게이트웨이로 붙어 있을 때만 **노랗게** 그린다.
87
+ *
88
+ * 상태줄 글자 한 칸(⌂/↗)으로도 되지 않느냐 싶지만, 그건 좁으면 밀릴 수 있고
89
+ * 무엇보다 사람이 안 본다. 테두리는 안 볼 수가 없다 — 치는 글을 감싸고 있어서,
90
+ * 눈이 글자를 따라가는 내내 시야에 들어온다. 칸도 한 칸 안 먹는다.
91
+ *
92
+ * 시끄러운 것이 맞고, 그게 뜻이다. 내 소스가 밖으로 나가는 중이라면
93
+ * 화면이 그만큼은 말해 줘야 한다.
94
+ */
95
+ const 테 = 바깥 ? c.hyellow : c.gray;
69
96
  const 안쪽 = Math.max(4, 칸 - 앞머리 - 2); // 오른쪽 ' │' 두 칸
70
97
  const 줄들 = [];
71
98
 
@@ -73,7 +100,7 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
73
100
  if (경고) 줄들.push(` ${경고}`);
74
101
 
75
102
  const 가로 = '─'.repeat(칸 - 3);
76
- 줄들.push(c.gray(` ╭${가로}╮`));
103
+ 줄들.push((` ╭${가로}╮`));
77
104
 
78
105
  /*
79
106
  * 일하는 중.
@@ -98,7 +125,7 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
98
125
  const 남 = 일안쪽 - width(왼쪽) - width(오른쪽);
99
126
  // 자리가 모자라면 오른쪽(걸린 시간·중단 안내)을 버린다. 왼쪽이 본문이다.
100
127
  const 안 = 남 >= 2 ? `${왼쪽}${' '.repeat(남)}${오른쪽}` : 왼쪽;
101
- 줄들.push(` ${c.gray('│')} ${안}${' '.repeat(Math.max(0, 일안쪽 - width(안)))} ${c.gray('│')}`);
128
+ 줄들.push(` ${('│')} ${안}${' '.repeat(Math.max(0, 일안쪽 - width(안)))} ${('│')}`);
102
129
 
103
130
  /*
104
131
  * 일하는 도중에 미리 쳐 두는 자리.
@@ -114,11 +141,11 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
114
141
  const 대기줄들 = 접어쓰기(일감.대기, 안쪽).slice(0, 3);
115
142
  for (const [i, 한줄] of 대기줄들.entries()) {
116
143
  const 표 = i === 0 ? c.hcyan('❯') : ' ';
117
- 줄들.push(` ${c.gray('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 안쪽 - width(한줄)))} ${c.gray('│')}`);
144
+ 줄들.push(` ${('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 안쪽 - width(한줄)))} ${('│')}`);
118
145
  }
119
146
  }
120
147
 
121
- 줄들.push(c.gray(` ╰${가로}╯`));
148
+ 줄들.push((` ╰${가로}╯`));
122
149
  if (일감.대기) 줄들.push(` ${c.gray('Enter 를 치면 지금 일이 끝난 뒤에 보냅니다')}`);
123
150
  else if (곁말) 줄들.push(` ${c.gray(곁말)}`);
124
151
 
@@ -141,9 +168,9 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
141
168
  for (const [i, 한줄] of 보일줄.entries()) {
142
169
  const 표 = i === 0 ? (넘침 ? c.gray('…') : c.hcyan('❯')) : ' ';
143
170
  const 남 = 안쪽 - width(한줄);
144
- 줄들.push(` ${c.gray('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 남))} ${c.gray('│')}`);
171
+ 줄들.push(` ${('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 남))} ${('│')}`);
145
172
  }
146
- 줄들.push(c.gray(` ╰${가로}╯`));
173
+ 줄들.push((` ╰${가로}╯`));
147
174
 
148
175
  /*
149
176
  * 자동완성 추천은 상자 **아래**에 놓는다.
@@ -382,6 +409,9 @@ export class InputBox {
382
409
  곁말: this.곁말,
383
410
  일감,
384
411
  추천,
412
+ // 테두리 색이 경계선을 말한다. 못 읽는 주소면 평소대로 둔다 —
413
+ // 애매한 것을 노랗게 칠하면, 진짜로 밖으로 나갈 때의 노랑이 안 먹힌다.
414
+ 바깥: this.session ? 바깥으로가나(this.session) : false,
385
415
  });
386
416
 
387
417
  /*