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,174 @@
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
+
38
+ const 앞 = 'deel';
39
+ const 꼬리 = '-local';
40
+ const 이름폭 = 앞.length + 꼬리.length; // 10
41
+ const 들여 = ' ';
42
+
43
+ /** 모션을 끄는 스위치. 그림 돌림표(motion.js)와 같은 스위치를 함께 본다. */
44
+ export const 끔 = () => !!(process.env.DEEL_NO_INTRO || process.env.DEEL_NO_MOTION);
45
+
46
+ /**
47
+ * 한 틀에 얼마나 머무나.
48
+ *
49
+ * 다 합쳐 1초를 안 넘긴다. 켤 때마다 보는 것이라, 두 번째부터는 기다림이 된다.
50
+ * 멋있는 것보다 **짧은 것**이 낫다 — 하루에 열 번 켜는 사람이 기준이다.
51
+ */
52
+ export const 틀주기 = 38;
53
+
54
+ /*
55
+ * 틀 차례.
56
+ *
57
+ * 값으로 적어 둔다. 그려 놓고 눈으로만 맞추면 나중에 아무도 못 고치고,
58
+ * 검사도 '무엇이 보여야 하나' 를 말할 수가 없다.
59
+ */
60
+ function 차례짓기() {
61
+ const out = [];
62
+ // 1) 꼬리가 한 글자씩 자란다. 커서가 뒤를 따라간다.
63
+ for (let i = 0; i <= 꼬리.length; i++) out.push({ 글: i, 선: 0, 곁: false, 커서: true });
64
+ // 2) 커서가 멎는다. 여기서 한 박자 쉬는 것이 다음 선을 눈에 띄게 한다.
65
+ out.push({ 글: 꼬리.length, 선: 0, 곁: false, 커서: false });
66
+ // 3) 아래로 선이 그어진다 — 경계가 닫히는 자리다.
67
+ for (let n = 2; n <= 이름폭; n += 2) out.push({ 글: 꼬리.length, 선: n, 곁: false, 커서: false });
68
+ // 4) 닫히고 나서야 곁말이 붙는다. 순서가 뜻이다 — 닫힌 다음에 하는 말이라야 믿을 만하다.
69
+ out.push({ 글: 꼬리.length, 선: 이름폭, 곁: true, 커서: false });
70
+ return out;
71
+ }
72
+
73
+ const 차례 = 차례짓기();
74
+
75
+ /** 몇 틀짜리인가. */
76
+ export const 틀수 = 차례.length;
77
+
78
+ /**
79
+ * 한 틀을 줄들로. 언제나 **두 줄**이다.
80
+ *
81
+ * 줄 수가 틀마다 달라지면 다시 그릴 때 몇 줄을 올려야 하는지가 틀마다 달라지고,
82
+ * 한 번 어긋나면 앞 그림이 화면에 조각으로 남는다. 그래서 아직 안 그린 줄도
83
+ * 빈 줄로 자리를 잡아 둔다.
84
+ *
85
+ * @param {number} n 몇 번째 틀 (범위를 벗어나면 마지막 틀)
86
+ * @param {object} o
87
+ * @param {boolean} [o.바깥] 바깥으로 나가는 연결인가
88
+ * @param {string} [o.곁말] 선 옆에 붙는 한 줄
89
+ * @returns {string[]} 두 줄
90
+ */
91
+ export function 틀(n, { 바깥 = false, 곁말 = '' } = {}) {
92
+ const f = 차례[Math.max(0, Math.min(차례.length - 1, Math.trunc(Number(n) || 0)))];
93
+ const 색 = 바깥 ? c.hyellow : c.hgreen;
94
+
95
+ /*
96
+ * 방금 들어온 글자만 굵게, 앞엣것은 제 색으로 가라앉힌다.
97
+ *
98
+ * 다 같은 밝기로 그리면 글자가 '길어지는' 것으로만 보인다. 마지막 한 글자가
99
+ * 한 틀 동안 도드라졌다 가라앉아야 **찍히는** 것으로 보인다 — 사람이 자판을
100
+ * 두드릴 때 눈이 좇는 것이 그 마지막 글자라서 그렇다.
101
+ */
102
+ const 자란글 = 꼬리.slice(0, f.글);
103
+ const 앞선글 = 색(자란글.slice(0, -1));
104
+ const 방금글 = 자란글 ? c.bold(색(자란글.slice(-1))) : '';
105
+ const 남은칸 = ' '.repeat(이름폭 - 앞.length - 자란글.length);
106
+ // 커서는 글자 자리를 안 먹는다 — 남은 칸 안에서 그린다. 그래야 글이 안 밀린다.
107
+ const 커서글 = f.커서 ? c.gray('▏') : ' ';
108
+ // 다 자란 뒤에는 굵기를 뺀다. 굵은 채로 굳으면 '아직 찍히는 중' 으로 보인다.
109
+ const 이름글 = f.커서 ? `${앞선글}${방금글}` : 색(자란글);
110
+ const 첫줄 = `${들여}${c.bold(c.white(앞))}${이름글}${커서글}${남은칸.slice(1)}`;
111
+
112
+ const 선 = f.선 ? 색('─'.repeat(f.선)) : '';
113
+ const 곁 = f.곁 && 곁말 ? ` ${c.gray(곁말)}` : '';
114
+ const 둘째줄 = f.선 ? `${들여}${선}${곁}` : '';
115
+
116
+ return [첫줄, 둘째줄];
117
+ }
118
+
119
+ /** 다 돈 마지막 모습. 모션을 껐을 때 이것만 찍는다. */
120
+ export function 마지막틀(opts = {}) { return 틀(틀수 - 1, opts); }
121
+
122
+ /**
123
+ * 실제로 돌린다.
124
+ *
125
+ * @param {object} o
126
+ * @param {(s:string)=>void} [o.쓰기] 기본은 stdout. 검사가 갈아 끼운다.
127
+ * @param {boolean} [o.움직임] 거짓이면 마지막 한 장만
128
+ * @param {number} [o.주기]
129
+ * @param {boolean} [o.바깥]
130
+ * @param {string} [o.곁말]
131
+ * @param {(ms:number)=>Promise<void>} [o.쉬기] 검사에서 진짜로 안 기다리게
132
+ * @returns {Promise<number>} 그린 틀 수 (한 장만 찍었으면 1)
133
+ */
134
+ export async function 보이기({
135
+ 쓰기 = (s) => process.stdout.write(s),
136
+ 움직임 = null,
137
+ 주기 = 틀주기,
138
+ 바깥 = false,
139
+ 곁말 = '',
140
+ 쉬기 = (ms) => new Promise((r) => setTimeout(r, ms)),
141
+ } = {}) {
142
+ const 옵션 = { 바깥, 곁말 };
143
+ const 돌릴까 = 움직임 ?? (!끔() && !!process.stdout.isTTY);
144
+
145
+ if (!돌릴까) {
146
+ // 한 장만. 커서를 안 올리므로 파이프·기록에 제어문자가 안 섞인다.
147
+ for (const l of 마지막틀(옵션)) 쓰기(`${l}\n`);
148
+ return 1;
149
+ }
150
+
151
+ cursor.hide();
152
+ try {
153
+ for (let i = 0; i < 틀수; i++) {
154
+ const 줄들 = 틀(i, 옵션);
155
+ // 첫 틀은 자리를 새로 잡고, 그 뒤로는 그 두 줄만 지우고 다시 쓴다.
156
+ const 앞머리 = i === 0 ? '' : '\x1b[2A';
157
+ 쓰기(`${앞머리}\x1b[2K${줄들[0]}\n\x1b[2K${줄들[1]}\n`);
158
+ if (i < 틀수 - 1) await 쉬기(주기);
159
+ }
160
+ } finally {
161
+ // 무슨 일이 있어도 커서는 되돌린다. 여기서 안 되돌리면 그 뒤로 사람이
162
+ // 치는 글에 커서가 안 보인다 — 프로그램이 멈춘 것처럼 보인다.
163
+ cursor.show();
164
+ }
165
+ return 틀수;
166
+ }
167
+
168
+ /** 곁말은 부르는 쪽이 정한다. 여기 있는 것은 기본값이다. */
169
+ export function 기본곁말(바깥) {
170
+ return 말(바깥 ? 'intro.outside' : 'intro.inside');
171
+ }
172
+
173
+ // 폭 계산이 어긋나지 않는지 부르는 쪽에서 재 볼 수 있게 열어 둔다.
174
+ 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
+ }