deel-local-cli 1.5.5 → 1.5.7

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.
@@ -1,7 +1,7 @@
1
1
  // 도구. 이름과 인자를 Claude Code 와 같게 맞춘다 —
2
2
  // 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
3
- import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
4
- import { dirname } from 'node:path';
3
+ import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync, renameSync, cpSync, rmSync } from 'node:fs';
4
+ import { dirname, join, relative, sep } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { globToRegex, walk, readText, readTextFull, 내부살림 } from './fsutil.js';
7
7
  import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
@@ -24,7 +24,7 @@ import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOl
24
24
  import { diffLines } from '../ui/diff.js';
25
25
  import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
26
26
  import { 도구설명EN } from './desc.en.js';
27
- import { 언어 } from '../i18n/index.js';
27
+ import { 지시말 } from '../i18n/index.js';
28
28
 
29
29
  /*
30
30
  * 한 번에 돌려줄 양은 **모델에 맞춰** 정한다 (agent/budget.js).
@@ -265,6 +265,121 @@ function 문서읽기(abs, ctx) {
265
265
  * 것이 중요하다. 한 덩이로 뜨면 `/undo` 가 전부-아니면-전무가 되어, 넷 중
266
266
  * 하나만 잘못 만들었을 때 나머지 셋까지 날려야 한다.
267
267
  */
268
+ /*
269
+ * ── 하나 옮기기 ─────────────────────────────────────────────────────────
270
+ *
271
+ * 되돌리기는 **내용을 떠 놓는** 방식이다(safety/undo.js). 옮기기는 그 틀에
272
+ * 이렇게 얹는다: 떠난 자리와 닿을 자리를 **둘 다** 뜬다.
273
+ *
274
+ * 떠난 자리 — 내용이 떠지니 되돌리면 그 자리에 다시 쓰인다
275
+ * 닿을 자리 — 없던 파일이라 null 로 떠지고, 되돌리면 지워진다
276
+ *
277
+ * 둘이 합쳐져야 '옮기기 전' 이 된다. 하나만 뜨면 되돌린 뒤 파일이 두 군데
278
+ * 있거나(닿을 자리를 안 뜬 경우) 한 군데도 없다(떠난 자리를 안 뜬 경우).
279
+ *
280
+ * 그림·zip 같은 바이너리는 내용을 못 뜬다(skipped). 그때 되돌리기는 그 파일을
281
+ * **손대지 않는다** — 옮겨진 자리에 그대로 남는다. 되돌아가지는 않지만
282
+ * 없어지지도 않는다. 안전망이 파일을 지우는 것보다 이쪽이 낫다.
283
+ */
284
+ function 한개옮기기({ from, to, overwrite = false }, ctx) {
285
+ if (typeof from !== 'string' || !from) return { error: 'from 이 없습니다' };
286
+ if (typeof to !== 'string' || !to) return { error: 'to 가 없습니다' };
287
+
288
+ const 앞 = ctx.scope.resolve(from);
289
+ const 뒤 = ctx.scope.resolve(to);
290
+ if (!existsSync(앞)) return { error: `없는 파일입니다: ${from}` };
291
+ if (앞 === 뒤) return { error: `옮길 자리가 지금 자리와 같습니다: ${from}` };
292
+
293
+ // 읽기만 막고 옮기기를 열어 두면 .deel/config.json 을 옮겨 연결을 끊을 수 있다.
294
+ for (const p of [앞, 뒤]) { const 왜 = 내부살림(p); if (왜) return { error: 왜 }; }
295
+
296
+ const 폴더인가 = statSync(앞).isDirectory();
297
+ /*
298
+ * 폴더를 **제 안으로** 옮기면 그 폴더가 통째로 사라진다 (`mv a a/b`).
299
+ * 셸에서도 잘 나는 사고라 여기서 막는다.
300
+ */
301
+ if (폴더인가 && (뒤 + sep).startsWith(앞 + sep)) {
302
+ return { error: `폴더를 제 안으로 옮길 수 없습니다: ${from} → ${to}` };
303
+ }
304
+
305
+ /*
306
+ * 닿을 자리에 이미 있으면 **기본값은 거절**이다.
307
+ *
308
+ * 조용히 덮어쓰면 그 파일 내용이 그 자리에서 없어진다. 구조를 바꾸는 일은
309
+ * 파일을 스무 개씩 옮기는 일이라, 이름이 겹치는 것이 드물지 않다.
310
+ * 겹쳤다고 알려 주면 모델이 이름을 고쳐서 다시 부른다.
311
+ */
312
+ if (existsSync(뒤) && !overwrite) {
313
+ return { error: `이미 있습니다: ${to}\n 덮어쓰려면 overwrite: true 를 주세요.` };
314
+ }
315
+
316
+ /*
317
+ * 되돌릴 수 있게 뜬다.
318
+ *
319
+ * 폴더면 안의 파일을 하나씩 짝지어 뜬다. walk 는 node_modules 같은 것을
320
+ * 건너뛰므로(fsutil.js 의 SKIP_DIRS) 그 안은 이력에 안 남는다 — 옮기기는
321
+ * 그대로 되지만 되돌리기에는 안 잡힌다. 그런 폴더를 되돌리자고 수만 개를
322
+ * 뜨는 쪽이 훨씬 나쁘다.
323
+ */
324
+ const 짝들 = 폴더인가
325
+ ? walk(앞).map((f) => [f.path, join(뒤, relative(앞, f.path))])
326
+ : [[앞, 뒤]];
327
+ for (const [a, b] of 짝들) {
328
+ ctx.history.snapshot(a, 'Move');
329
+ ctx.history.snapshot(b, 'Move');
330
+ }
331
+
332
+ mkdirSync(dirname(뒤), { recursive: true });
333
+ try {
334
+ renameSync(앞, 뒤);
335
+ } catch (err) {
336
+ /*
337
+ * 드라이브가 다르면 rename 이 안 된다 (윈도우 C: → D:, 리눅스 마운트 경계).
338
+ * 그때만 복사해서 옮긴다. 늘 복사하지 않는 것은 큰 폴더에서 값이 크고,
339
+ * 복사 도중에 끊기면 양쪽에 반씩 남기 때문이다.
340
+ */
341
+ if (err.code !== 'EXDEV') return { error: `못 옮겼습니다: ${err.message}` };
342
+ try {
343
+ cpSync(앞, 뒤, { recursive: true });
344
+ rmSync(앞, { recursive: true, force: true });
345
+ } catch (err2) {
346
+ return { error: `못 옮겼습니다: ${err2.message}` };
347
+ }
348
+ }
349
+
350
+ for (const [, b] of 짝들) ctx.seen.add(b);
351
+ const 무엇 = 폴더인가 ? `폴더 ${짝들.length}개 파일` : '';
352
+ return {
353
+ content: `옮김: ${ctx.scope.show(앞)} → ${ctx.scope.show(뒤)}${무엇 ? ` (${무엇})` : ''}`,
354
+ summary: `${ctx.scope.show(뒤)}`,
355
+ changed: 뒤,
356
+ };
357
+ }
358
+
359
+ /** 여러 개를 한 번에. 하나가 막혀도 나머지는 옮기고, 무엇이 막혔는지 다 알린다. */
360
+ function 여러개옮기기(목록, ctx) {
361
+ const 된것 = [];
362
+ const 안된것 = [];
363
+ for (const 하나 of 목록) {
364
+ const r = 한개옮기기(하나, ctx);
365
+ if (r.error) 안된것.push(`${하나.from ?? '?'} → ${하나.to ?? '?'}: ${r.error}`);
366
+ else 된것.push(r.content);
367
+ }
368
+ /*
369
+ * 하나도 못 옮겼으면 오류로 돌려준다. 그래야 모델이 '됐다' 고 넘어가지
370
+ * 않는다. 하나라도 됐으면 결과로 돌려주되 막힌 것을 그 안에 다 적는다 —
371
+ * 절반만 옮겨진 상태를 모르고 지나가는 것이 제일 나쁘다.
372
+ */
373
+ if (!된것.length) return { error: 안된것.join('\n') || '옮길 것이 없습니다' };
374
+ return {
375
+ content: `${된것.length}개 옮겼습니다${안된것.length ? `, ${안된것.length}개 실패` : ''}.\n`
376
+ + 된것.map((c) => ` ✓ ${c.replace(/^옮김: /, '')}`).join('\n')
377
+ + (안된것.length ? `\n${안된것.map((e) => ` ✗ ${e.split('\n')[0]}`).join('\n')}` : '')
378
+ + (안된것.length ? '\n\n실패한 것만 다시 보내세요. 된 것은 다시 안 보내도 됩니다.' : ''),
379
+ summary: `${된것.length}개 옮김${안된것.length ? ` · ${안된것.length}개 실패` : ''}`,
380
+ };
381
+ }
382
+
268
383
  function 한파일쓰기(args, ctx) {
269
384
  const abs = ctx.scope.resolve(args.file_path);
270
385
  if (typeof args.content !== 'string') return { error: 'content 가 문자열이 아닙니다' };
@@ -712,6 +827,62 @@ export const TOOLS = {
712
827
  },
713
828
  },
714
829
 
830
+ /*
831
+ * ── 파일을 옮긴다 ───────────────────────────────────────────────────────
832
+ *
833
+ * 이게 없어서 「폴더 구조를 역할에 맞게 바꿔 줘」 가 계속 헛돌았다.
834
+ *
835
+ * 도구 목록에 옮기는 것이 없으니 길은 `Bash mv` 하나뿐이었는데, mv 는 위험
836
+ * 명령으로 잡혀 있어서(safety/guard.js 의 MUTATING) 승인 방식에 따라
837
+ * **파일 하나 옮길 때마다** 물었다. 스무 개면 스무 번이다. 게다가 Bash 로
838
+ * 옮긴 것은 되돌리기에 안 잡혀서, /undo 를 눌러도 구조가 안 돌아왔다.
839
+ *
840
+ * 그래서 시키는 일에 맞는 도구가 없었던 것이지, 모델이 말을 안 들은 게
841
+ * 아니었다. 도구를 주면 한 번에 스무 개를 옮기고 되돌리기에도 잡힌다.
842
+ */
843
+ Move: {
844
+ schema: {
845
+ name: 'Move',
846
+ description: '파일·폴더를 옮기거나 이름을 바꾼다. 구조를 바꾸는 일은 이걸로 한다.'
847
+ + ' **여러 개는 moves 배열로 한 번에** — 스무 개를 옮기려고 스무 번 부르지 마라.'
848
+ + ' Bash 의 mv 를 쓰지 마라. 되돌리기에 안 잡히고 매번 승인을 묻는다.',
849
+ parameters: {
850
+ type: 'object',
851
+ properties: {
852
+ from: { type: 'string', description: '옮길 파일·폴더 (한 개일 때)' },
853
+ to: { type: 'string', description: '옮겨 갈 자리. 없는 폴더는 만든다 (한 개일 때)' },
854
+ moves: {
855
+ type: 'array',
856
+ description: '여러 개를 한 번에. 이걸 쓰면 from·to 는 안 쓴다.',
857
+ items: {
858
+ type: 'object',
859
+ properties: {
860
+ from: { type: 'string', description: '옮길 파일·폴더' },
861
+ to: { type: 'string', description: '옮겨 갈 자리' },
862
+ },
863
+ required: ['from', 'to'],
864
+ },
865
+ },
866
+ overwrite: {
867
+ type: 'boolean',
868
+ description: '옮겨 갈 자리에 이미 있어도 덮어쓴다. 기본은 false 라 겹치면 거절한다',
869
+ },
870
+ },
871
+ required: [],
872
+ },
873
+ },
874
+ run(args, ctx) {
875
+ const 목록 = Array.isArray(args.moves) ? args.moves.filter((x) => x && typeof x === 'object') : [];
876
+ if (목록.length) {
877
+ return 여러개옮기기(목록.map((m) => ({ ...m, overwrite: m.overwrite ?? args.overwrite })), ctx);
878
+ }
879
+ if (typeof args.from !== 'string' || !args.from) {
880
+ return { error: 'from 이 없습니다. 한 개면 from·to 를, 여러 개면 moves 배열을 주세요.' };
881
+ }
882
+ return 한개옮기기(args, ctx);
883
+ },
884
+ },
885
+
715
886
  Glob: {
716
887
  schema: {
717
888
  name: 'Glob',
@@ -1083,6 +1254,85 @@ export const TOOLS = {
1083
1254
  *
1084
1255
  * 이 폴더의 기록만 본다. 작업 범위 밖은 애초에 읽을 수 없다.
1085
1256
  */
1257
+ /*
1258
+ * ── 사람에게 되묻기 ─────────────────────────────────────────────────────
1259
+ *
1260
+ * 이게 없어서 실제로 이런 일이 났다. 큰 폴더를 "역할에 맞게 구조 바꿔 줘"
1261
+ * 라고 시켰더니, 모델이 파일을 스무 개쯤 읽고 나서 대화창에 이렇게 적고
1262
+ * 멈췄다:
1263
+ *
1264
+ * "수행할 구체적인 작업 요청이 없습니다. 원하는 변경 사항을 알려주세요."
1265
+ *
1266
+ * 요청은 있었다. 모델이 **어떻게 물어야 할지 몰랐던** 것이다. 물어볼 길이
1267
+ * 없으니 글로 적고 턴을 끝냈고, 사람은 아무것도 안 된 화면을 봤다.
1268
+ *
1269
+ * 되묻기가 도구여야 하는 이유:
1270
+ * · 글로 물으면 그 턴이 **끝난다.** 사람이 답해도 앞의 조사 결과는 이미
1271
+ * 끝난 턴에 있고, 모델은 처음부터 다시 읽는다.
1272
+ * · 도구로 물으면 답이 도구 결과로 돌아와 **하던 자리에서 이어진다.**
1273
+ *
1274
+ * 고를 것을 같이 주게 하는 것이 핵심이다. "어떻게 할까요?" 만 오면 사람은
1275
+ * 다시 생각해야 하지만, 세 갈래를 주면 숫자 하나로 끝난다.
1276
+ */
1277
+ Ask: {
1278
+ schema: {
1279
+ name: 'Ask',
1280
+ /*
1281
+ * 첫 문장에 제일 중요한 것을 둔다 — 좁은 창에서는 설명줄이기가
1282
+ * **뒤에서부터 문장째로** 잘라 낸다. 8k 에서 남는 것은 앞 90자다.
1283
+ */
1284
+ description: '갈림길에서 사람에게 하나 묻는다. 글로 "알려주세요" 하고 끝내지 마라 —'
1285
+ + ' 턴이 끝나서 여태 본 것이 버려진다. options 에 2~4개를 주면 숫자로 답한다.'
1286
+ + ' **이미 사용자가 말한 것을 다시 묻지 마라.** "파일 정리해 줘" 라고 했으면'
1287
+ + ' 그게 답이다 — 그대로 하면 된다. 어떻게 할지 정하는 것은 네 일이다.',
1288
+ parameters: {
1289
+ type: 'object',
1290
+ properties: {
1291
+ question: { type: 'string', description: '한 문장짜리 질문. 무엇을 정해야 하는지 분명하게' },
1292
+ options: {
1293
+ type: 'array',
1294
+ items: { type: 'string' },
1295
+ description: '고를 것 2~4개. 각각 한 줄로, 무엇이 달라지는지 알 수 있게',
1296
+ },
1297
+ },
1298
+ required: ['question'],
1299
+ },
1300
+ },
1301
+ async run(args, ctx) {
1302
+ const 물음 = String(args.question ?? '').trim();
1303
+ if (!물음) return { error: 'question 이 비었습니다' };
1304
+
1305
+ const 고를것 = (Array.isArray(args.options) ? args.options : [])
1306
+ .map((x) => String(x ?? '').trim()).filter(Boolean).slice(0, 4);
1307
+
1308
+ /*
1309
+ * 물어볼 자리가 없는 데서도 안 죽어야 한다 — `deel -p` 한 방 실행, 파이프,
1310
+ * 하위 작업. 거기서는 **막히지 말고** 스스로 판단하라고 돌려준다.
1311
+ * 답을 기다리며 서 있으면 그 실행은 영영 안 끝난다.
1312
+ */
1313
+ /*
1314
+ * 결과는 반드시 `content` 로 돌려준다.
1315
+ *
1316
+ * loop.js 가 대화에 싣는 것은 `result.content` 하나다(그 자리 하나로
1317
+ * 비밀 가리기·중복 셈이 다 걸린다). 처음에 `answer` 로만 돌려줬더니
1318
+ * 화면에는 답이 멀쩡히 뜨는데 **모델에게는 빈 글이 갔다** — 물어보고
1319
+ * 답을 받고도 못 들은 셈이라, 없느니만 못한 자리가 될 뻔했다.
1320
+ */
1321
+ if (typeof ctx.ask물음 !== 'function') {
1322
+ return {
1323
+ content: '지금은 사람에게 물을 수 없는 자리입니다(한 방 실행·하위 작업).'
1324
+ + ' 되묻지 말고 가장 그럴듯한 쪽으로 진행하고, 무엇을 가정했는지 끝에 적으세요.',
1325
+ };
1326
+ }
1327
+
1328
+ const 답 = await ctx.ask물음(물음, 고를것);
1329
+ if (답 === null || 답 === undefined || String(답).trim() === '') {
1330
+ return { content: '사람이 답하지 않았습니다. 되묻지 말고 스스로 판단해 이어가세요.' };
1331
+ }
1332
+ return { content: `사람의 답: ${String(답)}` };
1333
+ },
1334
+ },
1335
+
1086
1336
  Recall: {
1087
1337
  schema: {
1088
1338
  name: 'Recall',
@@ -1206,7 +1456,10 @@ export const TOOLS = {
1206
1456
  *
1207
1457
  * 인자 설명도 같이 줄인다. 괄호로 붙인 보충(`(한 개일 때)`)이 먼저 떨어진다.
1208
1458
  */
1209
- const 뻔한인자 = new Set(['file_path', 'content', 'pattern', 'path', 'command', 'text', 'name']);
1459
+ // from·to Move 쓴다. 옮기는 도구에서 "어디서 어디로" 는 이름만으로 안다.
1460
+ const 뻔한인자 = new Set([
1461
+ 'file_path', 'content', 'pattern', 'path', 'command', 'text', 'name', 'question', 'from', 'to',
1462
+ ]);
1210
1463
 
1211
1464
  export function 설명줄이기(schema, 한도) {
1212
1465
  if (!Number.isFinite(한도)) return schema;
@@ -1266,14 +1519,18 @@ export function 설명줄이기(schema, 한도) {
1266
1519
  }
1267
1520
 
1268
1521
  /**
1269
- * 도구 설명을 지금 화면 말에 맞춘다.
1522
+ * 도구 설명을 **모델에게 주는 말**에 맞춘다 (i18n 의 지시말).
1523
+ *
1524
+ * 화면 말이 아니라 지시말을 본다. 도구 설명은 사람이 아니라 모델이 읽는
1525
+ * 글이라, '한국어 화면 + 영어 지시' 를 고른 사람에게는 여기도 영어로
1526
+ * 가야 한다 — 그래야 아끼려던 토큰이 실제로 아껴진다.
1270
1527
  *
1271
- * 표에 없는 도구·인자는 한글 설명이 그대로 나간다 — 화면 말과 같은 규칙이다.
1528
+ * 표에 없는 도구·인자는 한글 설명이 그대로 나간다.
1272
1529
  * 빈 설명을 내보내지 않는다. 설명 없는 도구는 모델이 언제 쓰는지 모른 채로
1273
1530
  * 목록에만 서 있게 되는데, 그건 없는 것보다 나쁘다.
1274
1531
  */
1275
1532
  function 영어설명(schema, 이름) {
1276
- if (언어() !== 'en') return schema;
1533
+ if (지시말() !== 'en') return schema;
1277
1534
  const 것 = 도구설명EN[이름];
1278
1535
  if (!것) return schema;
1279
1536
 
package/src/ui/ansi.js CHANGED
@@ -71,6 +71,25 @@ export const cursor = {
71
71
  보임: ON ? '\x1b[?25h' : '',
72
72
  };
73
73
 
74
+ /*
75
+ * 한 판을 다 그린 뒤에 한 번에 보여 달라 (synchronized output, DEC 2026).
76
+ *
77
+ * 한 번의 write 로 보내도 터미널은 받는 대로 그린다. 그래서 열두 줄이 같이
78
+ * 바뀌는 판에서는 고쳐 그리는 중간 모습이 눈에 들어온다 — 잔상·찢김으로 보인다.
79
+ * 이 두 표 사이에 든 것은 다 받은 뒤에 한 번에 바뀐다.
80
+ *
81
+ * 모르는 터미널은 **그냥 무시한다**. 사설 모드라 못 알아들으면 아무 일도
82
+ * 안 일어난다 — 켜지는 데서만 좋아지고 나머지는 그대로다. (맥 기본 터미널은
83
+ * 이걸 모른다. 거기 떨림은 이걸로 안 잡힌다.)
84
+ *
85
+ * 색과 달리 ON 으로 안 막는다. 이걸 쓰는 자리는 입력 상자 하나뿐인데 그
86
+ * 화면은 터미널일 때만 켜진다 — 거기서 한 겹 더 막으면 막힌 것을 모르고
87
+ * 「안 나가는데」를 좋게 겪는다(처음 넣을 때 실제로 그랬다). 색이 아니므로
88
+ * NO_COLOR 과도 상관없다.
89
+ */
90
+ export const 판시작 = '\x1b[?2026h';
91
+ export const 판끝 = '\x1b[?2026l';
92
+
74
93
  // 한글·한자·가나는 터미널에서 두 칸을 차지한다. 표 정렬이 이걸 모르면 어긋난다.
75
94
  export function width(str) {
76
95
  let w = 0;
@@ -33,7 +33,7 @@
33
33
  * 2) 그릴 것을 값으로 내놓는다(프레임). 검사는 파이프로 도니 화면을 못 본다 —
34
34
  * 값으로 내놓아야 한 칸도 안 어긋나는지 잴 수 있다.
35
35
  */
36
- import { c, width, cursor, clip } from './ansi.js';
36
+ import { c, width, cursor, clip, 판시작, 판끝 } from './ansi.js';
37
37
  import { isLocalHost } from '../safety/network.js';
38
38
  import { statusLine, contextWarning } from './status.js';
39
39
  import { 접어쓰기 } from './wrap.js';
@@ -457,7 +457,9 @@ export class InputBox {
457
457
  // 예약해 둔 것이 있으면 잊지 않게 세어 준다. 몇 분짜리 일이 끝난 뒤
458
458
  // 엉뚱한 말이 저절로 나가는 것처럼 보이면 안 된다.
459
459
  this.일감.예약수 ? `${this.일감.예약수}건 예약됨` : '',
460
- 'Ctrl+C 중단',
460
+ // 도는 동안 보이는 자리라 여기서는 ESC 만 말한다. Ctrl+C 도 되지만
461
+ // 두 번 누르면 끝나 버려서, 급히 멈추려는 사람에게 권할 것이 못 된다.
462
+ 'ESC 중단',
461
463
  ].filter(Boolean).join(' · '),
462
464
  대기: this.일감.대기,
463
465
  });
@@ -522,21 +524,38 @@ export class InputBox {
522
524
  const 폭 = 칸수 ?? 80;
523
525
 
524
526
  /*
525
- * 지난번에 그린 크기와 다르면 들고 있던 것을 다 버린다.
527
+ * ── 크기가 바뀌었을 ──────────────────────────────────────────────
526
528
  *
527
- * 터미널이 이미 제 손으로 줄을 다시 접어 놨으므로, 옛 폭에서 센 값으로
528
- * 커서를 올리면 엉뚱한 줄에 덮어쓴다 — 대화가 지워지는 길이 그것이다.
529
+ * 폭이 바뀌면 터미널이 **이미 제 손으로 줄을 다시 접어 놨다.** 옛 폭에서
530
+ * 센 값으로 커서를 올리면 엉뚱한 줄에 덮어쓴다 — 대화가 지워지는 길이다.
529
531
  *
530
- * 그린줄을 0 으로 두면 위로 올라가고 지금 자리에서 새로 그린다. 창을
531
- * 줄인 직후 한 번은 낡은 상자가 위에 남을 있는데, 그건 스크롤로 지나가는
532
- * 자국일 뿐이다. **어디 있는지 모르는 줄을 짐작해서 덮어쓰는 것보다,
533
- * 안 건드리고 새로 그리는 편이 낫다.**
532
+ * 그래서 예전에는 크기가 조금이라도 다르면 그냥 버리고 지금 자리에서
533
+ * 새로 그렸다. "낡은 상자가 한 번 위에 남지만 스크롤 자국일 뿐" 이라는
534
+ * 셈이었는데, **한 번이 아니었다.**
535
+ *
536
+ * 창을 마우스로 끌면 터미널은 한 칸마다 크기를 새로 알려 준다. 끌면서
537
+ * 쉰 번이 오면 상자를 쉰 벌 찍고 하나도 안 지운다 — 사무실이 화면을
538
+ * 다다다 타고 내려가는 그 현상이 이것이다. 세로로 늘릴 때 특히 심하다.
539
+ * 28줄을 넘는 순간 사무실 열두 줄이 통째로 더 붙기 때문이다.
540
+ *
541
+ * 나눠서 다룬다. **접힘이 그대로면 들고 있던 수도 그대로 맞다.**
542
+ *
543
+ * 높이만 바뀌었다 — 줄 접힘은 폭이 정한다. 폭이 그대로면 접힘도 그대로라
544
+ * 그린줄·커서위 가 아직 맞다. 평소처럼 제대로 지운다.
545
+ * 폭이 바뀌었다 — 옛 수는 다 틀어졌다. 대신 커서 아래를 통째로 쓸어낸다.
546
+ * 상자는 늘 맨 아래에 있어서 그 아래에는 아무것도 없다.
547
+ * 위로는 안 올라가니 대화를 건드릴 일이 없다.
534
548
  */
535
549
  const 높이 = process.stdout.rows ?? null;
536
550
  if (this.그린폭 !== 폭 || this.그린높이 !== 높이) {
537
- this.앞줄들 = null;
538
- this.그린줄 = 0;
539
- this.커서위 = 0;
551
+ if (this.그린폭 === 폭) {
552
+ this.지우기();
553
+ } else {
554
+ if (this.그린줄) process.stdout.write('\r\x1b[J');
555
+ this.앞줄들 = null;
556
+ this.그린줄 = 0;
557
+ this.커서위 = 0;
558
+ }
540
559
  this.그린폭 = 폭;
541
560
  this.그린높이 = 높이;
542
561
  }
@@ -616,7 +635,22 @@ export class InputBox {
616
635
  || 남은줄 > 0
617
636
  || 지금때 - this.마지막통째로 >= 자가치유주기;
618
637
 
619
- let 나갈것 = cursor.숨김 + '\r' + 위로(윗줄까지);
638
+ /*
639
+ * ── 한 판을 통째로 바꿔 달라고 터미널에 부탁한다 ──────────────────────
640
+ *
641
+ * 여기서 나가는 것은 이미 한 번의 write 다. 그래도 떨림이 보이는 터미널이
642
+ * 있다 — write 는 한 번이라도 터미널은 받는 대로 그리므로, 열두 줄을
643
+ * 고쳐 그리는 중간 모습이 그대로 눈에 들어온다. 사무실처럼 한 판에 여러
644
+ * 줄이 같이 바뀌는 화면에서 제일 심하다(잔상·찢김).
645
+ *
646
+ * `?2026` 은 "이 사이에 들어오는 것은 다 그린 뒤에 한 번에 보여 달라" 는
647
+ * 뜻이다. 지원하는 터미널(iTerm2 · WezTerm · kitty · Windows Terminal ·
648
+ * Ghostty)에서는 떨림이 사라지고, 모르는 터미널은 **그냥 무시한다** —
649
+ * 사설 모드라 못 알아들으면 아무 일도 안 일어난다. 그래서 잃을 것이 없다.
650
+ *
651
+ * 맥 기본 터미널은 이걸 모른다. 거기 떨림은 이걸로 안 잡힌다.
652
+ */
653
+ let 나갈것 = 판시작 + cursor.숨김 + '\r' + 위로(윗줄까지);
620
654
  if (통째로) {
621
655
  나갈것 += 줄들.map((l) => l + 줄끝지움).join('\n') + '\n';
622
656
  for (let i = 0; i < 남은줄; i++) 나갈것 += 줄끝지움 + '\n';
@@ -638,7 +672,7 @@ export class InputBox {
638
672
  */
639
673
  const 오른쪽 = Math.max(0, 자리.열 - 1);
640
674
  나갈것 += 위로(남은줄 + 자리.위 + 1) + '\r' + (오른쪽 ? `\x1b[${오른쪽}C` : '');
641
- 나갈것 += cursor.보임;
675
+ 나갈것 += cursor.보임 + 판끝;
642
676
  process.stdout.write(나갈것);
643
677
 
644
678
  this.그린줄 = 줄들.length;
package/src/ui/status.js CHANGED
@@ -10,6 +10,9 @@ import { COMPACT_AT, FOLD_AT } from '../agent/compact.js';
10
10
  import { 말, 언어 } from '../i18n/index.js';
11
11
  import { 표시 as 승인표시, 고르기 as 승인고르기 } from './approve.js';
12
12
 
13
+ // 저장소 주소. package.json 의 repository 와 같아야 한다 — 검사가 지킨다.
14
+ export const GITHUB = 'https://github.com/jysvai/deel-local-cli';
15
+
13
16
  // 한 조각씩 따로 만든다. 좁은 화면에서는 뒤에서부터 떨군다.
14
17
  // 순서 = 중요도 순. 앞쪽이 끝까지 살아남는다.
15
18
  export const SEGMENTS = {
@@ -479,5 +482,22 @@ export function headerLines(session, found, 상자쓰나 = true) {
479
482
  if (found.skills.length || found.commands.length) {
480
483
  lines.push(`${c.gray(자리(말('head.thisPC')))}${c.white(말('head.skills', { n: found.skills.length }))}${c.gray(' · ')}${c.white(말('head.commands', { n: found.commands.length }))}${c.gray(' · ')}${c.white(말('head.plugins', { n: found.plugins.length }))}`);
481
484
  }
485
+ /*
486
+ * 별 부탁 한 줄.
487
+ *
488
+ * 맨 아래에 둔다. 위쪽은 **어디로 소스가 나가는지**를 읽는 자리라, 그
489
+ * 사이에 부탁을 끼우면 정작 읽어야 할 줄이 묻힌다.
490
+ *
491
+ * 회색 한 줄로만 둔다. 켤 때마다 나오는 것이라, 눈에 띄게 만들면 열 번째
492
+ * 켤 때부터는 부탁이 아니라 방해가 된다.
493
+ *
494
+ * 사람이 보고 있을 때만 낸다. 상자를 쓰나(상자쓰나)가 아니라 화면이 터미널인가로
495
+ * 가른다 — `--no-tui` 로 쓰는 사람도 사람이지만, 파이프·CI 로 흘러 들어가는
496
+ * 기록에 부탁 줄이 섞이면 그건 그냥 잡음이다.
497
+ */
498
+ if (process.stdout.isTTY) {
499
+ lines.push('');
500
+ lines.push(`${빈자리}${c.gray(말('head.star'))} ${c.cyan(GITHUB)}`);
501
+ }
482
502
  return lines;
483
503
  }