deel-local-cli 1.0.0 → 1.0.2
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/README.en.md +175 -40
- package/README.md +179 -40
- package/bin/deel.js +4 -4
- package/package.json +1 -1
- package/src/agent/modes.js +1 -1
- package/src/commands.js +29 -4
- package/src/repl.js +210 -38
- package/src/ui/approve.js +66 -0
- package/src/ui/complete.js +89 -0
- package/src/ui/inputbox.js +309 -0
- package/src/ui/screen.js +123 -28
- package/src/ui/status.js +29 -6
- package/src/ui/working.js +118 -0
- package/src/ui/wrap.js +68 -0
- package/src/ui/tui.js +0 -419
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 입력 상자.
|
|
3
|
+
*
|
|
4
|
+
* 아래처럼 생겼다. **이것만** 다시 그린다 — 위쪽 대화는 안 건드린다.
|
|
5
|
+
*
|
|
6
|
+
* ▏myproject · qwen2.5-coder:7b ▏ ▰▰▱▱▱▱ 22% ▏ ◎ 종합 · auto
|
|
7
|
+
* ╭──────────────────────────────────────────────────────────╮
|
|
8
|
+
* │ ❯ 집계 함수도 줄여줘 │
|
|
9
|
+
* ╰──────────────────────────────────────────────────────────╯
|
|
10
|
+
*
|
|
11
|
+
* ── 왜 이렇게 바꿨나 ────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* 전에는 터미널을 통째로 빌려(대체 화면) 대화 칸·파일 칸·할 일 칸을 나눠
|
|
14
|
+
* 그렸다. 보기에는 그럴듯했지만 **슬래시 명령이 전부 먹통**이 됐다.
|
|
15
|
+
*
|
|
16
|
+
* commands.js · setup.js · scanui.js · sessionui.js · report.js 는
|
|
17
|
+
* 화면 객체를 안 거치고 stdout 에 바로 쓴다. 전체화면은 매번 화면 전체를
|
|
18
|
+
* 자기 버퍼에서 다시 그리므로, 그 글은 찍히자마자 덮여 사라졌다.
|
|
19
|
+
* 명령이 안 도는 게 아니라 **결과가 안 보이는** 것이라 더 나빴다.
|
|
20
|
+
*
|
|
21
|
+
* 고치려면 stdout 에 쓰는 자리를 전부 화면 객체로 꿰야 한다. 지금 여섯 곳이고
|
|
22
|
+
* 앞으로 늘 것이며, 하나라도 빠뜨리면 같은 증상이 조용히 돌아온다.
|
|
23
|
+
*
|
|
24
|
+
* 그래서 반대로 간다. **대화는 터미널에 그냥 흘려보낸다.** 그러면 stdout 에
|
|
25
|
+
* 쓰는 모든 코드가 손 안 대고 그대로 맞다. 스크롤·복사·`| tee` 도 그대로다.
|
|
26
|
+
* 우리가 관리하는 것은 화면 맨 아래 **입력 상자 몇 줄뿐**이다.
|
|
27
|
+
*
|
|
28
|
+
* ── 지키는 선 ───────────────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* 1) 줄 편집은 readline 이 계속 맡는다. 한글 조합·붙여넣기·위아래 이력·
|
|
31
|
+
* 백스페이스·Ctrl+A 를 우리가 다시 짜지 않는다. 우리는 readline 이 들고
|
|
32
|
+
* 있는 글(rl.line)을 **그리기만** 한다.
|
|
33
|
+
* 2) 그릴 것을 값으로 내놓는다(프레임). 검사는 파이프로 도니 화면을 못 본다 —
|
|
34
|
+
* 값으로 내놓아야 한 칸도 안 어긋나는지 잴 수 있다.
|
|
35
|
+
*/
|
|
36
|
+
import { c, width, cursor, clip } from './ansi.js';
|
|
37
|
+
import { statusLine, contextWarning } from './status.js';
|
|
38
|
+
import { 접어쓰기 } from './wrap.js';
|
|
39
|
+
import { 문구고르기, 돌림틀, 걸린시간, 느긋해질때, 문구주기 } from './working.js';
|
|
40
|
+
import { 최대추천 } from './complete.js';
|
|
41
|
+
|
|
42
|
+
const 줄끝지움 = '\x1b[K';
|
|
43
|
+
const 위로 = (n) => (n > 0 ? `\x1b[${n}A` : '');
|
|
44
|
+
// 돌아가는 표시를 한 칸 넘기는 주기. 사람 눈에 부드럽고, 다시 그리는 값은 싸다.
|
|
45
|
+
const 돌림주기 = 90;
|
|
46
|
+
|
|
47
|
+
// 상자 안쪽 왼쪽에 붙는 것: ' ' + '│' + ' ' + '❯' + ' ' → 5칸
|
|
48
|
+
const 앞머리 = 5;
|
|
49
|
+
// 상자가 차지할 최대 줄 수. 긴 글을 붙여넣어도 화면을 다 먹지 않게.
|
|
50
|
+
export const 안쪽최대 = 8;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 상자를 이룰 줄들과 커서 자리를 값으로 내놓는다.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} o
|
|
56
|
+
* @param {string} o.글 사람이 지금까지 친 것
|
|
57
|
+
* @param {number} o.커서 글 안에서의 커서 위치(글자 수)
|
|
58
|
+
* @param {number} o.폭 터미널 칸 수
|
|
59
|
+
* @param {string} o.상태 상태줄 (없으면 안 그린다)
|
|
60
|
+
* @param {string} o.경고 컨텍스트 경고 (없으면 안 그린다)
|
|
61
|
+
* @param {string} o.곁말 상자 아래 흐린 한 줄 (도움말 등)
|
|
62
|
+
* @param {object[]} o.추천 자동완성 후보 [{이름, 설명, 인자}]
|
|
63
|
+
* @returns {{줄들: string[], 커서: {위: number, 열: number}}}
|
|
64
|
+
* 커서.위 = 마지막 줄에서 몇 줄 위인가, 커서.열 = 1부터 세는 칸
|
|
65
|
+
*/
|
|
66
|
+
export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상태 = '', 경고 = '', 곁말 = '', 일감 = null, 추천 = [] } = {}) {
|
|
67
|
+
const 칸 = Math.max(20, 폭);
|
|
68
|
+
const 안쪽 = Math.max(4, 칸 - 앞머리 - 2); // 오른쪽 ' │' 두 칸
|
|
69
|
+
const 줄들 = [];
|
|
70
|
+
|
|
71
|
+
if (상태) 줄들.push(상태);
|
|
72
|
+
if (경고) 줄들.push(` ${경고}`);
|
|
73
|
+
|
|
74
|
+
const 가로 = '─'.repeat(칸 - 3);
|
|
75
|
+
줄들.push(c.gray(` ╭${가로}╮`));
|
|
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
|
+
줄들.push(c.gray(` ╰${가로}╯`));
|
|
102
|
+
if (곁말) 줄들.push(` ${c.gray(곁말)}`);
|
|
103
|
+
// 일하는 중에는 커서를 안 보여 준다 — 칠 자리가 아니다.
|
|
104
|
+
return { 줄들, 커서: { 위: (곁말 ? 1 : 0), 열: 1 } };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* 긴 입력은 접는다. 자르면 사람이 친 글이 안 보이는데, 안 보이는 채로
|
|
109
|
+
* Enter 를 치게 되는 것이 제일 나쁘다 — 무엇을 보내는지 모르게 된다.
|
|
110
|
+
*/
|
|
111
|
+
const 안쪽줄 = 글 ? 접어쓰기(글, 안쪽) : [''];
|
|
112
|
+
const 넘침 = 안쪽줄.length > 안쪽최대;
|
|
113
|
+
const 보일줄 = 넘침 ? 안쪽줄.slice(안쪽줄.length - 안쪽최대) : 안쪽줄;
|
|
114
|
+
|
|
115
|
+
for (const [i, 한줄] of 보일줄.entries()) {
|
|
116
|
+
const 표 = i === 0 ? (넘침 ? c.gray('…') : c.hcyan('❯')) : ' ';
|
|
117
|
+
const 남 = 안쪽 - width(한줄);
|
|
118
|
+
줄들.push(` ${c.gray('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 남))} ${c.gray('│')}`);
|
|
119
|
+
}
|
|
120
|
+
줄들.push(c.gray(` ╰${가로}╯`));
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
* 자동완성 추천은 상자 **아래**에 놓는다.
|
|
124
|
+
*
|
|
125
|
+
* 안에 넣으면 치던 글이 밀려 올라가서, 무엇을 치고 있었는지가 안 보인다.
|
|
126
|
+
* 아래에 두면 목록이 몇 줄이든 치던 자리는 늘 같은 데 있다.
|
|
127
|
+
*/
|
|
128
|
+
let 추천줄수 = 0;
|
|
129
|
+
if (추천?.length) {
|
|
130
|
+
const 보일것 = 추천.slice(0, 최대추천);
|
|
131
|
+
// 이름은 상자 안 글자와 같은 자리(6칸째)에서 시작한다 — 눈이 안 흔들린다.
|
|
132
|
+
const 이름들 = 보일것.map((x) => `/${x.이름}${x.인자 ? ` ${x.인자}` : ''}`);
|
|
133
|
+
// 이름칸이 넓어지면 설명이 그만큼 잘린다. 제일 긴 이름 하나 때문에 설명이
|
|
134
|
+
// 다 사라지는 일이 없게 위쪽을 막아 둔다.
|
|
135
|
+
const 이름폭 = Math.min(Math.max(...이름들.map(width)), Math.max(12, Math.floor(칸 * 0.38)));
|
|
136
|
+
const 설명폭 = 칸 - 앞머리 - 이름폭 - 3;
|
|
137
|
+
|
|
138
|
+
for (const [i, x] of 보일것.entries()) {
|
|
139
|
+
// 첫째 것만 밝게. Tab 을 누르면 대개 이것으로 채워진다.
|
|
140
|
+
const 표 = i === 0 ? c.hcyan('›') : ' ';
|
|
141
|
+
const 칠 = i === 0 ? c.white : c.gray;
|
|
142
|
+
const 이름 = clip(이름들[i], 이름폭);
|
|
143
|
+
const 벌림 = ' '.repeat(Math.max(1, 이름폭 - width(이름) + 2));
|
|
144
|
+
// 좁은 터미널에서는 설명을 버린다. 이름이 본문이다.
|
|
145
|
+
const 설명 = 설명폭 >= 8 ? c.gray(clip(x.설명 ?? '', 설명폭)) : '';
|
|
146
|
+
줄들.push(` ${표} ${칠(이름)}${설명 ? 벌림 + 설명 : ''}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
추천줄수 = 보일것.length;
|
|
150
|
+
if (추천.length > 보일것.length) {
|
|
151
|
+
줄들.push(` ${c.gray(`그 밖에 ${추천.length - 보일것.length}개 더`)}`);
|
|
152
|
+
추천줄수 += 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (곁말) 줄들.push(` ${c.gray(곁말)}`);
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* 커서 자리.
|
|
160
|
+
*
|
|
161
|
+
* 글자 수가 아니라 **폭**으로 세야 한다. 한글은 두 칸이라, 글자 수로 세면
|
|
162
|
+
* '가나다' 를 친 뒤 커서가 세 칸 왼쪽에 가 있게 된다. 백스페이스를 누르면
|
|
163
|
+
* 엉뚱한 자리가 지워지는 것처럼 보인다.
|
|
164
|
+
*/
|
|
165
|
+
const 앞글 = 커서자리 == null ? 글 : String(글).slice(0, 커서자리);
|
|
166
|
+
const 앞줄들 = 앞글 ? 접어쓰기(앞글, 안쪽) : [''];
|
|
167
|
+
|
|
168
|
+
// 넘쳐서 앞부분을 잘라 냈으면 그만큼 줄 번호가 밀린다.
|
|
169
|
+
const 숨긴줄 = 안쪽줄.length - 보일줄.length;
|
|
170
|
+
const 커서줄 = Math.min(Math.max(0, 앞줄들.length - 1 - 숨긴줄), 보일줄.length - 1);
|
|
171
|
+
|
|
172
|
+
// 커서 아래에 몇 줄이 더 있나 — 닫는 테두리 + 추천 목록 + 곁말.
|
|
173
|
+
const 아래 = 1 + 추천줄수 + (곁말 ? 1 : 0);
|
|
174
|
+
const 위 = 아래 + (보일줄.length - 1 - 커서줄);
|
|
175
|
+
// 1부터 세는 칸. 앞머리(5칸) 다음이 6칸째다 — 거기가 첫 글자 자리다.
|
|
176
|
+
const 열 = Math.min(앞머리 + 1 + width(앞줄들.at(-1) ?? ''), 칸 - 1);
|
|
177
|
+
|
|
178
|
+
return { 줄들, 커서: { 위, 열 } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 터미널 맨 아래에 상자를 붙여 두는 화면.
|
|
183
|
+
*
|
|
184
|
+
* 대화는 stdout 으로 그냥 나간다(줄 화면과 같다). 우리가 지우고 다시 그리는
|
|
185
|
+
* 것은 상자 몇 줄뿐이다.
|
|
186
|
+
*/
|
|
187
|
+
export class InputBox {
|
|
188
|
+
constructor() {
|
|
189
|
+
this.그린줄 = 0; // 지금 화면에 붙어 있는 상자의 줄 수
|
|
190
|
+
/*
|
|
191
|
+
* 커서를 상자 맨 아랫줄에서 몇 줄 위에 놓아 뒀나.
|
|
192
|
+
*
|
|
193
|
+
* 이걸 안 들고 있으면 지울 때가 어긋난다. 상자가 한 줄짜리일 때는
|
|
194
|
+
* 커서가 늘 아래에서 첫째 줄이라 티가 안 나는데, 긴 글이 접혀 상자가
|
|
195
|
+
* 세 줄이 되거나 아래에 곁말이 붙는 순간 **몇 줄을 내려가야 하는지**가
|
|
196
|
+
* 달라진다. 고정값으로 내려가면 화면 맨 아래에 부딪혀 멈추고, 그 뒤로는
|
|
197
|
+
* 엉뚱한 줄을 지운다 — 대화가 한 줄씩 갉여 나간다.
|
|
198
|
+
*/
|
|
199
|
+
this.커서위 = 0;
|
|
200
|
+
this.session = null;
|
|
201
|
+
this.곁말 = '';
|
|
202
|
+
|
|
203
|
+
// 일하는 중 상태. null 이면 평소(입력 기다리는 중)다.
|
|
204
|
+
this.일감 = null;
|
|
205
|
+
this.박자 = null; // 돌아가는 표시를 돌리는 시계
|
|
206
|
+
this.틱 = 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 일하기 시작. 상자 안엣것이 입력칸에서 '돌아가는 중' 으로 바뀐다.
|
|
211
|
+
*
|
|
212
|
+
* 스스로 다시 그리는 시계를 단다. 모델이 30초를 말없이 생각할 때 우리 쪽에서
|
|
213
|
+
* 아무 일도 안 일어나면 표시가 얼어붙는데, 그건 멈춘 것과 화면상 구분이 안 된다.
|
|
214
|
+
*/
|
|
215
|
+
일시작(session, 갈래 = '기본') {
|
|
216
|
+
this.session = session ?? this.session;
|
|
217
|
+
this.일감 = { 갈래, 시작: Date.now(), 곁정보: '' };
|
|
218
|
+
this.틱 = 0;
|
|
219
|
+
this.일그리기();
|
|
220
|
+
if (this.박자) clearInterval(this.박자);
|
|
221
|
+
this.박자 = setInterval(() => { this.틱 += 1; this.일그리기(); }, 돌림주기);
|
|
222
|
+
// 이 시계 때문에 프로그램이 안 끝나면 안 된다. 끝맺음이 걸려 버린다.
|
|
223
|
+
if (typeof this.박자.unref === 'function') this.박자.unref();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 하는 일이 바뀌었다 (파일 읽기 → 코드 고치기 처럼). */
|
|
227
|
+
일바꿈(갈래, 곁정보 = null) {
|
|
228
|
+
if (!this.일감) return;
|
|
229
|
+
// 갈래가 바뀌면 문구를 그 자리에서 갈아 준다. 4초를 기다리면 이미 다음
|
|
230
|
+
// 일로 넘어가 있어서, 화면이 늘 한 걸음 늦은 말을 하게 된다.
|
|
231
|
+
if (갈래 && 갈래 !== this.일감.갈래) { this.일감.갈래 = 갈래; this.틱 = 0; }
|
|
232
|
+
if (곁정보 !== null) this.일감.곁정보 = 곁정보;
|
|
233
|
+
this.일그리기();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
일끝() {
|
|
237
|
+
if (this.박자) { clearInterval(this.박자); this.박자 = null; }
|
|
238
|
+
this.일감 = null;
|
|
239
|
+
this.지우기();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
일그리기() {
|
|
243
|
+
if (!this.일감) return;
|
|
244
|
+
const 지난 = Date.now() - this.일감.시작;
|
|
245
|
+
const 갈래 = 지난 >= 느긋해질때 ? '느긋' : this.일감.갈래;
|
|
246
|
+
const 회차 = Math.floor((this.틱 * 돌림주기) / 문구주기);
|
|
247
|
+
this.그리기(this.session, '', 0, {
|
|
248
|
+
돌림: 돌림틀[this.틱 % 돌림틀.length],
|
|
249
|
+
말: 문구고르기(갈래, 회차),
|
|
250
|
+
곁: [걸린시간(지난), this.일감.곁정보, 'Ctrl+C 중단'].filter(Boolean).join(' · '),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 상자를 지운다. 위쪽 대화는 안 건드린다. */
|
|
255
|
+
지우기() {
|
|
256
|
+
if (!this.그린줄) return;
|
|
257
|
+
// 커서가 있는 자리에서 맨 아랫줄까지 내려간 뒤, 위로 훑어 올라가며 지운다.
|
|
258
|
+
let out = '\r' + (this.커서위 > 0 ? `\x1b[${this.커서위}B` : '') + '\r';
|
|
259
|
+
for (let i = 0; i < this.그린줄; i++) {
|
|
260
|
+
out += 줄끝지움;
|
|
261
|
+
if (i < this.그린줄 - 1) out += '\x1b[1A';
|
|
262
|
+
}
|
|
263
|
+
out += '\r';
|
|
264
|
+
process.stdout.write(out);
|
|
265
|
+
this.그린줄 = 0;
|
|
266
|
+
this.커서위 = 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 상자를 그린다(있으면 지우고 다시).
|
|
271
|
+
*
|
|
272
|
+
* @param {object} session 상태줄을 그리는 데 쓴다
|
|
273
|
+
* @param {string} 글 readline 이 들고 있는 글
|
|
274
|
+
* @param {number} 커서 그 안에서의 커서 위치
|
|
275
|
+
* @param {object} 일감 일하는 중이면 그 상태
|
|
276
|
+
* @param {object[]} 추천 자동완성 후보
|
|
277
|
+
*/
|
|
278
|
+
그리기(session, 글 = '', 커서자리 = null, 일감 = null, 추천 = []) {
|
|
279
|
+
this.session = session ?? this.session;
|
|
280
|
+
this.지우기();
|
|
281
|
+
|
|
282
|
+
const { 줄들, 커서: 자리 } = 프레임({
|
|
283
|
+
글,
|
|
284
|
+
커서: 커서자리,
|
|
285
|
+
폭: process.stdout.columns ?? 80,
|
|
286
|
+
상태: this.session ? statusLine(this.session) : '',
|
|
287
|
+
경고: this.session ? (contextWarning(this.session) ?? '') : '',
|
|
288
|
+
곁말: this.곁말,
|
|
289
|
+
일감,
|
|
290
|
+
추천,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
cursor.hide();
|
|
294
|
+
process.stdout.write(줄들.map((l) => l + 줄끝지움).join('\n') + '\n');
|
|
295
|
+
/*
|
|
296
|
+
* 방금 찍은 마지막 줄 **다음** 줄에 커서가 있다. 거기서 되짚어 올라간다.
|
|
297
|
+
*
|
|
298
|
+
* 오른쪽으로 옮길 때 한 칸이 어긋나기 쉽다. \r 은 커서를 1칸째에 놓고,
|
|
299
|
+
* \x1b[nC 는 거기서 n칸 **더** 간다 — 그래서 1부터 세는 목표 칸으로
|
|
300
|
+
* 가려면 열-1 을 보내야 한다. 이걸 틀리면 친 글자가 한 칸씩 밀려 보이고,
|
|
301
|
+
* 백스페이스가 엉뚱한 자리를 지우는 것처럼 보인다.
|
|
302
|
+
*/
|
|
303
|
+
const 오른쪽 = Math.max(0, 자리.열 - 1);
|
|
304
|
+
process.stdout.write(위로(자리.위 + 1) + '\r' + (오른쪽 ? `\x1b[${오른쪽}C` : ''));
|
|
305
|
+
cursor.show();
|
|
306
|
+
this.그린줄 = 줄들.length;
|
|
307
|
+
this.커서위 = 자리.위;
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/ui/screen.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 왜 이 파일이 생겼나:
|
|
5
5
|
* repl.js 안에 say()·process.stdout.write 가 79군데 흩어져 있었다. 그 상태로는
|
|
6
|
-
*
|
|
6
|
+
* 화면을 하나 더 얹을 수가 없다 — 어디로 무엇이 나가는지 한 군데서 못 잡으니
|
|
7
7
|
* 두 화면이 같은 코드를 나눠 쓰려다 결국 둘 다 어중간해진다.
|
|
8
8
|
*
|
|
9
9
|
* 그래서 '무엇을 그린다' 와 '어떻게 그린다' 를 가른다. repl.js 는 이제
|
|
@@ -11,33 +11,46 @@
|
|
|
11
11
|
* 칸에 담아 다시 그릴지는 여기서 정한다.
|
|
12
12
|
*
|
|
13
13
|
* 두 가지 구현이 있다:
|
|
14
|
-
* 줄화면(LineScreen)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* 줄화면(LineScreen) 지금까지의 그 화면. 위에서 아래로 흘러간다.
|
|
15
|
+
* 파이프·기록·CI·`deel run`·검사가 전부 이것을 읽는다.
|
|
16
|
+
* 상자화면(BoxScreen) ui/inputbox.js. 사람이 터미널 앞에 앉아 있을 때만.
|
|
17
|
+
* **대화는 똑같이 흘려보내고** 맨 아래 입력 상자만
|
|
18
|
+
* 우리가 지우고 다시 그린다.
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* ── 한 번 틀렸던 길 ─────────────────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* 처음에는 터미널을 통째로 빌려(대체 화면) 대화 칸·파일 칸·할 일 칸을 나눠
|
|
23
|
+
* 그렸다. 보기에는 그럴듯했는데 **슬래시 명령이 전부 먹통**이 됐다.
|
|
24
|
+
* commands.js 를 비롯한 여섯 모듈이 화면 객체를 안 거치고 stdout 에 바로 쓰는데,
|
|
25
|
+
* 전체화면은 매번 자기 버퍼에서 화면을 다시 그리므로 그 글이 찍히자마자
|
|
26
|
+
* 덮여 사라졌다. 명령이 안 도는 게 아니라 결과가 안 보이는 것이라 더 나빴다.
|
|
27
|
+
*
|
|
28
|
+
* 고치려면 stdout 에 쓰는 자리를 전부 화면 객체로 꿰야 하는데, 지금 여섯 곳이고
|
|
29
|
+
* 앞으로 늘 것이며, 하나라도 빠뜨리면 같은 증상이 조용히 돌아온다. 그래서
|
|
30
|
+
* 대화는 그냥 흘려보내기로 했다. 그러면 stdout 에 쓰는 모든 코드가 손 안 대고
|
|
31
|
+
* 그대로 맞다. 스크롤·복사·`| tee` 도 그대로다.
|
|
21
32
|
*/
|
|
22
33
|
import { c, say, cursor, box, cols } from './ansi.js';
|
|
23
34
|
import { statusLine, contextWarning } from './status.js';
|
|
24
35
|
import { spin } from './spinner.js';
|
|
25
36
|
|
|
26
37
|
/**
|
|
27
|
-
* 지금 이 자리에서
|
|
38
|
+
* 지금 이 자리에서 입력 상자를 써도 되는가.
|
|
39
|
+
*
|
|
40
|
+
* 하나라도 아니면 줄화면이다 — 애매하면 줄화면이 맞다. 잘못 켜면 사용자
|
|
41
|
+
* 화면이 깨지지만, 잘못 안 켜면 그냥 지금까지의 화면일 뿐이다.
|
|
28
42
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* 잘못 안 켜면 그냥 지금까지의 화면일 뿐이다.
|
|
43
|
+
* 상자는 커서를 위로 올려 가며 자기가 그린 줄을 지운다. 그 앞자리가
|
|
44
|
+
* 터미널이 아니면(파이프·기록) 제어문자가 그대로 글에 섞인다.
|
|
32
45
|
*/
|
|
33
|
-
export function
|
|
46
|
+
export function 상자쓸까({ tui = null } = {}) {
|
|
34
47
|
if (tui === false) return false; // deel --no-tui
|
|
35
48
|
if (!process.stdout.isTTY) return false; // 파이프·기록·CI
|
|
36
49
|
if (!process.stdin.isTTY) return false; // 입력이 파이프로 들어옴 (검사·데모)
|
|
37
50
|
if (process.env.TERM === 'dumb') return false;
|
|
38
51
|
if (process.env.CI) return false;
|
|
39
|
-
//
|
|
40
|
-
if ((process.stdout.columns ?? 0) <
|
|
52
|
+
// 너무 좁으면 상자 안에 남는 자리가 없다. 테두리만 남고 글이 안 보인다.
|
|
53
|
+
if ((process.stdout.columns ?? 0) < 40) return false;
|
|
41
54
|
if (tui === true) return true;
|
|
42
55
|
return true;
|
|
43
56
|
}
|
|
@@ -94,9 +107,9 @@ export class LineScreen {
|
|
|
94
107
|
/**
|
|
95
108
|
* 사람이 치고 있던 입력 줄을 지운다.
|
|
96
109
|
*
|
|
97
|
-
* Shift+Tab 으로
|
|
110
|
+
* Shift+Tab 으로 승인 방식을 돌릴 때처럼, 입력을 기다리는 도중에 화면에
|
|
98
111
|
* 한 줄을 끼워 넣어야 하는 자리가 있다. 안 지우면 `❯ ` 뒤에 새 글이 붙는다.
|
|
99
|
-
*
|
|
112
|
+
* 상자화면에서는 상자를 통째로 걷어낸다(BoxScreen 이 갈아 끼운다).
|
|
100
113
|
*/
|
|
101
114
|
입력지움() { cursor.clearLine(); }
|
|
102
115
|
|
|
@@ -134,13 +147,19 @@ export class LineScreen {
|
|
|
134
147
|
process.stdout.write(` ${c.hcyan('❯')} `);
|
|
135
148
|
}
|
|
136
149
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
150
|
+
/** 사람이 치는 중인 글이 바뀌었다. 줄화면은 readline 이 알아서 되비춘다. */
|
|
151
|
+
입력갱신() { }
|
|
139
152
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
153
|
+
/*
|
|
154
|
+
* 일하는 중 표시. 줄화면은 안 그린다.
|
|
155
|
+
*
|
|
156
|
+
* 줄화면은 흘러가는 화면이라 '한 자리에서 도는 표시' 를 둘 데가 없다.
|
|
157
|
+
* 대신 기다림()·생각() 이 그 자리를 이미 맡고 있다 — 여기서 또 그리면
|
|
158
|
+
* 같은 말이 두 번 나간다. 파이프·기록에서는 돌아가는 표시 자체가 잡음이다.
|
|
159
|
+
*/
|
|
160
|
+
일시작() { }
|
|
161
|
+
일바꿈() { }
|
|
162
|
+
일끝() { }
|
|
144
163
|
|
|
145
164
|
close() {
|
|
146
165
|
this.돌림멈춤();
|
|
@@ -148,22 +167,98 @@ export class LineScreen {
|
|
|
148
167
|
}
|
|
149
168
|
}
|
|
150
169
|
|
|
170
|
+
/**
|
|
171
|
+
* 대화는 그대로 흘려보내고, 맨 아래 입력 상자만 우리가 관리하는 화면.
|
|
172
|
+
*
|
|
173
|
+
* LineScreen 을 그대로 물려받는다 — 대화가 나가는 길이 **한 글자도 다르면
|
|
174
|
+
* 안 되기** 때문이다. 다르면 검사가 읽는 화면과 사람이 보는 화면이 갈라진다.
|
|
175
|
+
* 여기서 갈아 끼우는 것은 입력 자리 세 가지뿐이다.
|
|
176
|
+
*/
|
|
177
|
+
export class BoxScreen extends LineScreen {
|
|
178
|
+
constructor(상자) {
|
|
179
|
+
super();
|
|
180
|
+
this.kind = 'box';
|
|
181
|
+
this.상자 = 상자;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/*
|
|
185
|
+
* 대화 한 줄이 나가기 전에 상자를 걷어낸다. 안 걷으면 새 글이 상자 위에
|
|
186
|
+
* 겹쳐 찍히고, 지울 때 대화까지 같이 지워진다.
|
|
187
|
+
*
|
|
188
|
+
* 일하는 중이면 찍은 **뒤에 다시 세운다.** 이게 없으면 첫 도구 결과가
|
|
189
|
+
* 찍히는 순간 상자가 사라지고, 그 뒤로는 일이 끝날 때까지 화면 아래가
|
|
190
|
+
* 텅 빈 채로 남는다 — 정확히 그게 "밋밋해 보인다" 는 상태다.
|
|
191
|
+
*/
|
|
192
|
+
줄(s = '') { this.상자.지우기(); super.줄(s); this.상자.일그리기(); }
|
|
193
|
+
|
|
194
|
+
/*
|
|
195
|
+
* 스트리밍으로 이어 붙이는 글은 다시 안 세운다.
|
|
196
|
+
*
|
|
197
|
+
* 토막마다 상자를 세웠다 걷으면, 글자 몇 개마다 상자가 깜빡이고 커서가
|
|
198
|
+
* 위아래로 튄다. 답이 흘러나오는 동안에는 그 자체가 살아 있다는 신호라
|
|
199
|
+
* 상자가 없어도 밋밋하지 않다. 답이 끝나면 repl.js 가 다시 세운다.
|
|
200
|
+
*/
|
|
201
|
+
붙임(s) { this.상자.지우기(); super.붙임(s); }
|
|
202
|
+
|
|
203
|
+
/** 일하기 시작·바뀜·끝. 상자 안엣것만 바뀐다 (ui/working.js 의 문구). */
|
|
204
|
+
일시작(session, 갈래) { this.임시지움(); this.상자.일시작(session, 갈래); }
|
|
205
|
+
일바꿈(갈래, 곁정보) { this.상자.일바꿈(갈래, 곁정보); }
|
|
206
|
+
일끝() { this.상자.일끝(); }
|
|
207
|
+
|
|
208
|
+
/*
|
|
209
|
+
* 줄화면에서 '생각 중…' 을 한 줄로 흘리던 자리.
|
|
210
|
+
*
|
|
211
|
+
* 상자를 쓸 때는 그 말이 상자 안으로 들어가야 한다. 안 그러면 같은 말이
|
|
212
|
+
* 두 군데(흘러가는 줄 + 상자)에 동시에 뜬다.
|
|
213
|
+
*/
|
|
214
|
+
기다림() { }
|
|
215
|
+
생각(s) {
|
|
216
|
+
// '생각 중… 1,234자' 에서 숫자만 떼어 곁정보로 붙인다. 진짜로 늘어나는
|
|
217
|
+
// 숫자가 하나 있어야 '살아 있다' 가 표시가 아니라 사실이 된다.
|
|
218
|
+
const m = /([\d,]+)자/.exec(String(s ?? ''));
|
|
219
|
+
this.상자.일바꿈('생각', m ? `생각 ${m[1]}자` : null);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 입력 자리 = 상태줄 + 상자. 커서는 상자 안에 놓인다.
|
|
224
|
+
*
|
|
225
|
+
* 치던 글을 받는다. 입력 도중에 한 줄을 끼워 넣고 다시 그리는 자리
|
|
226
|
+
* (Shift+Tab·Ctrl+O·Ctrl+C)에서, 빈 칸을 그렸다가 곧바로 채우면 한 번
|
|
227
|
+
* 깜빡인다. 처음부터 채워서 그린다 — 한 번만 그리면 안 깜빡인다.
|
|
228
|
+
*/
|
|
229
|
+
입력자리(session, 글 = '', 커서 = 0, 추천 = []) {
|
|
230
|
+
this.임시지움();
|
|
231
|
+
say('');
|
|
232
|
+
this.상자.그리기(session, 글, 커서, null, 추천);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 사람이 치는 중인 글이 바뀌었다 (repl.js 의 keypress 가 부른다). */
|
|
236
|
+
입력갱신(session, 글, 커서, 추천 = []) { this.상자.그리기(session, 글, 커서, null, 추천); }
|
|
237
|
+
|
|
238
|
+
입력지움() { this.상자.지우기(); }
|
|
239
|
+
|
|
240
|
+
close() {
|
|
241
|
+
this.상자.지우기();
|
|
242
|
+
super.close();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
151
246
|
/**
|
|
152
247
|
* 상황에 맞는 화면을 하나 고른다.
|
|
153
248
|
*
|
|
154
|
-
*
|
|
249
|
+
* 상자 쪽은 필요할 때만 읽어 들인다. 줄화면으로 돌 때 inputbox.js 를
|
|
155
250
|
* 파싱조차 안 하게 하려는 것이다 — `deel run` 이 조금이라도 빨리 시작해야 한다.
|
|
156
251
|
*/
|
|
157
252
|
export async function 화면고르기(opts = {}) {
|
|
158
|
-
if (
|
|
253
|
+
if (!상자쓸까(opts)) return new LineScreen();
|
|
159
254
|
try {
|
|
160
|
-
const {
|
|
161
|
-
return new
|
|
255
|
+
const { InputBox } = await import('./inputbox.js');
|
|
256
|
+
return new BoxScreen(new InputBox());
|
|
162
257
|
} catch (e) {
|
|
163
|
-
//
|
|
258
|
+
// 상자를 못 세우면 조용히 줄화면으로 간다. 화면 하나 때문에
|
|
164
259
|
// 프로그램이 안 뜨는 일은 없어야 한다.
|
|
165
260
|
const s = new LineScreen();
|
|
166
|
-
s.줄(` ${c.gray(
|
|
261
|
+
s.줄(` ${c.gray(`입력 상자를 못 켰습니다 — 줄 화면으로 갑니다. (${e.message})`)}`);
|
|
167
262
|
return s;
|
|
168
263
|
}
|
|
169
264
|
}
|
package/src/ui/status.js
CHANGED
|
@@ -6,6 +6,7 @@ import { c, gauge, width, clip, cols, mark } from './ansi.js';
|
|
|
6
6
|
import { PROFILES } from '../agent/effort.js';
|
|
7
7
|
import { get as workMode, canWrite } from '../agent/modes.js';
|
|
8
8
|
import { isLocalHost, isOffline } from '../safety/network.js';
|
|
9
|
+
import { 표시 as 승인표시, 고르기 as 승인고르기 } from './approve.js';
|
|
9
10
|
|
|
10
11
|
// 한 조각씩 따로 만든다. 좁은 화면에서는 뒤에서부터 떨군다.
|
|
11
12
|
// 순서 = 중요도 순. 앞쪽이 끝까지 살아남는다.
|
|
@@ -14,7 +15,20 @@ export const SEGMENTS = {
|
|
|
14
15
|
// 이 조각 하나가 상태줄을 통째로 넘겨 줄이 접힌다(모델 이름은 이미 자르고 있었다).
|
|
15
16
|
dir: { desc: '작업 폴더', make: (s) => c.gray(clip(base(s.root), 20)) },
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
/*
|
|
19
|
+
* 모델 이름.
|
|
20
|
+
*
|
|
21
|
+
* 좁을 때 더 줄인다. 사내 게이트웨이 이름은 `databricks-gpt-5-6-luna` 처럼
|
|
22
|
+
* 스물세 칸을 그냥 먹는데, 자리가 없을 때 이것 하나 때문에 **셋째 덩이가
|
|
23
|
+
* 통째로 떨어져 나간다** — 승인 방식이 화면에서 사라진다는 뜻이다.
|
|
24
|
+
* 무엇으로 돌고 있는지는 이미 알고 있다. 내 파일이 안 물어보고 바뀌는지는
|
|
25
|
+
* 지금 봐야 안다. 둘 중 하나를 접어야 하면 이쪽이다.
|
|
26
|
+
*/
|
|
27
|
+
model: {
|
|
28
|
+
desc: '모델 이름',
|
|
29
|
+
make: (s) => c.hcyan(clip(s.conn.model, 24)),
|
|
30
|
+
short: (s) => c.hcyan(clip(s.conn.model, 14)),
|
|
31
|
+
},
|
|
18
32
|
|
|
19
33
|
ctx: {
|
|
20
34
|
desc: '컨텍스트 사용량',
|
|
@@ -54,12 +68,17 @@ export const SEGMENTS = {
|
|
|
54
68
|
short: (s) => `${c.gray('◇')} ${c.white(s.think)}`,
|
|
55
69
|
},
|
|
56
70
|
|
|
71
|
+
/*
|
|
72
|
+
* 승인 방식 — 내 파일이 물어보고 바뀌는가, 안 물어보고 바뀌는가.
|
|
73
|
+
*
|
|
74
|
+
* 전에는 `auto` 라는 영문 한 낱말이었다. 옆에 `종합`·`medium·절약` 이 나란히
|
|
75
|
+
* 있으니 셋 다 그냥 '모드' 로 보였고, 그중 하나가 **묻지 않고 파일을 고친다**
|
|
76
|
+
* 는 뜻이라는 것은 화면 어디에도 없었다. 이건 꾸미기가 아니라 안전 표시다.
|
|
77
|
+
*/
|
|
57
78
|
mode: {
|
|
58
|
-
desc: '
|
|
59
|
-
make: (s) =>
|
|
60
|
-
|
|
61
|
-
return tone(s.mode);
|
|
62
|
-
},
|
|
79
|
+
desc: '승인 방식',
|
|
80
|
+
make: (s) => 승인표시(s.mode),
|
|
81
|
+
short: (s) => 승인표시(s.mode, { 짧게: true }),
|
|
63
82
|
},
|
|
64
83
|
|
|
65
84
|
tok: {
|
|
@@ -217,6 +236,10 @@ export function headerLines(session, found) {
|
|
|
217
236
|
`${c.gray('보냄')} ${목적지} ${c.gray('← 여기 말고는 어디로도 안 갑니다')}`,
|
|
218
237
|
`${c.gray('연결')} ${능력}`,
|
|
219
238
|
`${c.gray('폴더')} ${c.white(clip(session.root, 56))}`,
|
|
239
|
+
// 켤 때 한 번은 사람 말로 알려 준다. 상태줄의 `⏵⏵ 자동 승인` 이 무슨
|
|
240
|
+
// 뜻인지 여기서 한 번 읽고 나면 그 다음부터는 글자만 봐도 안다.
|
|
241
|
+
`${c.gray('승인')} ${승인표시(session.mode)} ${c.gray('— ' + 승인고르기(session.mode).한줄)}`,
|
|
242
|
+
`${c.gray(' Shift+Tab')} ${c.gray('으로 바꿉니다 · ')}${c.gray('Tab 은 치던 / 명령을 채웁니다')}`,
|
|
220
243
|
];
|
|
221
244
|
if (found.skills.length || found.commands.length) {
|
|
222
245
|
lines.push(`${c.gray('이 PC')} ${c.white(`스킬 ${found.skills.length}`)}${c.gray(' · ')}${c.white(`명령 ${found.commands.length}`)}${c.gray(' · ')}${c.white(`플러그인 ${found.plugins.length}`)}`);
|