deel-local-cli 1.4.3 → 1.5.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.
package/src/ui/screen.js CHANGED
@@ -1,312 +1,319 @@
1
- /**
2
- * 화면 한 장.
3
- *
4
- * 왜 이 파일이 생겼나:
5
- * repl.js 안에 say()·process.stdout.write 가 79군데 흩어져 있었다. 그 상태로는
6
- * 화면을 하나 더 얹을 수가 없다 — 어디로 무엇이 나가는지 한 군데서 못 잡으니
7
- * 두 화면이 같은 코드를 나눠 쓰려다 결국 둘 다 어중간해진다.
8
- *
9
- * 그래서 '무엇을 그린다' 와 '어떻게 그린다' 를 가른다. repl.js 는 이제
10
- * `화면.줄(...)`·`화면.답조각(...)` 처럼 **뜻**만 말하고, 그것을 줄로 흘릴지
11
- * 칸에 담아 다시 그릴지는 여기서 정한다.
12
- *
13
- * 두 가지 구현이 있다:
14
- * 줄화면(LineScreen) 지금까지의 그 화면. 위에서 아래로 흘러간다.
15
- * 파이프·기록·CI·`deel run`·검사가 전부 이것을 읽는다.
16
- * 상자화면(BoxScreen) ui/inputbox.js. 사람이 터미널 앞에 앉아 있을 때만.
17
- * **대화는 똑같이 흘려보내고** 맨 아래 입력 상자만
18
- * 우리가 지우고 다시 그린다.
19
- *
20
- * ── 한 번 틀렸던 길 ─────────────────────────────────────────────────────
21
- *
22
- * 처음에는 터미널을 통째로 빌려(대체 화면) 대화 칸·파일 칸·할 일 칸을 나눠
23
- * 그렸다. 보기에는 그럴듯했는데 **슬래시 명령이 전부 먹통**이 됐다.
24
- * commands.js 를 비롯한 여섯 모듈이 화면 객체를 안 거치고 stdout 에 바로 쓰는데,
25
- * 전체화면은 매번 자기 버퍼에서 화면을 다시 그리므로 그 글이 찍히자마자
26
- * 덮여 사라졌다. 명령이 안 도는 게 아니라 결과가 안 보이는 것이라 더 나빴다.
27
- *
28
- * 고치려면 stdout 에 쓰는 자리를 전부 화면 객체로 꿰야 하는데, 지금 여섯 곳이고
29
- * 앞으로 늘 것이며, 하나라도 빠뜨리면 같은 증상이 조용히 돌아온다. 그래서
30
- * 대화는 그냥 흘려보내기로 했다. 그러면 stdout 에 쓰는 모든 코드가 손 안 대고
31
- * 그대로 맞다. 스크롤·복사·`| tee` 도 그대로다.
32
- */
33
- import { c, say, cursor, box } from './ansi.js';
34
- import { statusLine, contextWarning } from './status.js';
35
- import { spin } from './spinner.js';
36
-
37
- /**
38
- * 지금 이 자리에서 입력 상자를 써도 되는가.
39
- *
40
- * 하나라도 아니면 줄화면이다 — 애매하면 줄화면이 맞다. 잘못 켜면 사용자
41
- * 화면이 깨지지만, 잘못 안 켜면 그냥 지금까지의 화면일 뿐이다.
42
- *
43
- * 상자는 커서를 위로 올려 가며 자기가 그린 줄을 지운다. 그 앞자리가
44
- * 터미널이 아니면(파이프·기록) 제어문자가 그대로 글에 섞인다.
45
- */
46
- export function 상자쓸까({ tui = null } = {}) {
47
- if (tui === false) return false; // deel --no-tui
48
- if (!process.stdout.isTTY) return false; // 파이프·기록·CI
49
- if (!process.stdin.isTTY) return false; // 입력이 파이프로 들어옴 (검사·데모)
50
- if (process.env.TERM === 'dumb') return false;
51
- if (process.env.CI) return false;
52
- // 너무 좁으면 상자 안에 남는 자리가 없다. 테두리만 남고 글이 안 보인다.
53
- if ((process.stdout.columns ?? 0) < 40) return false;
54
- if (tui === true) return true;
55
- return true;
56
- }
57
-
58
- /**
59
- * 줄로 흘려보내는 화면 — 지금까지의 그 화면.
60
- *
61
- * 여기 있는 메서드 하나하나가 전에 repl.js 에 흩어져 있던 출력 한 줄과
62
- * **글자 하나까지 같아야 한다.** 검사가 전부 이 화면을 글로 읽고 있어서,
63
- * 다르면 그 자리에서 잡힌다. 그게 이 갈라내기가 맞는지 재는 자다.
64
- */
65
- export class LineScreen {
66
- constructor() {
67
- this.kind = 'line';
68
- // 곧 지워질 줄이 화면에 있나. \r 로 커서만 앞으로 보내 놓고 안 지우면
69
- // 다음에 오는 짧은 글이 그 줄 위에 겹쳐 찍힌다.
70
- this.임시중 = false;
71
- this.돌림 = null;
72
- /*
73
- * 하위 작업 안쪽인가 — 그렇다면 몇 겹인가.
74
- *
75
- * 하위 작업(tools/task.js)은 제 대화에서 따로 돌지만 화면은 하나다.
76
- * 표시가 없으면 하위가 부른 도구 줄이 부모 것과 똑같이 찍혀서, 사람은
77
- * 부모가 파일 열두 개를 읽은 줄로 본다. 한 단 들여 그으면 어디부터
78
- * 어디까지가 떼어 낸 일인지가 그냥 보인다.
79
- *
80
- * 여기 한 군데서만 붙인다. repl.js 의 say() 자리 예순 곳을 고치는 대신
81
- * 이벤트가 들고 온 깊이를 화면에 알려 주는 쪽이 안 빠뜨린다.
82
- */
83
- this.깊이 = 0;
84
- }
85
-
86
- // ── 흘러가는 글 ────────────────────────────────────────────────────────
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
-
100
- /** 한 줄. 이미 색이 입혀진 글을 받는다. */
101
- 줄(s = '') { this.임시지움(); say(this.깊이 && s ? this.#안내() + s : s); }
102
-
103
- /** 줄바꿈 없이 이어 붙인다 — 스트리밍으로 오는 답. */
104
- 붙임(s) { this.임시지움(); process.stdout.write(s); }
105
-
106
- // ── 곧 지워질 표시 ─────────────────────────────────────────────────────
107
-
108
- /**
109
- * 기다리는 중. \r 로 커서만 앞으로 보낸다.
110
- *
111
- * 파이프일 때도 찍는다 — 데모·기록에서 '무엇을 기다리는 중이었나' 가
112
- * 남아야 한다. 다만 지워야 할 줄로 세는 것은 터미널일 때만이다.
113
- */
114
- 기다림(s) {
115
- process.stdout.write(` ${s}\r`);
116
- if (process.stdout.isTTY) this.임시중 = true;
117
- }
118
-
119
- /** 생각하는 중. 글자 수가 계속 바뀌므로 터미널일 때만 그린다. */
120
- 생각(s) {
121
- if (!process.stdout.isTTY) return;
122
- cursor.clearLine();
123
- process.stdout.write(` ${s}`);
124
- this.임시중 = true;
125
- }
126
-
127
- 임시지움() {
128
- if (this.임시중) { cursor.clearLine(); this.임시중 = false; }
129
- }
130
-
131
- /**
132
- * 사람이 치고 있던 입력 줄을 지운다.
133
- *
134
- * Shift+Tab 으로 승인 방식을 돌릴 때처럼, 입력을 기다리는 도중에 화면에
135
- * 한 줄을 끼워 넣어야 하는 자리가 있다. 안 지우면 `❯ ` 뒤에 새 글이 붙는다.
136
- * 상자화면에서는 상자를 통째로 걷어낸다(BoxScreen 이 갈아 끼운다).
137
- */
138
- 입력지움() { cursor.clearLine(); }
139
-
140
- /** 오래 걸리는 일에 돌아가는 표시를 세운다. */
141
- 돌리기(label) { this.돌림 = spin(label); return this.돌림; }
142
-
143
- // spinner 가 멈추면서 이미 줄을 지우고 커서를 되살린다. 여기서 또 지우지 않는다 —
144
- // 화면에 나가는 제어문자가 한 벌이라도 달라지면 그게 갈라내기의 흔적이 된다.
145
- 돌림멈춤(finalLine) {
146
- if (!this.돌림) return;
147
- this.돌림.stop(finalLine);
148
- this.돌림 = null;
149
- }
150
-
151
- // ── 자리를 차지하는 것 ─────────────────────────────────────────────────
152
-
153
- /** 켤 때 한 번 그리는 머리말 상자. */
154
- 머리말(lines) {
155
- say('');
156
- for (const l of box(lines, { tone: c.gray })) say(' ' + l);
157
- }
158
-
159
- /**
160
- * 입력 자리. 위에 상태줄을 한 줄 깔고 그 아래에 커서를 둔다.
161
- *
162
- * 줄화면에서는 이게 매번 새로 찍힌다 — 대화가 길어지면 상태줄이 화면에
163
- * 여러 번 남는다. 그게 흘러가는 화면의 성질이고, 기록으로 읽을 때는
164
- * 오히려 그때그때 상태를 알 수 있어 낫다.
165
- */
166
- 입력자리(session) {
167
- say('');
168
- say(statusLine(session));
169
- const w = contextWarning(session);
170
- if (w) say(` ${w}`);
171
- process.stdout.write(` ${c.hcyan('❯')} `);
172
- }
173
-
174
- /** 사람이 치는 중인 글이 바뀌었다. 줄화면은 readline 이 알아서 되비춘다. */
175
- 입력갱신() { }
176
-
177
- /** 일하는 중 미리 치는 글. 줄화면은 상자가 없어 보여 줄 자리가 없다. */
178
- 대기갱신() { }
179
-
180
- /** 줄화면은 붙여 쓰기 전에 임시글만 지우면 되므로 따로 셀 것이 없다. */
181
- 줄중간인가() { return false; }
182
-
183
- /*
184
- * 일하는 중 표시. 줄화면은 안 그린다.
185
- *
186
- * 줄화면은 흘러가는 화면이라 '한 자리에서 도는 표시' 를 둘 데가 없다.
187
- * 대신 기다림()·생각() 이 그 자리를 이미 맡고 있다 — 여기서 또 그리면
188
- * 같은 말이 두 번 나간다. 파이프·기록에서는 돌아가는 표시 자체가 잡음이다.
189
- */
190
- 일시작() { }
191
- 일바꿈() { }
192
- 일끝() { }
193
-
194
- close() {
195
- this.돌림멈춤();
196
- this.임시지움();
197
- }
198
- }
199
-
200
- /**
201
- * 대화는 그대로 흘려보내고, 맨 아래 입력 상자만 우리가 관리하는 화면.
202
- *
203
- * LineScreen 을 그대로 물려받는다 — 대화가 나가는 길이 **한 글자도 다르면
204
- * 되기** 때문이다. 다르면 검사가 읽는 화면과 사람이 보는 화면이 갈라진다.
205
- * 여기서 갈아 끼우는 것은 입력 자리 세 가지뿐이다.
206
- */
207
- export class BoxScreen extends LineScreen {
208
- constructor(상자) {
209
- super();
210
- this.kind = 'box';
211
- this.상자 = 상자;
212
- }
213
-
214
- /*
215
- * 대화 한 줄이 나가기 전에 상자를 걷어낸다. 안 걷으면 새 글이 상자 위에
216
- * 겹쳐 찍히고, 지울 때 대화까지 같이 지워진다.
217
- *
218
- * 일하는 중이면 찍은 **뒤에 다시 세운다.** 이게 없으면 도구 결과가
219
- * 찍히는 순간 상자가 사라지고, 뒤로는 일이 끝날 때까지 화면 아래가
220
- * 텅 빈 채로 남는다 — 정확히 그게 "밋밋해 보인다" 는 상태다.
221
- */
222
- 줄(s = '') {
223
- this.상자.지우기();
224
- super.줄(s);
225
- // 줄이 끝났다 — 이제 아래에 상자를 세워도 대화를 안 덮는다.
226
- this.상자.줄끝남();
227
- this.상자.일그리기();
228
- }
229
-
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, 추천 = []) {
278
- this.임시지움();
279
- say('');
280
- this.상자.그리기(session, 글, 커서, null, 추천);
281
- }
282
-
283
- /** 사람이 치는 중인 글이 바뀌었다 (repl.js 의 keypress 가 부른다). */
284
- 입력갱신(session, 글, 커서, 추천 = []) { this.상자.그리기(session, 글, 커서, null, 추천); }
285
-
286
- 입력지움() { this.상자.지우기(); }
287
-
288
- close() {
289
- this.상자.지우기();
290
- super.close();
291
- }
292
- }
293
-
294
- /**
295
- * 상황에 맞는 화면을 하나 고른다.
296
- *
297
- * 상자 쪽은 필요할 때만 읽어 들인다. 줄화면으로 돌 때 inputbox.js 를
298
- * 파싱조차 안 하게 하려는 것이다 — `deel run` 이 조금이라도 빨리 시작해야 한다.
299
- */
300
- export async function 화면고르기(opts = {}) {
301
- if (!상자쓸까(opts)) return new LineScreen();
302
- try {
303
- const { InputBox } = await import('./inputbox.js');
304
- return new BoxScreen(new InputBox());
305
- } catch (e) {
306
- // 상자를 못 세우면 조용히 줄화면으로 간다. 화면 하나 때문에
307
- // 프로그램이 뜨는 일은 없어야 한다.
308
- const s = new LineScreen();
309
- s.줄(` ${c.gray(`입력 상자를 못 켰습니다 — 줄 화면으로 갑니다. (${e.message})`)}`);
310
- return s;
311
- }
312
- }
1
+ /**
2
+ * 화면 한 장.
3
+ *
4
+ * 왜 이 파일이 생겼나:
5
+ * repl.js 안에 say()·process.stdout.write 가 79군데 흩어져 있었다. 그 상태로는
6
+ * 화면을 하나 더 얹을 수가 없다 — 어디로 무엇이 나가는지 한 군데서 못 잡으니
7
+ * 두 화면이 같은 코드를 나눠 쓰려다 결국 둘 다 어중간해진다.
8
+ *
9
+ * 그래서 '무엇을 그린다' 와 '어떻게 그린다' 를 가른다. repl.js 는 이제
10
+ * `화면.줄(...)`·`화면.답조각(...)` 처럼 **뜻**만 말하고, 그것을 줄로 흘릴지
11
+ * 칸에 담아 다시 그릴지는 여기서 정한다.
12
+ *
13
+ * 두 가지 구현이 있다:
14
+ * 줄화면(LineScreen) 지금까지의 그 화면. 위에서 아래로 흘러간다.
15
+ * 파이프·기록·CI·`deel run`·검사가 전부 이것을 읽는다.
16
+ * 상자화면(BoxScreen) ui/inputbox.js. 사람이 터미널 앞에 앉아 있을 때만.
17
+ * **대화는 똑같이 흘려보내고** 맨 아래 입력 상자만
18
+ * 우리가 지우고 다시 그린다.
19
+ *
20
+ * ── 한 번 틀렸던 길 ─────────────────────────────────────────────────────
21
+ *
22
+ * 처음에는 터미널을 통째로 빌려(대체 화면) 대화 칸·파일 칸·할 일 칸을 나눠
23
+ * 그렸다. 보기에는 그럴듯했는데 **슬래시 명령이 전부 먹통**이 됐다.
24
+ * commands.js 를 비롯한 여섯 모듈이 화면 객체를 안 거치고 stdout 에 바로 쓰는데,
25
+ * 전체화면은 매번 자기 버퍼에서 화면을 다시 그리므로 그 글이 찍히자마자
26
+ * 덮여 사라졌다. 명령이 안 도는 게 아니라 결과가 안 보이는 것이라 더 나빴다.
27
+ *
28
+ * 고치려면 stdout 에 쓰는 자리를 전부 화면 객체로 꿰야 하는데, 지금 여섯 곳이고
29
+ * 앞으로 늘 것이며, 하나라도 빠뜨리면 같은 증상이 조용히 돌아온다. 그래서
30
+ * 대화는 그냥 흘려보내기로 했다. 그러면 stdout 에 쓰는 모든 코드가 손 안 대고
31
+ * 그대로 맞다. 스크롤·복사·`| tee` 도 그대로다.
32
+ */
33
+ import { c, say, cursor, box } from './ansi.js';
34
+ import { statusLine, contextWarning } from './status.js';
35
+ import { spin } from './spinner.js';
36
+
37
+ /**
38
+ * 지금 이 자리에서 입력 상자를 써도 되는가.
39
+ *
40
+ * 하나라도 아니면 줄화면이다 — 애매하면 줄화면이 맞다. 잘못 켜면 사용자
41
+ * 화면이 깨지지만, 잘못 안 켜면 그냥 지금까지의 화면일 뿐이다.
42
+ *
43
+ * 상자는 커서를 위로 올려 가며 자기가 그린 줄을 지운다. 그 앞자리가
44
+ * 터미널이 아니면(파이프·기록) 제어문자가 그대로 글에 섞인다.
45
+ */
46
+ export function 상자쓸까({ tui = null } = {}) {
47
+ if (tui === false) return false; // deel --no-tui
48
+ if (!process.stdout.isTTY) return false; // 파이프·기록·CI
49
+ if (!process.stdin.isTTY) return false; // 입력이 파이프로 들어옴 (검사·데모)
50
+ if (process.env.TERM === 'dumb') return false;
51
+ if (process.env.CI) return false;
52
+ // 너무 좁으면 상자 안에 남는 자리가 없다. 테두리만 남고 글이 안 보인다.
53
+ if ((process.stdout.columns ?? 0) < 40) return false;
54
+ if (tui === true) return true;
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * 줄로 흘려보내는 화면 — 지금까지의 그 화면.
60
+ *
61
+ * 여기 있는 메서드 하나하나가 전에 repl.js 에 흩어져 있던 출력 한 줄과
62
+ * **글자 하나까지 같아야 한다.** 검사가 전부 이 화면을 글로 읽고 있어서,
63
+ * 다르면 그 자리에서 잡힌다. 그게 이 갈라내기가 맞는지 재는 자다.
64
+ */
65
+ export class LineScreen {
66
+ constructor() {
67
+ this.kind = 'line';
68
+ // 곧 지워질 줄이 화면에 있나. \r 로 커서만 앞으로 보내 놓고 안 지우면
69
+ // 다음에 오는 짧은 글이 그 줄 위에 겹쳐 찍힌다.
70
+ this.임시중 = false;
71
+ this.돌림 = null;
72
+ /*
73
+ * 하위 작업 안쪽인가 — 그렇다면 몇 겹인가.
74
+ *
75
+ * 하위 작업(tools/task.js)은 제 대화에서 따로 돌지만 화면은 하나다.
76
+ * 표시가 없으면 하위가 부른 도구 줄이 부모 것과 똑같이 찍혀서, 사람은
77
+ * 부모가 파일 열두 개를 읽은 줄로 본다. 한 단 들여 그으면 어디부터
78
+ * 어디까지가 떼어 낸 일인지가 그냥 보인다.
79
+ *
80
+ * 여기 한 군데서만 붙인다. repl.js 의 say() 자리 예순 곳을 고치는 대신
81
+ * 이벤트가 들고 온 깊이를 화면에 알려 주는 쪽이 안 빠뜨린다.
82
+ */
83
+ this.깊이 = 0;
84
+ }
85
+
86
+ // ── 흘러가는 글 ────────────────────────────────────────────────────────
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
+
100
+ /** 한 줄. 이미 색이 입혀진 글을 받는다. */
101
+ 줄(s = '') { this.임시지움(); say(this.깊이 && s ? this.#안내() + s : s); }
102
+
103
+ /** 줄바꿈 없이 이어 붙인다 — 스트리밍으로 오는 답. */
104
+ 붙임(s) { this.임시지움(); process.stdout.write(s); }
105
+
106
+ // ── 곧 지워질 표시 ─────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * 기다리는 중. \r 로 커서만 앞으로 보낸다.
110
+ *
111
+ * 파이프일 때도 찍는다 — 데모·기록에서 '무엇을 기다리는 중이었나' 가
112
+ * 남아야 한다. 다만 지워야 할 줄로 세는 것은 터미널일 때만이다.
113
+ */
114
+ 기다림(s) {
115
+ process.stdout.write(` ${s}\r`);
116
+ if (process.stdout.isTTY) this.임시중 = true;
117
+ }
118
+
119
+ /** 생각하는 중. 글자 수가 계속 바뀌므로 터미널일 때만 그린다. */
120
+ 생각(s) {
121
+ if (!process.stdout.isTTY) return;
122
+ cursor.clearLine();
123
+ process.stdout.write(` ${s}`);
124
+ this.임시중 = true;
125
+ }
126
+
127
+ 임시지움() {
128
+ if (this.임시중) { cursor.clearLine(); this.임시중 = false; }
129
+ }
130
+
131
+ /**
132
+ * 사람이 치고 있던 입력 줄을 지운다.
133
+ *
134
+ * Shift+Tab 으로 승인 방식을 돌릴 때처럼, 입력을 기다리는 도중에 화면에
135
+ * 한 줄을 끼워 넣어야 하는 자리가 있다. 안 지우면 `❯ ` 뒤에 새 글이 붙는다.
136
+ * 상자화면에서는 상자를 통째로 걷어낸다(BoxScreen 이 갈아 끼운다).
137
+ */
138
+ 입력지움() { cursor.clearLine(); }
139
+
140
+ /** 오래 걸리는 일에 돌아가는 표시를 세운다. */
141
+ 돌리기(label) { this.돌림 = spin(label); return this.돌림; }
142
+
143
+ // spinner 가 멈추면서 이미 줄을 지우고 커서를 되살린다. 여기서 또 지우지 않는다 —
144
+ // 화면에 나가는 제어문자가 한 벌이라도 달라지면 그게 갈라내기의 흔적이 된다.
145
+ 돌림멈춤(finalLine) {
146
+ if (!this.돌림) return;
147
+ this.돌림.stop(finalLine);
148
+ this.돌림 = null;
149
+ }
150
+
151
+ // ── 자리를 차지하는 것 ─────────────────────────────────────────────────
152
+
153
+ /** 켤 때 한 번 그리는 머리말 상자. */
154
+ 머리말(lines) {
155
+ say('');
156
+ for (const l of box(lines, { tone: c.gray })) say(' ' + l);
157
+ }
158
+
159
+ /**
160
+ * 입력 자리. 위에 상태줄을 한 줄 깔고 그 아래에 커서를 둔다.
161
+ *
162
+ * 줄화면에서는 이게 매번 새로 찍힌다 — 대화가 길어지면 상태줄이 화면에
163
+ * 여러 번 남는다. 그게 흘러가는 화면의 성질이고, 기록으로 읽을 때는
164
+ * 오히려 그때그때 상태를 알 수 있어 낫다.
165
+ */
166
+ 입력자리(session) {
167
+ say('');
168
+ say(statusLine(session));
169
+ const w = contextWarning(session);
170
+ if (w) say(` ${w}`);
171
+ process.stdout.write(` ${c.hcyan('❯')} `);
172
+ }
173
+
174
+ /** 사람이 치는 중인 글이 바뀌었다. 줄화면은 readline 이 알아서 되비춘다. */
175
+ 입력갱신() { }
176
+
177
+ /** 일하는 중 미리 치는 글. 줄화면은 상자가 없어 보여 줄 자리가 없다. */
178
+ 대기갱신() { }
179
+
180
+ /** 줄화면은 붙여 쓰기 전에 임시글만 지우면 되므로 따로 셀 것이 없다. */
181
+ 줄중간인가() { return false; }
182
+
183
+ /*
184
+ * 일하는 중 표시. 줄화면은 안 그린다.
185
+ *
186
+ * 줄화면은 흘러가는 화면이라 '한 자리에서 도는 표시' 를 둘 데가 없다.
187
+ * 대신 기다림()·생각() 이 그 자리를 이미 맡고 있다 — 여기서 또 그리면
188
+ * 같은 말이 두 번 나간다. 파이프·기록에서는 돌아가는 표시 자체가 잡음이다.
189
+ */
190
+ 일시작() { }
191
+ 일바꿈() { }
192
+ 일끝() { }
193
+ // 줄 화면에는 사무실이 없다 — 받아만 두고 아무것도 안 한다.
194
+ 할일갱신() { }
195
+ 하위갱신() { }
196
+
197
+ close() {
198
+ this.돌림멈춤();
199
+ this.임시지움();
200
+ }
201
+ }
202
+
203
+ /**
204
+ * 대화는 그대로 흘려보내고, 아래 입력 상자만 우리가 관리하는 화면.
205
+ *
206
+ * LineScreen 을 그대로 물려받는다 — 대화가 나가는 길이 **한 글자도 다르면
207
+ * 되기** 때문이다. 다르면 검사가 읽는 화면과 사람이 보는 화면이 갈라진다.
208
+ * 여기서 갈아 끼우는 것은 입력 자리 세 가지뿐이다.
209
+ */
210
+ export class BoxScreen extends LineScreen {
211
+ constructor(상자) {
212
+ super();
213
+ this.kind = 'box';
214
+ this.상자 = 상자;
215
+ }
216
+
217
+ /*
218
+ * 대화 줄이 나가기 전에 상자를 걷어낸다. 걷으면 글이 상자 위에
219
+ * 겹쳐 찍히고, 지울 대화까지 같이 지워진다.
220
+ *
221
+ * 일하는 중이면 찍은 **뒤에 다시 세운다.** 이게 없으면 첫 도구 결과가
222
+ * 찍히는 순간 상자가 사라지고, 그 뒤로는 일이 끝날 때까지 화면 아래가
223
+ * 텅 빈 채로 남는다 — 정확히 그게 "밋밋해 보인다" 는 상태다.
224
+ */
225
+ 줄(s = '') {
226
+ this.상자.지우기();
227
+ super.줄(s);
228
+ // 줄이 끝났다 — 이제 아래에 상자를 세워도 대화를 안 덮는다.
229
+ this.상자.줄끝남();
230
+ this.상자.일그리기();
231
+ }
232
+
233
+ /*
234
+ * 스트리밍으로 이어 붙이는 글은 다시 세운다.
235
+ *
236
+ * 토막마다 상자를 세웠다 걷으면, 글자 몇 개마다 상자가 깜빡이고 커서가
237
+ * 위아래로 튄다. 답이 흘러나오는 동안에는 그 자체가 살아 있다는 신호라
238
+ * 상자가 없어도 밋밋하지 않다. 답이 끝나면 repl.js 다시 세운다.
239
+ */
240
+ 붙임(s) {
241
+ // 상자를 걷고, **줄 중간에 멈춰 있다고 표시한다.** 이 표시가 없으면
242
+ // 돌아가는 표시가 90ms 뒤에 제 시계로 상자를 답 줄 위에 그려 버리고,
243
+ // 그 다음 지우기가 답까지 같이 지운다.
244
+ this.상자.줄중간표시();
245
+ super.붙임(s);
246
+ }
247
+
248
+ /** 일하기 시작·바뀜·끝. 상자 안엣것만 바뀐다 (ui/working.js 문구). */
249
+ 일시작(session, 갈래) { this.임시지움(); this.상자.일시작(session, 갈래); }
250
+ 일바꿈(갈래, 곁정보) { this.상자.일바꿈(갈래, 곁정보); }
251
+ 일끝() { this.상자.일끝(); }
252
+ /** 할 일이 바뀌었다 — 사무실 화이트보드가 이걸 본다. */
253
+ 할일갱신(할일들) { this.상자.할일갱신(할일들); }
254
+ /** 도는 하위 작업 수가 바뀌었다 — 사무실 자리가 이만큼 찬다. */
255
+ 하위갱신(n) { this.상자.하위갱신(n); }
256
+
257
+ /** 일하는 도중에 미리 치고 있는 · 예약해 둔 개수. */
258
+ 대기갱신(글, 예약수) { this.상자.대기갱신(글, 예약수); }
259
+
260
+ /** 대화가 줄 중간에 멈춰 있나 (답이 흘러나오는 ). */
261
+ 줄중간인가() { return this.상자.줄중간; }
262
+
263
+ /*
264
+ * 줄화면에서 '생각 중…' 줄로 흘리던 자리.
265
+ *
266
+ * 상자를 때는 말이 상자 안으로 들어가야 한다. 안 그러면 같은 말이
267
+ * 두 군데(흘러가는 + 상자)에 동시에 뜬다.
268
+ */
269
+ 기다림() { }
270
+ 생각(s) {
271
+ // '생각 중… 1,234자' 에서 숫자만 떼어 곁정보로 붙인다. 진짜로 늘어나는
272
+ // 숫자가 하나 있어야 '살아 있다' 가 표시가 아니라 사실이 된다.
273
+ const m = /([\d,]+)자/.exec(String(s ?? ''));
274
+ this.상자.일바꿈('생각', m ? `생각 ${m[1]}자` : null);
275
+ }
276
+
277
+ /**
278
+ * 입력 자리 = 상태줄 + 상자. 커서는 상자 안에 놓인다.
279
+ *
280
+ * 치던 글을 받는다. 입력 도중에 한 줄을 끼워 넣고 다시 그리는 자리
281
+ * (Shift+Tab·Ctrl+O·Ctrl+C)에서, 빈 칸을 그렸다가 곧바로 채우면 한 번
282
+ * 깜빡인다. 처음부터 채워서 그린다 — 한 번만 그리면 안 깜빡인다.
283
+ */
284
+ 입력자리(session, = '', 커서 = 0, 추천 = []) {
285
+ this.임시지움();
286
+ say('');
287
+ this.상자.그리기(session, 글, 커서, null, 추천);
288
+ }
289
+
290
+ /** 사람이 치는 중인 글이 바뀌었다 (repl.js 의 keypress 가 부른다). */
291
+ 입력갱신(session, 글, 커서, 추천 = []) { this.상자.그리기(session, 글, 커서, null, 추천); }
292
+
293
+ 입력지움() { this.상자.지우기(); }
294
+
295
+ close() {
296
+ this.상자.지우기();
297
+ super.close();
298
+ }
299
+ }
300
+
301
+ /**
302
+ * 상황에 맞는 화면을 하나 고른다.
303
+ *
304
+ * 상자 쪽은 필요할 때만 읽어 들인다. 줄화면으로 돌 때 inputbox.js 를
305
+ * 파싱조차 안 하게 하려는 것이다 — `deel run` 이 조금이라도 빨리 시작해야 한다.
306
+ */
307
+ export async function 화면고르기(opts = {}) {
308
+ if (!상자쓸까(opts)) return new LineScreen();
309
+ try {
310
+ const { InputBox } = await import('./inputbox.js');
311
+ return new BoxScreen(new InputBox());
312
+ } catch (e) {
313
+ // 상자를 못 세우면 조용히 줄화면으로 간다. 화면 하나 때문에
314
+ // 프로그램이 안 뜨는 일은 없어야 한다.
315
+ const s = new LineScreen();
316
+ s.줄(` ${c.gray(`입력 상자를 못 켰습니다 — 줄 화면으로 갑니다. (${e.message})`)}`);
317
+ return s;
318
+ }
319
+ }