deel-local-cli 1.0.1 → 1.1.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,89 @@
1
+ /**
2
+ * 슬래시 명령 자동완성.
3
+ *
4
+ * ── 무엇을 푸는가 ───────────────────────────────────────────────────────
5
+ *
6
+ * 명령이 서른 개가 넘는다. 다 외우고 있는 사람은 만든 사람뿐이고, 그마저도
7
+ * `/mem…` 까지 치고 "이게 memory 였나 memo 였나" 하고 멈춘다. 그때 할 수 있는
8
+ * 것이 `/help` 를 쳐서 서른 줄을 받아 눈으로 훑는 것뿐이면, 그건 명령이 아니라
9
+ * 시험이다.
10
+ *
11
+ * 그래서 치는 도중에 보여 준다. 고르는 것이 아니라 **알아보는 것**이 목적이라
12
+ * 위아래 화살표로 고르게 하지 않는다 — 그러면 지난 입력 이력(위 화살표)을
13
+ * 뺏어야 하고, 그건 훨씬 자주 쓰는 기능이다. Tab 한 번이면 채워진다.
14
+ */
15
+
16
+ // 화면에 한 번에 보여 줄 최대 개수. 더 많으면 목록이 화면을 밀어낸다.
17
+ export const 최대추천 = 6;
18
+
19
+ /**
20
+ * 지금 치고 있는 글에 맞는 명령들.
21
+ *
22
+ * 슬래시로 시작하고 아직 빈칸을 안 친 동안에만 본다. `/mode auto` 처럼 인자를
23
+ * 치기 시작하면 명령은 이미 정해진 것이라 더 보여 줄 것이 없다.
24
+ *
25
+ * @param {string} 글 사람이 지금까지 친 것
26
+ * @param {object} 명령표 { 이름: { desc, arg } }
27
+ * @param {string[]} 보일것 이 수준에서 보여 줄 이름들 (없으면 전부)
28
+ */
29
+ export function 추천(글, 명령표 = {}, 보일것 = null) {
30
+ const s = String(글 ?? '');
31
+ const m = /^\/([^\s]*)$/.exec(s);
32
+ if (!m) return [];
33
+
34
+ const 친것 = m[1].toLowerCase();
35
+ const 이름들 = 보일것 ?? Object.keys(명령표);
36
+
37
+ /*
38
+ * 앞에서부터 맞는 것을 먼저, 가운데 맞는 것을 뒤에.
39
+ *
40
+ * `/mo` 를 치면 mode·model 이 먼저 오고 memory 는 안 온다. 그런데 `/emo` 처럼
41
+ * 앞을 틀리게 쳤을 때도 memory 가 나오면 고맙다 — 오타는 앞글자에서 제일 많이 난다.
42
+ */
43
+ const 앞 = [];
44
+ const 안 = [];
45
+ for (const 이름 of 이름들) {
46
+ const 낮 = 이름.toLowerCase();
47
+ if (!친것) { 앞.push(이름); continue; }
48
+ if (낮.startsWith(친것)) 앞.push(이름);
49
+ else if (낮.includes(친것)) 안.push(이름);
50
+ }
51
+ return [...앞, ...안].map((이름) => ({
52
+ 이름,
53
+ 설명: 명령표[이름]?.desc ?? '',
54
+ 인자: 명령표[이름]?.arg ?? '',
55
+ }));
56
+ }
57
+
58
+ /**
59
+ * Tab 을 눌렀을 때 **덧붙일 글자**. 없으면 빈 글자.
60
+ *
61
+ * 하나만 맞으면 끝까지 채우고 빈칸을 하나 붙인다(인자를 받는 명령이면 바로 이어
62
+ * 칠 수 있게). 여럿이면 **다 같이 가진 앞부분까지만** 채운다 — 하나를 골라
63
+ * 넣어 버리면 사람이 원한 것이 아닐 때 지우는 수고가 더 든다.
64
+ */
65
+ export function 채울글(글, 후보들) {
66
+ const s = String(글 ?? '');
67
+ const m = /^\/([^\s]*)$/.exec(s);
68
+ if (!m || !후보들?.length) return '';
69
+ const 친것 = m[1];
70
+
71
+ // 앞에서부터 맞는 것만 채운다. 가운데 맞은 것을 채우면 친 글자가 사라진다.
72
+ const 맞는것 = 후보들.filter((x) => x.이름.toLowerCase().startsWith(친것.toLowerCase()));
73
+ if (!맞는것.length) return '';
74
+
75
+ if (맞는것.length === 1) {
76
+ const 이름 = 맞는것[0].이름;
77
+ return 이름.slice(친것.length) + (맞는것[0].인자 ? ' ' : '');
78
+ }
79
+
80
+ // 다 같이 가진 앞부분.
81
+ let 같은데까지 = 맞는것[0].이름;
82
+ for (const x of 맞는것.slice(1)) {
83
+ let i = 0;
84
+ while (i < 같은데까지.length && i < x.이름.length
85
+ && 같은데까지[i].toLowerCase() === x.이름[i].toLowerCase()) i++;
86
+ 같은데까지 = 같은데까지.slice(0, i);
87
+ }
88
+ return 같은데까지.length > 친것.length ? 같은데까지.slice(친것.length) : '';
89
+ }
@@ -33,12 +33,16 @@
33
33
  * 2) 그릴 것을 값으로 내놓는다(프레임). 검사는 파이프로 도니 화면을 못 본다 —
34
34
  * 값으로 내놓아야 한 칸도 안 어긋나는지 잴 수 있다.
35
35
  */
36
- import { c, width, cursor } from './ansi.js';
36
+ import { c, width, cursor, clip } from './ansi.js';
37
37
  import { statusLine, contextWarning } from './status.js';
38
38
  import { 접어쓰기 } from './wrap.js';
39
+ import { 문구고르기, 돌림틀, 걸린시간, 느긋해질때, 문구주기 } from './working.js';
40
+ import { 최대추천 } from './complete.js';
39
41
 
40
42
  const 줄끝지움 = '\x1b[K';
41
43
  const 위로 = (n) => (n > 0 ? `\x1b[${n}A` : '');
44
+ // 돌아가는 표시를 한 칸 넘기는 주기. 사람 눈에 부드럽고, 다시 그리는 값은 싸다.
45
+ const 돌림주기 = 90;
42
46
 
43
47
  // 상자 안쪽 왼쪽에 붙는 것: ' ' + '│' + ' ' + '❯' + ' ' → 5칸
44
48
  const 앞머리 = 5;
@@ -55,10 +59,11 @@ export const 안쪽최대 = 8;
55
59
  * @param {string} o.상태 상태줄 (없으면 안 그린다)
56
60
  * @param {string} o.경고 컨텍스트 경고 (없으면 안 그린다)
57
61
  * @param {string} o.곁말 상자 아래 흐린 한 줄 (도움말 등)
62
+ * @param {object[]} o.추천 자동완성 후보 [{이름, 설명, 인자}]
58
63
  * @returns {{줄들: string[], 커서: {위: number, 열: number}}}
59
64
  * 커서.위 = 마지막 줄에서 몇 줄 위인가, 커서.열 = 1부터 세는 칸
60
65
  */
61
- export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상태 = '', 경고 = '', 곁말 = '' } = {}) {
66
+ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상태 = '', 경고 = '', 곁말 = '', 일감 = null, 추천 = [] } = {}) {
62
67
  const 칸 = Math.max(20, 폭);
63
68
  const 안쪽 = Math.max(4, 칸 - 앞머리 - 2); // 오른쪽 ' │' 두 칸
64
69
  const 줄들 = [];
@@ -69,6 +74,61 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
69
74
  const 가로 = '─'.repeat(칸 - 3);
70
75
  줄들.push(c.gray(` ╭${가로}╮`));
71
76
 
77
+ /*
78
+ * 일하는 중.
79
+ *
80
+ * 상자를 없애고 대신 한 줄을 흘려보내는 길도 있었다. 그런데 그러면 일할 때만
81
+ * 화면 아래가 텅 비어, 사람이 보기에 **프로그램이 다른 것으로 바뀐 것처럼**
82
+ * 보인다. 테두리를 그대로 두고 안엣것만 바꾼다 — 같은 자리에서 같은 상자가
83
+ * 계속 살아 있는 편이 '돌아가는 중' 이라는 신호로 훨씬 낫다.
84
+ */
85
+ if (일감) {
86
+ /*
87
+ * 여기서 쓸 수 있는 칸은 평소보다 **두 칸 넓다.**
88
+ *
89
+ * 평소에는 왼쪽에 `❯ ` 가 붙지만(앞머리 5칸) 일하는 중에는 돌아가는 표시가
90
+ * 본문 안에 들어가므로 그 두 칸이 본문 몫이 된다. 이걸 안 더하면 테두리가
91
+ * 딱 두 칸 짧아진다 — 눈으로는 거의 안 보이는데 실제 터미널에서는 줄이
92
+ * 어긋나 보인다.
93
+ */
94
+ const 일안쪽 = 안쪽 + 2;
95
+ const 왼쪽 = `${c.hcyan(일감.돌림 ?? '⠋')} ${c.white(일감.말 ?? '')}${c.gray('…')}`;
96
+ const 오른쪽 = c.gray(일감.곁 ?? '');
97
+ const 남 = 일안쪽 - width(왼쪽) - width(오른쪽);
98
+ // 자리가 모자라면 오른쪽(걸린 시간·중단 안내)을 버린다. 왼쪽이 본문이다.
99
+ const 안 = 남 >= 2 ? `${왼쪽}${' '.repeat(남)}${오른쪽}` : 왼쪽;
100
+ 줄들.push(` ${c.gray('│')} ${안}${' '.repeat(Math.max(0, 일안쪽 - width(안)))} ${c.gray('│')}`);
101
+
102
+ /*
103
+ * 일하는 도중에 미리 쳐 두는 자리.
104
+ *
105
+ * 로컬 모델은 한 턴에 몇 분이 걸린다. 그 동안 생각난 것을 못 적어 두고
106
+ * 끝나기를 지켜보고 있어야 한다면, 기다리는 시간이 통째로 버려진다.
107
+ * 사실 readline 은 그 동안에도 글을 받아 두고 있었다 — 다만 화면에
108
+ * 아무것도 안 보여서, 쳐도 안 먹는 줄 알았을 뿐이다.
109
+ *
110
+ * 그래서 보여만 준다. 새로 받는 게 아니라 **이미 받고 있던 것**이다.
111
+ */
112
+ if (일감.대기) {
113
+ const 대기줄들 = 접어쓰기(일감.대기, 안쪽).slice(0, 3);
114
+ for (const [i, 한줄] of 대기줄들.entries()) {
115
+ const 표 = i === 0 ? c.hcyan('❯') : ' ';
116
+ 줄들.push(` ${c.gray('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 안쪽 - width(한줄)))} ${c.gray('│')}`);
117
+ }
118
+ }
119
+
120
+ 줄들.push(c.gray(` ╰${가로}╯`));
121
+ if (일감.대기) 줄들.push(` ${c.gray('Enter 를 치면 지금 일이 끝난 뒤에 보냅니다')}`);
122
+ else if (곁말) 줄들.push(` ${c.gray(곁말)}`);
123
+
124
+ // 커서는 미리 치는 중일 때만 보여 준다. 아니면 칠 자리가 아니다.
125
+ if (일감.대기) {
126
+ const 마지막 = 접어쓰기(일감.대기, 안쪽).slice(0, 3).at(-1) ?? '';
127
+ return { 줄들, 커서: { 위: 2, 열: Math.min(앞머리 + 1 + width(마지막), 칸 - 1) } };
128
+ }
129
+ return { 줄들, 커서: { 위: (곁말 ? 1 : 0), 열: 1 } };
130
+ }
131
+
72
132
  /*
73
133
  * 긴 입력은 접는다. 자르면 사람이 친 글이 안 보이는데, 안 보이는 채로
74
134
  * Enter 를 치게 되는 것이 제일 나쁘다 — 무엇을 보내는지 모르게 된다.
@@ -83,6 +143,41 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
83
143
  줄들.push(` ${c.gray('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 남))} ${c.gray('│')}`);
84
144
  }
85
145
  줄들.push(c.gray(` ╰${가로}╯`));
146
+
147
+ /*
148
+ * 자동완성 추천은 상자 **아래**에 놓는다.
149
+ *
150
+ * 안에 넣으면 치던 글이 밀려 올라가서, 무엇을 치고 있었는지가 안 보인다.
151
+ * 아래에 두면 목록이 몇 줄이든 치던 자리는 늘 같은 데 있다.
152
+ */
153
+ let 추천줄수 = 0;
154
+ if (추천?.length) {
155
+ const 보일것 = 추천.slice(0, 최대추천);
156
+ // 이름은 상자 안 글자와 같은 자리(6칸째)에서 시작한다 — 눈이 안 흔들린다.
157
+ const 이름들 = 보일것.map((x) => `/${x.이름}${x.인자 ? ` ${x.인자}` : ''}`);
158
+ // 이름칸이 넓어지면 설명이 그만큼 잘린다. 제일 긴 이름 하나 때문에 설명이
159
+ // 다 사라지는 일이 없게 위쪽을 막아 둔다.
160
+ const 이름폭 = Math.min(Math.max(...이름들.map(width)), Math.max(12, Math.floor(칸 * 0.38)));
161
+ const 설명폭 = 칸 - 앞머리 - 이름폭 - 3;
162
+
163
+ for (const [i, x] of 보일것.entries()) {
164
+ // 첫째 것만 밝게. Tab 을 누르면 대개 이것으로 채워진다.
165
+ const 표 = i === 0 ? c.hcyan('›') : ' ';
166
+ const 칠 = i === 0 ? c.white : c.gray;
167
+ const 이름 = clip(이름들[i], 이름폭);
168
+ const 벌림 = ' '.repeat(Math.max(1, 이름폭 - width(이름) + 2));
169
+ // 좁은 터미널에서는 설명을 버린다. 이름이 본문이다.
170
+ const 설명 = 설명폭 >= 8 ? c.gray(clip(x.설명 ?? '', 설명폭)) : '';
171
+ 줄들.push(` ${표} ${칠(이름)}${설명 ? 벌림 + 설명 : ''}`);
172
+ }
173
+
174
+ 추천줄수 = 보일것.length;
175
+ if (추천.length > 보일것.length) {
176
+ 줄들.push(` ${c.gray(`그 밖에 ${추천.length - 보일것.length}개 더`)}`);
177
+ 추천줄수 += 1;
178
+ }
179
+ }
180
+
86
181
  if (곁말) 줄들.push(` ${c.gray(곁말)}`);
87
182
 
88
183
  /*
@@ -99,7 +194,8 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
99
194
  const 숨긴줄 = 안쪽줄.length - 보일줄.length;
100
195
  const 커서줄 = Math.min(Math.max(0, 앞줄들.length - 1 - 숨긴줄), 보일줄.length - 1);
101
196
 
102
- const 아래 = (곁말 ? 1 : 0) + 1; // 곁말 + 닫는 테두리
197
+ // 커서 아래에 줄이 있나 닫는 테두리 + 추천 목록 + 곁말.
198
+ const 아래 = 1 + 추천줄수 + (곁말 ? 1 : 0);
103
199
  const 위 = 아래 + (보일줄.length - 1 - 커서줄);
104
200
  // 1부터 세는 칸. 앞머리(5칸) 다음이 6칸째다 — 거기가 첫 글자 자리다.
105
201
  const 열 = Math.min(앞머리 + 1 + width(앞줄들.at(-1) ?? ''), 칸 - 1);
@@ -128,6 +224,111 @@ export class InputBox {
128
224
  this.커서위 = 0;
129
225
  this.session = null;
130
226
  this.곁말 = '';
227
+
228
+ // 일하는 중 상태. null 이면 평소(입력 기다리는 중)다.
229
+ this.일감 = null;
230
+ this.박자 = null; // 돌아가는 표시를 돌리는 시계
231
+ this.틱 = 0;
232
+
233
+ /*
234
+ * 대화가 **줄 중간**에 멈춰 있나.
235
+ *
236
+ * 이걸 안 들고 있으면 답이 잘려 보인다. 실제로 나갔던 결함이다.
237
+ *
238
+ * 모델이 답을 흘리는 동안 글은 토막토막 온다. 한 토막을 찍으면 커서는
239
+ * 그 줄 한가운데에 선다. 그런데 돌아가는 표시는 90ms 마다 제 시계로
240
+ * 상자를 다시 그리므로, 그 순간 **상자가 답 줄에 얹혀 그려진다.**
241
+ *
242
+ * ▌ **계획표** ▏myproject · qwen… ▏ ▰▱▱▱▱ 13% ▏ ◎ 종합
243
+ *
244
+ * 그 다음 지우기는 `\r` 로 1칸째에 간 뒤 `\x1b[K` 로 줄을 지운다.
245
+ * 상자만 지우려던 것인데 **같은 줄에 있던 답까지 같이 지워진다.**
246
+ * 사람 눈에는 답의 앞부분이 사라진 것으로 보인다 — `**계획표**` 가
247
+ * `표**` 만 남는 식이다. 글자가 안 온 게 아니라 지워진 것이라,
248
+ * 모델을 아무리 바꿔도 똑같이 나온다.
249
+ *
250
+ * 그래서 규칙을 하나로 못 박는다 — **줄 중간에서는 아무것도 안 그린다.**
251
+ */
252
+ this.줄중간 = false;
253
+ }
254
+
255
+ /** 대화 글이 줄 중간까지 쓰였다. 줄이 끝날 때까지 상자를 안 세운다. */
256
+ 줄중간표시() {
257
+ this.지우기();
258
+ this.줄중간 = true;
259
+ }
260
+
261
+ /** 줄이 끝났다. 이제 아래에 상자를 세워도 안전하다. */
262
+ 줄끝남() { this.줄중간 = false; }
263
+
264
+ /**
265
+ * 일하기 시작. 상자 안엣것이 입력칸에서 '돌아가는 중' 으로 바뀐다.
266
+ *
267
+ * 스스로 다시 그리는 시계를 단다. 모델이 30초를 말없이 생각할 때 우리 쪽에서
268
+ * 아무 일도 안 일어나면 표시가 얼어붙는데, 그건 멈춘 것과 화면상 구분이 안 된다.
269
+ */
270
+ 일시작(session, 갈래 = '기본') {
271
+ this.session = session ?? this.session;
272
+ // 대기 = 일하는 도중에 미리 치고 있는 글, 예약수 = 이미 Enter 를 친 것들.
273
+ this.일감 = { 갈래, 시작: Date.now(), 곁정보: '', 대기: '', 예약수: 0 };
274
+ this.틱 = 0;
275
+ this.일그리기();
276
+ if (this.박자) clearInterval(this.박자);
277
+ this.박자 = setInterval(() => { this.틱 += 1; this.일그리기(); }, 돌림주기);
278
+ // 이 시계 때문에 프로그램이 안 끝나면 안 된다. 끝맺음이 걸려 버린다.
279
+ if (typeof this.박자.unref === 'function') this.박자.unref();
280
+ }
281
+
282
+ /** 하는 일이 바뀌었다 (파일 읽기 → 코드 고치기 처럼). */
283
+ 일바꿈(갈래, 곁정보 = null) {
284
+ if (!this.일감) return;
285
+ // 갈래가 바뀌면 문구를 그 자리에서 갈아 준다. 4초를 기다리면 이미 다음
286
+ // 일로 넘어가 있어서, 화면이 늘 한 걸음 늦은 말을 하게 된다.
287
+ if (갈래 && 갈래 !== this.일감.갈래) { this.일감.갈래 = 갈래; this.틱 = 0; }
288
+ if (곁정보 !== null) this.일감.곁정보 = 곁정보;
289
+ this.일그리기();
290
+ }
291
+
292
+ 일끝() {
293
+ if (this.박자) { clearInterval(this.박자); this.박자 = null; }
294
+ this.일감 = null;
295
+ this.지우기();
296
+ }
297
+
298
+ /**
299
+ * 일하는 도중에 사람이 치고 있는 글 · 예약해 둔 개수.
300
+ *
301
+ * 답이 흘러나오는 동안(줄 중간)에는 안 그려진다 — 그건 일그리기() 가
302
+ * 알아서 넘긴다. 글은 readline 이 계속 들고 있으므로 잃지 않는다.
303
+ */
304
+ 대기갱신(글 = '', 예약수 = 0) {
305
+ if (!this.일감) return;
306
+ if (this.일감.대기 === 글 && this.일감.예약수 === 예약수) return;
307
+ this.일감.대기 = 글;
308
+ this.일감.예약수 = 예약수;
309
+ this.일그리기();
310
+ }
311
+
312
+ 일그리기() {
313
+ // 줄 중간이면 안 그린다. 여기가 시계로 저절로 도는 유일한 자리라,
314
+ // 답이 흘러나오는 동안 상자가 답 줄에 얹히는 것을 여기서 막는다.
315
+ if (!this.일감 || this.줄중간) return;
316
+ const 지난 = Date.now() - this.일감.시작;
317
+ const 갈래 = 지난 >= 느긋해질때 ? '느긋' : this.일감.갈래;
318
+ const 회차 = Math.floor((this.틱 * 돌림주기) / 문구주기);
319
+ this.그리기(this.session, '', 0, {
320
+ 돌림: 돌림틀[this.틱 % 돌림틀.length],
321
+ 말: 문구고르기(갈래, 회차),
322
+ 곁: [
323
+ 걸린시간(지난),
324
+ this.일감.곁정보,
325
+ // 예약해 둔 것이 있으면 잊지 않게 세어 준다. 몇 분짜리 일이 끝난 뒤
326
+ // 엉뚱한 말이 저절로 나가는 것처럼 보이면 안 된다.
327
+ this.일감.예약수 ? `${this.일감.예약수}건 예약됨` : '',
328
+ 'Ctrl+C 중단',
329
+ ].filter(Boolean).join(' · '),
330
+ 대기: this.일감.대기,
331
+ });
131
332
  }
132
333
 
133
334
  /** 상자를 지운다. 위쪽 대화는 안 건드린다. */
@@ -151,9 +352,23 @@ export class InputBox {
151
352
  * @param {object} session 상태줄을 그리는 데 쓴다
152
353
  * @param {string} 글 readline 이 들고 있는 글
153
354
  * @param {number} 커서 그 안에서의 커서 위치
355
+ * @param {object} 일감 일하는 중이면 그 상태
356
+ * @param {object[]} 추천 자동완성 후보
154
357
  */
155
- 그리기(session, 글 = '', 커서자리 = null) {
358
+ 그리기(session, 글 = '', 커서자리 = null, 일감 = null, 추천 = []) {
156
359
  this.session = session ?? this.session;
360
+ /*
361
+ * 줄 중간에서는 절대 안 그린다 — 여기가 그 규칙을 지키는 마지막 자리다.
362
+ *
363
+ * 시계로 도는 일그리기() 는 아예 안 그리고 넘어가지만, 사람이 입력을
364
+ * 기다리는 자리(입력자리·입력갱신)는 그리긴 그려야 한다. 그때는 줄을
365
+ * 먼저 끝내고 그린다. 안 그러면 상자가 대화 줄에 얹히고, 다음 지우기가
366
+ * 그 줄을 통째로 지운다.
367
+ */
368
+ if (this.줄중간) {
369
+ process.stdout.write('\n');
370
+ this.줄중간 = false;
371
+ }
157
372
  this.지우기();
158
373
 
159
374
  const { 줄들, 커서: 자리 } = 프레임({
@@ -163,6 +378,8 @@ export class InputBox {
163
378
  상태: this.session ? statusLine(this.session) : '',
164
379
  경고: this.session ? (contextWarning(this.session) ?? '') : '',
165
380
  곁말: this.곁말,
381
+ 일감,
382
+ 추천,
166
383
  });
167
384
 
168
385
  cursor.hide();
package/src/ui/screen.js CHANGED
@@ -69,12 +69,36 @@ export class LineScreen {
69
69
  // 다음에 오는 짧은 글이 그 줄 위에 겹쳐 찍힌다.
70
70
  this.임시중 = false;
71
71
  this.돌림 = null;
72
+ /*
73
+ * 하위 작업 안쪽인가 — 그렇다면 몇 겹인가.
74
+ *
75
+ * 하위 작업(tools/task.js)은 제 대화에서 따로 돌지만 화면은 하나다.
76
+ * 표시가 없으면 하위가 부른 도구 줄이 부모 것과 똑같이 찍혀서, 사람은
77
+ * 부모가 파일 열두 개를 읽은 줄로 본다. 한 단 들여 그으면 어디부터
78
+ * 어디까지가 떼어 낸 일인지가 그냥 보인다.
79
+ *
80
+ * 여기 한 군데서만 붙인다. repl.js 의 say() 자리 예순 곳을 고치는 대신
81
+ * 이벤트가 들고 온 깊이를 화면에 알려 주는 쪽이 안 빠뜨린다.
82
+ */
83
+ this.깊이 = 0;
72
84
  }
73
85
 
74
86
  // ── 흘러가는 글 ────────────────────────────────────────────────────────
75
87
 
88
+ /** 지금부터 찍는 줄이 몇 겹 안쪽인지. 0 이면 평소대로. */
89
+ 들여쓰기(n) { this.깊이 = Math.max(0, Number(n) || 0); }
90
+
91
+ /*
92
+ * 하위 작업 구간에 세로줄을 세운다.
93
+ *
94
+ * 빈 칸으로만 밀면 어디서 시작해 어디서 끝났는지가 안 보인다 — 특히 하위가
95
+ * 파일을 열 개 만지고 나면, 스크롤을 올려 여는 줄을 찾아야 그게 하위였는지
96
+ * 안다. 세로줄 한 칸이면 훑는 것만으로 구간이 잡힌다.
97
+ */
98
+ #안내() { return this.깊이 ? c.gray(' │').repeat(this.깊이) : ''; }
99
+
76
100
  /** 한 줄. 이미 색이 입혀진 글을 받는다. */
77
- 줄(s = '') { this.임시지움(); say(s); }
101
+ 줄(s = '') { this.임시지움(); say(this.깊이 && s ? this.#안내() + s : s); }
78
102
 
79
103
  /** 줄바꿈 없이 이어 붙인다 — 스트리밍으로 오는 답. */
80
104
  붙임(s) { this.임시지움(); process.stdout.write(s); }
@@ -107,7 +131,7 @@ export class LineScreen {
107
131
  /**
108
132
  * 사람이 치고 있던 입력 줄을 지운다.
109
133
  *
110
- * Shift+Tab 으로 작업 모드를 돌릴 때처럼, 입력을 기다리는 도중에 화면에
134
+ * Shift+Tab 으로 승인 방식을 돌릴 때처럼, 입력을 기다리는 도중에 화면에
111
135
  * 한 줄을 끼워 넣어야 하는 자리가 있다. 안 지우면 `❯ ` 뒤에 새 글이 붙는다.
112
136
  * 상자화면에서는 상자를 통째로 걷어낸다(BoxScreen 이 갈아 끼운다).
113
137
  */
@@ -150,6 +174,23 @@ export class LineScreen {
150
174
  /** 사람이 치는 중인 글이 바뀌었다. 줄화면은 readline 이 알아서 되비춘다. */
151
175
  입력갱신() { }
152
176
 
177
+ /** 일하는 중 미리 치는 글. 줄화면은 상자가 없어 보여 줄 자리가 없다. */
178
+ 대기갱신() { }
179
+
180
+ /** 줄화면은 붙여 쓰기 전에 임시글만 지우면 되므로 따로 셀 것이 없다. */
181
+ 줄중간인가() { return false; }
182
+
183
+ /*
184
+ * 일하는 중 표시. 줄화면은 안 그린다.
185
+ *
186
+ * 줄화면은 흘러가는 화면이라 '한 자리에서 도는 표시' 를 둘 데가 없다.
187
+ * 대신 기다림()·생각() 이 그 자리를 이미 맡고 있다 — 여기서 또 그리면
188
+ * 같은 말이 두 번 나간다. 파이프·기록에서는 돌아가는 표시 자체가 잡음이다.
189
+ */
190
+ 일시작() { }
191
+ 일바꿈() { }
192
+ 일끝() { }
193
+
153
194
  close() {
154
195
  this.돌림멈춤();
155
196
  this.임시지움();
@@ -170,20 +211,77 @@ export class BoxScreen extends LineScreen {
170
211
  this.상자 = 상자;
171
212
  }
172
213
 
173
- // 대화 한 줄이 나가기 전에 상자를 걷어낸다. 안 걷으면 새 글이 상자 위에
174
- // 겹쳐 찍히고, 지울 대화까지 같이 지워진다.
175
- 줄(s = '') { this.상자.지우기(); super.줄(s); }
176
- 붙임(s) { this.상자.지우기(); super.붙임(s); }
214
+ /*
215
+ * 대화 줄이 나가기 전에 상자를 걷어낸다. 안 걷으면 새 글이 상자 위에
216
+ * 겹쳐 찍히고, 지울 대화까지 같이 지워진다.
217
+ *
218
+ * 일하는 중이면 찍은 **뒤에 다시 세운다.** 이게 없으면 첫 도구 결과가
219
+ * 찍히는 순간 상자가 사라지고, 그 뒤로는 일이 끝날 때까지 화면 아래가
220
+ * 텅 빈 채로 남는다 — 정확히 그게 "밋밋해 보인다" 는 상태다.
221
+ */
222
+ 줄(s = '') {
223
+ this.상자.지우기();
224
+ super.줄(s);
225
+ // 줄이 끝났다 — 이제 아래에 상자를 세워도 대화를 안 덮는다.
226
+ this.상자.줄끝남();
227
+ this.상자.일그리기();
228
+ }
177
229
 
178
- /** 입력 자리 = 상태줄 + 상자. 커서는 상자 안에 놓인다. */
179
- 입력자리(session) {
230
+ /*
231
+ * 스트리밍으로 이어 붙이는 글은 다시 안 세운다.
232
+ *
233
+ * 토막마다 상자를 세웠다 걷으면, 글자 몇 개마다 상자가 깜빡이고 커서가
234
+ * 위아래로 튄다. 답이 흘러나오는 동안에는 그 자체가 살아 있다는 신호라
235
+ * 상자가 없어도 밋밋하지 않다. 답이 끝나면 repl.js 가 다시 세운다.
236
+ */
237
+ 붙임(s) {
238
+ // 상자를 걷고, **줄 중간에 멈춰 있다고 표시한다.** 이 표시가 없으면
239
+ // 돌아가는 표시가 90ms 뒤에 제 시계로 상자를 답 줄 위에 그려 버리고,
240
+ // 그 다음 지우기가 답까지 같이 지운다.
241
+ this.상자.줄중간표시();
242
+ super.붙임(s);
243
+ }
244
+
245
+ /** 일하기 시작·바뀜·끝. 상자 안엣것만 바뀐다 (ui/working.js 의 문구). */
246
+ 일시작(session, 갈래) { this.임시지움(); this.상자.일시작(session, 갈래); }
247
+ 일바꿈(갈래, 곁정보) { this.상자.일바꿈(갈래, 곁정보); }
248
+ 일끝() { this.상자.일끝(); }
249
+
250
+ /** 일하는 도중에 미리 치고 있는 글 · 예약해 둔 개수. */
251
+ 대기갱신(글, 예약수) { this.상자.대기갱신(글, 예약수); }
252
+
253
+ /** 대화가 줄 중간에 멈춰 있나 (답이 흘러나오는 중). */
254
+ 줄중간인가() { return this.상자.줄중간; }
255
+
256
+ /*
257
+ * 줄화면에서 '생각 중…' 을 한 줄로 흘리던 자리.
258
+ *
259
+ * 상자를 쓸 때는 그 말이 상자 안으로 들어가야 한다. 안 그러면 같은 말이
260
+ * 두 군데(흘러가는 줄 + 상자)에 동시에 뜬다.
261
+ */
262
+ 기다림() { }
263
+ 생각(s) {
264
+ // '생각 중… 1,234자' 에서 숫자만 떼어 곁정보로 붙인다. 진짜로 늘어나는
265
+ // 숫자가 하나 있어야 '살아 있다' 가 표시가 아니라 사실이 된다.
266
+ const m = /([\d,]+)자/.exec(String(s ?? ''));
267
+ this.상자.일바꿈('생각', m ? `생각 ${m[1]}자` : null);
268
+ }
269
+
270
+ /**
271
+ * 입력 자리 = 상태줄 + 상자. 커서는 상자 안에 놓인다.
272
+ *
273
+ * 치던 글을 받는다. 입력 도중에 한 줄을 끼워 넣고 다시 그리는 자리
274
+ * (Shift+Tab·Ctrl+O·Ctrl+C)에서, 빈 칸을 그렸다가 곧바로 채우면 한 번
275
+ * 깜빡인다. 처음부터 채워서 그린다 — 한 번만 그리면 안 깜빡인다.
276
+ */
277
+ 입력자리(session, 글 = '', 커서 = 0, 추천 = []) {
180
278
  this.임시지움();
181
279
  say('');
182
- this.상자.그리기(session, '', 0);
280
+ this.상자.그리기(session, 글, 커서, null, 추천);
183
281
  }
184
282
 
185
283
  /** 사람이 치는 중인 글이 바뀌었다 (repl.js 의 keypress 가 부른다). */
186
- 입력갱신(session, 글, 커서) { this.상자.그리기(session, 글, 커서); }
284
+ 입력갱신(session, 글, 커서, 추천 = []) { this.상자.그리기(session, 글, 커서, null, 추천); }
187
285
 
188
286
  입력지움() { this.상자.지우기(); }
189
287