deel-local-cli 1.2.0 → 1.4.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.
Files changed (50) hide show
  1. package/README.en.md +301 -1537
  2. package/README.md +272 -1582
  3. package/bin/deel.js +73 -4
  4. package/package.json +8 -3
  5. package/src/acp/jsonrpc.js +230 -0
  6. package/src/acp/map.js +219 -0
  7. package/src/acp/serve.js +556 -0
  8. package/src/agent/card.js +129 -0
  9. package/src/agent/compact.js +10 -2
  10. package/src/agent/effort.js +5 -0
  11. package/src/agent/evidence.js +186 -0
  12. package/src/agent/grade.js +20 -0
  13. package/src/agent/loop.js +134 -13
  14. package/src/agent/models.js +169 -0
  15. package/src/agent/modes.js +47 -1
  16. package/src/agent/pins.js +140 -0
  17. package/src/agent/preset.js +113 -0
  18. package/src/agent/project.js +10 -4
  19. package/src/agent/session.js +233 -13
  20. package/src/agent/store.js +31 -0
  21. package/src/backend/adapter.js +14 -0
  22. package/src/commands.js +616 -52
  23. package/src/i18n/en.js +265 -0
  24. package/src/i18n/index.js +126 -0
  25. package/src/i18n/ko.js +252 -0
  26. package/src/lsp/client.js +459 -0
  27. package/src/lsp/diag.js +112 -0
  28. package/src/lsp/rpc.js +84 -0
  29. package/src/lsp/servers.js +218 -0
  30. package/src/oneshot.js +19 -0
  31. package/src/pack/sbom.js +218 -0
  32. package/src/pack/selfpack.js +19 -3
  33. package/src/preview/serve.js +19 -2
  34. package/src/repl.js +193 -8
  35. package/src/safety/secrets.js +205 -0
  36. package/src/safety/undo.js +10 -3
  37. package/src/tools/desc.en.js +221 -0
  38. package/src/tools/docs.js +252 -0
  39. package/src/tools/index.js +190 -6
  40. package/src/tools/lsp.js +327 -0
  41. package/src/tools/task.js +30 -2
  42. package/src/ui/ansi.js +45 -0
  43. package/src/ui/approve.js +25 -21
  44. package/src/ui/banner.js +245 -0
  45. package/src/ui/export.js +217 -0
  46. package/src/ui/inputbox.js +37 -7
  47. package/src/ui/intro.js +206 -0
  48. package/src/ui/level.js +11 -5
  49. package/src/ui/notify.js +101 -0
  50. package/src/ui/status.js +181 -35
@@ -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
  /*
@@ -0,0 +1,206 @@
1
+ /**
2
+ * 켤 때 한 번 도는 글자 모션.
3
+ *
4
+ * ── 무엇을 보여 주나 ────────────────────────────────────────────────────
5
+ *
6
+ * 사람은 `deel` 이라고 치고 들어온다. 그 글자가 그 자리에서 **deel-local** 로
7
+ * 자라고, 그 아래로 선이 그어지며 닫힌다.
8
+ *
9
+ * deel▏
10
+ * deel-lo▏
11
+ * deel-local
12
+ * deel-local
13
+ * ─────
14
+ * deel-local
15
+ * ────────── 이 컴퓨터 안에서만
16
+ *
17
+ * 꾸미기만은 아니다. 이 프로그램이 하는 말이 딱 하나인데 — **소스가 이 컴퓨터
18
+ * 밖으로 안 나간다** — 그 말을 켜는 첫 0.5초에 그림으로 한 번 하는 것이다.
19
+ * 자라는 글자가 이름이고, 닫히는 선이 경계다. 바깥 게이트웨이로 붙어 있으면
20
+ * 같은 선이 노랗게 그어진다. 그러면 사람이 그 자리에서 안다.
21
+ *
22
+ * ── 왜 이 글자들만 쓰나 ─────────────────────────────────────────────────
23
+ *
24
+ * 이모지를 안 쓴다. 터미널마다 한 칸이 되기도 두 칸이 되기도 해서, 다시
25
+ * 그릴 때 지워야 할 폭이 어긋나고 앞 그림이 조각으로 남는다. ● ○ 같은
26
+ * 기호도 동아시아 로캘에서 두 칸으로 잡히는 것이 섞여 있다.
27
+ * 여기서는 ASCII 와 '─' 만 쓴다 — 어디서나 한 칸이다. (ui/motion.js 와 같은 이유)
28
+ *
29
+ * ── 잘 때를 아는 것 ─────────────────────────────────────────────────────
30
+ *
31
+ * 파이프·CI·기록으로 나갈 때는 **한 장만** 찍고 만다. 다시 그리기는 커서를
32
+ * 위로 올려 지우는 것인데, 앞자리가 터미널이 아니면 그 제어문자가 글에
33
+ * 그대로 섞여 남는다. 로그를 열었더니 `\x1b[2A` 가 널려 있는 꼴이 된다.
34
+ */
35
+ import { c, cursor, width } from './ansi.js';
36
+ import { 말 } from '../i18n/index.js';
37
+ import * as 큰이름 from './banner.js';
38
+
39
+ const 앞 = 'deel';
40
+ const 꼬리 = '-local';
41
+ const 이름폭 = 앞.length + 꼬리.length; // 10
42
+ const 들여 = ' ';
43
+
44
+ /** 모션을 끄는 스위치. 그림 돌림표(motion.js)와 같은 스위치를 함께 본다. */
45
+ export const 끔 = () => !!(process.env.DEEL_NO_INTRO || process.env.DEEL_NO_MOTION);
46
+
47
+ /**
48
+ * 한 틀에 얼마나 머무나.
49
+ *
50
+ * 다 합쳐 1초를 안 넘긴다. 켤 때마다 보는 것이라, 두 번째부터는 기다림이 된다.
51
+ * 멋있는 것보다 **짧은 것**이 낫다 — 하루에 열 번 켜는 사람이 기준이다.
52
+ */
53
+ export const 틀주기 = 38;
54
+
55
+ /*
56
+ * 틀 차례.
57
+ *
58
+ * 값으로 적어 둔다. 그려 놓고 눈으로만 맞추면 나중에 아무도 못 고치고,
59
+ * 검사도 '무엇이 보여야 하나' 를 말할 수가 없다.
60
+ */
61
+ function 차례짓기() {
62
+ const out = [];
63
+ // 1) 꼬리가 한 글자씩 자란다. 커서가 뒤를 따라간다.
64
+ for (let i = 0; i <= 꼬리.length; i++) out.push({ 글: i, 선: 0, 곁: false, 커서: true });
65
+ // 2) 커서가 멎는다. 여기서 한 박자 쉬는 것이 다음 선을 눈에 띄게 한다.
66
+ out.push({ 글: 꼬리.length, 선: 0, 곁: false, 커서: false });
67
+ // 3) 아래로 선이 그어진다 — 경계가 닫히는 자리다.
68
+ for (let n = 2; n <= 이름폭; n += 2) out.push({ 글: 꼬리.length, 선: n, 곁: false, 커서: false });
69
+ // 4) 닫히고 나서야 곁말이 붙는다. 순서가 뜻이다 — 닫힌 다음에 하는 말이라야 믿을 만하다.
70
+ out.push({ 글: 꼬리.length, 선: 이름폭, 곁: true, 커서: false });
71
+ return out;
72
+ }
73
+
74
+ const 차례 = 차례짓기();
75
+
76
+ /** 몇 틀짜리인가. */
77
+ export const 틀수 = 차례.length;
78
+
79
+ /**
80
+ * 한 틀을 줄들로. 언제나 **두 줄**이다.
81
+ *
82
+ * 줄 수가 틀마다 달라지면 다시 그릴 때 몇 줄을 올려야 하는지가 틀마다 달라지고,
83
+ * 한 번 어긋나면 앞 그림이 화면에 조각으로 남는다. 그래서 아직 안 그린 줄도
84
+ * 빈 줄로 자리를 잡아 둔다.
85
+ *
86
+ * @param {number} n 몇 번째 틀 (범위를 벗어나면 마지막 틀)
87
+ * @param {object} o
88
+ * @param {boolean} [o.바깥] 바깥으로 나가는 연결인가
89
+ * @param {string} [o.곁말] 선 옆에 붙는 한 줄
90
+ * @returns {string[]} 두 줄
91
+ */
92
+ export function 틀(n, { 바깥 = false, 곁말 = '' } = {}) {
93
+ const f = 차례[Math.max(0, Math.min(차례.length - 1, Math.trunc(Number(n) || 0)))];
94
+ const 색 = 바깥 ? c.hyellow : c.hgreen;
95
+
96
+ /*
97
+ * 방금 들어온 글자만 굵게, 앞엣것은 제 색으로 가라앉힌다.
98
+ *
99
+ * 다 같은 밝기로 그리면 글자가 '길어지는' 것으로만 보인다. 마지막 한 글자가
100
+ * 한 틀 동안 도드라졌다 가라앉아야 **찍히는** 것으로 보인다 — 사람이 자판을
101
+ * 두드릴 때 눈이 좇는 것이 그 마지막 글자라서 그렇다.
102
+ */
103
+ const 자란글 = 꼬리.slice(0, f.글);
104
+ const 앞선글 = 색(자란글.slice(0, -1));
105
+ const 방금글 = 자란글 ? c.bold(색(자란글.slice(-1))) : '';
106
+ const 남은칸 = ' '.repeat(이름폭 - 앞.length - 자란글.length);
107
+ // 커서는 글자 자리를 안 먹는다 — 남은 칸 안에서 그린다. 그래야 글이 안 밀린다.
108
+ const 커서글 = f.커서 ? c.gray('▏') : ' ';
109
+ // 다 자란 뒤에는 굵기를 뺀다. 굵은 채로 굳으면 '아직 찍히는 중' 으로 보인다.
110
+ const 이름글 = f.커서 ? `${앞선글}${방금글}` : 색(자란글);
111
+ const 첫줄 = `${들여}${c.bold(c.white(앞))}${이름글}${커서글}${남은칸.slice(1)}`;
112
+
113
+ const 선 = f.선 ? 색('─'.repeat(f.선)) : '';
114
+ const 곁 = f.곁 && 곁말 ? ` ${c.gray(곁말)}` : '';
115
+ const 둘째줄 = f.선 ? `${들여}${선}${곁}` : '';
116
+
117
+ return [첫줄, 둘째줄];
118
+ }
119
+
120
+ /** 다 돈 마지막 모습. 모션을 껐을 때 이것만 찍는다. */
121
+ export function 마지막틀(opts = {}) { return 틀(틀수 - 1, opts); }
122
+
123
+ /**
124
+ * 실제로 돌린다.
125
+ *
126
+ * @param {object} o
127
+ * @param {(s:string)=>void} [o.쓰기] 기본은 stdout. 검사가 갈아 끼운다.
128
+ * @param {boolean} [o.움직임] 거짓이면 마지막 한 장만
129
+ * @param {number} [o.주기]
130
+ * @param {boolean} [o.바깥]
131
+ * @param {string} [o.곁말]
132
+ * @param {(ms:number)=>Promise<void>} [o.쉬기] 검사에서 진짜로 안 기다리게
133
+ * @returns {Promise<number>} 그린 틀 수 (한 장만 찍었으면 1)
134
+ */
135
+ export async function 보이기({
136
+ 쓰기 = (s) => process.stdout.write(s),
137
+ 움직임 = null,
138
+ 주기 = 틀주기,
139
+ 바깥 = false,
140
+ 곁말 = '',
141
+ 칸 = null,
142
+ 쉬기 = (ms) => new Promise((r) => setTimeout(r, ms)),
143
+ } = {}) {
144
+ const 앞자리 = !!process.stdout.isTTY;
145
+ const 돌릴까 = 움직임 ?? (!끔() && 앞자리);
146
+
147
+ /*
148
+ * 큰 이름을 쓸지 여기서 한 번에 정한다 (ui/banner.js).
149
+ *
150
+ * **앞자리가 터미널일 때만** 쓴다. 파이프·CI·기록으로 나갈 때는 작은 것
151
+ * 한 장이다 — 여덟 줄짜리 그림이 로그 맨 앞에 박히면 그 로그를 읽는
152
+ * 사람에게는 그냥 방해다. 화면에 뜨는 것과 파일에 남는 것이 달라도 되는,
153
+ * 몇 안 되는 자리다.
154
+ *
155
+ * 좁으면 억지로 안 밀어 넣는다. 글자가 접히면 무슨 그림인지 알아볼 수가
156
+ * 없고, 그건 아무것도 안 그린 것보다 나쁘다 — 화면이 깨진 것으로 보인다.
157
+ */
158
+ // 칸을 안 주면 앞자리를 보고 정한다. 터미널이 아니면 폭이 0이라 큰 것은 안 뜬다.
159
+ // 칸을 주면 그 폭으로 판단한다 — 검사가 앞자리 없이도 두 갈래를 다 재는 길이고,
160
+ // 부르는 쪽이 화면을 직접 아는 자리(다른 화면 구현)에서도 그대로 쓴다.
161
+ const 잴칸 = 칸 ?? (앞자리 ? (process.stdout.columns ?? 80) : 0);
162
+ const 배 = 큰이름.쓸배율(잴칸);
163
+ const 큰가 = 배 > 0;
164
+
165
+ const 옵션 = 큰가 ? { 바깥, 곁말, 배 } : { 바깥, 곁말 };
166
+ const 그릴틀 = 큰가 ? 큰이름.틀 : 틀;
167
+ const 그릴수 = 큰가 ? 큰이름.틀수 : 틀수;
168
+ const 줄수 = 큰가 ? 큰이름.줄수 : 2;
169
+
170
+ if (!돌릴까) {
171
+ // 한 장만. 커서를 안 올리므로 파이프·기록에 제어문자가 안 섞인다.
172
+ const 한장 = 큰가 ? 큰이름.마지막틀(옵션) : 마지막틀(옵션);
173
+ for (const l of 한장) 쓰기(`${l}\n`);
174
+ return 1;
175
+ }
176
+
177
+ cursor.hide();
178
+ try {
179
+ for (let i = 0; i < 그릴수; i++) {
180
+ const 줄들 = 그릴틀(i, 옵션);
181
+ /*
182
+ * 첫 틀은 자리를 새로 잡고, 그 뒤로는 그 줄들만 지우고 다시 쓴다.
183
+ *
184
+ * 올릴 줄 수는 **틀마다 같아야 한다.** 한 번이라도 어긋나면 그 위의
185
+ * 남의 글을 지우거나, 앞 그림이 화면에 조각으로 남는다. 그래서 두 그림
186
+ * 모두 아직 안 그린 줄까지 빈 줄로 자리를 잡아 둔다.
187
+ */
188
+ const 앞머리 = i === 0 ? '' : `\x1b[${줄수}A`;
189
+ 쓰기(앞머리 + 줄들.map((l) => `\x1b[2K${l}\n`).join(''));
190
+ if (i < 그릴수 - 1) await 쉬기(주기);
191
+ }
192
+ } finally {
193
+ // 무슨 일이 있어도 커서는 되돌린다. 여기서 안 되돌리면 그 뒤로 사람이
194
+ // 치는 글에 커서가 안 보인다 — 프로그램이 멈춘 것처럼 보인다.
195
+ cursor.show();
196
+ }
197
+ return 그릴수;
198
+ }
199
+
200
+ /** 곁말은 부르는 쪽이 정한다. 여기 있는 것은 기본값이다. */
201
+ export function 기본곁말(바깥) {
202
+ return 말(바깥 ? 'intro.outside' : 'intro.inside');
203
+ }
204
+
205
+ // 폭 계산이 어긋나지 않는지 부르는 쪽에서 재 볼 수 있게 열어 둔다.
206
+ export const 이름너비 = () => width(앞 + 꼬리);
package/src/ui/level.js CHANGED
@@ -10,12 +10,14 @@
10
10
  // 치면 그대로 먹는다. 목록에 안 띄울 뿐이다 — 처음 켠 사람에게 명령 열여덟
11
11
  // 개를 들이밀면 아무것도 못 고른다.
12
12
 
13
+ import { 말 } from '../i18n/index.js';
14
+
13
15
  export const LEVELS = {
14
16
  쉬움: {
15
17
  id: '쉬움',
16
18
  en: 'beginner',
17
- name: '쉬움',
18
- hint: '권장값으로 바로 시작',
19
+ get name() { return 말('level.beginner.name'); },
20
+ get hint() { return 말('level.beginner.hint'); },
19
21
  // 목록에 띄울 것. 나머지는 쳐도 먹지만 안 보인다.
20
22
  show: [
21
23
  'help', 'work', 'auto', 'code', 'plan', 'ask',
@@ -23,7 +25,11 @@ export const LEVELS = {
23
25
  // 가 되는데, 초보일수록 그 원인을 못 찾는다. 명령 하나로 끝나는 문제다.
24
26
  // diff 는 초보에게 특히 필요하다. auto 모드는 안 물어보고 고치니,
25
27
  // '무엇이 바뀌었나' 를 볼 통로가 없으면 되돌릴지 말지도 못 정한다.
26
- 'model', 'ctx', 'scan', 'diff', 'undo', 'clear', 'sessions', 'cost', 'level', 'exit',
28
+ // bell 초보 목록에 둔다. 로컬 모델은 턴이 몇 분씩 걸려서 다들 다른 창으로
29
+ // 가는데, 알림이 있는 줄 모르면 그 몇 분을 화면만 보고 앉아 있게 된다.
30
+ // lang 은 반드시 초보 목록에 있어야 한다. 화면이 온통 못 읽는 말인 사람에게
31
+ // '전부 보려면 /level developer' 라고 적어 봐야 그 줄도 못 읽는다.
32
+ 'model', 'ctx', 'scan', 'diff', 'undo', 'bell', 'lang', 'clear', 'sessions', 'cost', 'level', 'exit',
27
33
  ],
28
34
  // 첫 실행에서 훑어 추천까지 해 준다
29
35
  autoScan: true,
@@ -34,8 +40,8 @@ export const LEVELS = {
34
40
  개발자: {
35
41
  id: '개발자',
36
42
  en: 'developer',
37
- name: '개발자',
38
- hint: '전부 직접 만짐',
43
+ get name() { return 말('level.developer.name'); },
44
+ get hint() { return 말('level.developer.hint'); },
39
45
  show: null, // null = 전부
40
46
  autoScan: false,
41
47
  plainErrors: false,
@@ -0,0 +1,101 @@
1
+ /*
2
+ * 끝났을 때 알리기 — 종소리와 창 제목.
3
+ *
4
+ * 로컬 모델은 느리다. 7B 를 CPU 로 돌리면 한 턴에 2~3분이 예사고, 그동안
5
+ * 사람은 다른 창으로 간다. 그러다 돌아와 보면 이미 5분 전에 끝나 있거나,
6
+ * 더 나쁘게는 "실행할까요?" 에서 3분째 멈춰 서 있다 — 물어본 줄을 몰라서.
7
+ *
8
+ * 클라우드 에이전트는 몇 초 만에 끝나니 알림이 없어도 됐지만, 여기서는
9
+ * 그것이 곧 기다린 시간이 된다. 그래서 딱 두 가지만 한다.
10
+ *
11
+ * 종소리 다 됐을 때, 그리고 **물어볼 때**. 물어볼 때가 더 중요하다 —
12
+ * 끝난 것은 늦게 알아도 되지만, 막혀 있는 것은 그만큼 손해다.
13
+ * 창 제목 탭 이름만 봐도 도는 중인지 끝났는지 알게 한다.
14
+ *
15
+ * ── 어디로 내보내나 ─────────────────────────────────────────────────────
16
+ *
17
+ * stdout 이 터미널이면 stdout, 아니면 stderr. 둘 다 아니면 아무것도 안 한다.
18
+ *
19
+ * 이 순서가 중요하다. ACP(에디터 안에서 쓰기)와 `deel run` 은 stdout 이
20
+ * 파이프다. 거기에 \x07 이나 \x1b]2; 를 흘리면 JSON-RPC 한 줄이 깨져서
21
+ * 에디터가 세션을 통째로 끊는다. 소리 한 번 내려다 연결을 끊는 셈이다.
22
+ * isTTY 를 보는 이유가 이것이고, 여기서만은 '아마 괜찮겠지' 로 안 넘어간다.
23
+ */
24
+
25
+ /** 끄는 스위치. 켜 두면 소리도 제목도 없다. 조용한 사무실용. */
26
+ export const 끔 = () => !!(process.env.DEEL_NO_BELL || process.env.NO_BELL);
27
+
28
+ /**
29
+ * 몇 초 넘게 걸린 턴만 알린다.
30
+ *
31
+ * "안녕" 에 1초 만에 답할 때마다 딩 소리가 나면, 사람은 이틀 만에 알림을 끈다.
32
+ * 그러면 정작 3분짜리 턴도 못 듣는다. 알림은 아껴 써야 알림이다.
33
+ */
34
+ export const 알릴만한초 = 12;
35
+
36
+ function 내보낼곳() {
37
+ if (끔()) return null;
38
+ if (process.stdout?.isTTY) return process.stdout;
39
+ if (process.stderr?.isTTY) return process.stderr;
40
+ return null;
41
+ }
42
+
43
+ function 쓰기(글) {
44
+ const 곳 = 내보낼곳();
45
+ if (!곳) return false;
46
+ try { 곳.write(글); return true; } catch { return false; }
47
+ }
48
+
49
+ /** 종을 한 번 울린다. @returns {boolean} 실제로 울렸나 */
50
+ export function 종() {
51
+ return 쓰기('\x07');
52
+ }
53
+
54
+ /**
55
+ * 창 제목을 바꾼다.
56
+ *
57
+ * OSC 2 (제목만) 와 OSC 1 (아이콘 이름) 을 같이 보낸다. 터미널마다 보는
58
+ * 것이 달라서 — 윈도우 터미널·iTerm 은 2를, 어떤 tmux 설정은 1을 본다.
59
+ * 끝맺음은 BEL(\x07) 대신 ST(\x1b\\) 를 쓴다. BEL 로 끝내면 제목을 바꿀
60
+ * 때마다 종이 같이 울려서, 위에서 아껴 쓰기로 한 소리가 매초 난다.
61
+ */
62
+ export function 창제목(글) {
63
+ const s = String(글 ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 120);
64
+ return 쓰기(`\x1b]2;${s}\x1b\\\x1b]1;${s}\x1b\\`);
65
+ }
66
+
67
+ /** 켤 때 있던 제목으로 돌려놓는다. 남의 터미널을 우리 이름으로 두고 나가지 않는다. */
68
+ export function 제목되돌리기() {
69
+ return 쓰기('\x1b]2;\x1b\\\x1b]1;\x1b\\');
70
+ }
71
+
72
+ /**
73
+ * 이 턴을 알릴 것인가.
74
+ *
75
+ * @param {object} o
76
+ * @param {number} o.걸린밀리초
77
+ * @param {boolean} [o.물어봄] 사람 답을 기다리는 자리인가. 그러면 시간과 무관하게 알린다.
78
+ * @param {boolean} [o.켬] 설정에서 켰나 (기본 켬)
79
+ * @param {number} [o.문턱초]
80
+ */
81
+ export function 알릴까({ 걸린밀리초 = 0, 물어봄 = false, 켬 = true, 문턱초 = 알릴만한초 } = {}) {
82
+ if (!켬 || 끔()) return false;
83
+ if (물어봄) return true;
84
+ return 걸린밀리초 >= 문턱초 * 1000;
85
+ }
86
+
87
+ /**
88
+ * 상태에 맞는 창 제목 글을 짓는다.
89
+ *
90
+ * 폴더 이름을 앞에 붙인다. 창을 여럿 띄워 놓고 쓰는 사람이 많은데, 전부
91
+ * "deel" 이면 어느 탭이 끝난 건지 알 수가 없다. 탭 이름은 짧게 잘리니
92
+ * 구분되는 것(폴더)을 앞에, 공통인 것(deel)을 뒤에 둔다.
93
+ */
94
+ export function 제목글(갈래, { 폴더 = '', 초 = 0 } = {}) {
95
+ const 이름 = 폴더 ? `${폴더} · ` : '';
96
+ if (갈래 === '도는중') return `${이름}deel ${초 >= 60 ? `${Math.floor(초 / 60)}분` : `${Math.max(0, Math.round(초))}초`}`;
97
+ if (갈래 === '물어봄') return `${이름}deel — 물어볼 것이 있습니다`;
98
+ if (갈래 === '끝남') return `${이름}deel — 다 됐습니다`;
99
+ if (갈래 === '탈남') return `${이름}deel — 멈췄습니다`;
100
+ return `${이름}deel`;
101
+ }