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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 일하는 중에 띄울 문구.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 아무 말이나 안 쓰나 ──────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 재미 때문에 넣는 것이 아니다. 로컬 모델은 느리다 — 7B 라도 한 걸음에 수십
|
|
7
|
+
* 초가 걸리고, 그 동안 화면이 조용하면 사람은 **멈춘 줄 안다.** 실제로 Ctrl+C
|
|
8
|
+
* 를 눌러 버린다. 그러면 다 되어 가던 일이 날아간다.
|
|
9
|
+
*
|
|
10
|
+
* 그래서 문구는 **지금 진짜로 하는 일**을 따라간다. 파일을 읽는 중이면 읽는다고
|
|
11
|
+
* 하고, 명령을 돌리는 중이면 돌린다고 한다. 아무 말이나 돌려 대면 두 번째부터
|
|
12
|
+
* 아무도 안 읽고, 그때부터는 화면이 조용한 것과 같아진다.
|
|
13
|
+
*
|
|
14
|
+
* 한 갈래 안에서 여러 개를 돌리는 것은 다른 이유다. 같은 글자가 30초 동안
|
|
15
|
+
* 그대로 있으면 그것도 멈춘 것처럼 보인다. 돌아가는 표시만으로는 부족하다 —
|
|
16
|
+
* 터미널 렌더링이 밀리면 그것도 안 움직이는 것처럼 보이기 때문이다.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// 갈래별 문구. 앞엣것부터 차례로 돌린다 — 아무거나 뽑으면 눈에 거슬린다.
|
|
20
|
+
export const 문구 = {
|
|
21
|
+
생각: [
|
|
22
|
+
'머리 굴리는 중',
|
|
23
|
+
'어떻게 할지 궁리하는 중',
|
|
24
|
+
'수 읽는 중',
|
|
25
|
+
'따져 보는 중',
|
|
26
|
+
'생각 정리하는 중',
|
|
27
|
+
],
|
|
28
|
+
읽기: [
|
|
29
|
+
'파일 들여다보는 중',
|
|
30
|
+
'코드 훑는 중',
|
|
31
|
+
'어디 있나 뒤지는 중',
|
|
32
|
+
'단서 찾는 중',
|
|
33
|
+
],
|
|
34
|
+
쓰기: [
|
|
35
|
+
'코드 짜는 중',
|
|
36
|
+
'고쳐 넣는 중',
|
|
37
|
+
'손보는 중',
|
|
38
|
+
'한 줄씩 옮기는 중',
|
|
39
|
+
],
|
|
40
|
+
명령: [
|
|
41
|
+
'명령 돌리는 중',
|
|
42
|
+
'터미널 두드리는 중',
|
|
43
|
+
'결과 기다리는 중',
|
|
44
|
+
],
|
|
45
|
+
웹: [
|
|
46
|
+
'문서 찾아보는 중',
|
|
47
|
+
'읽어 오는 중',
|
|
48
|
+
],
|
|
49
|
+
답: [
|
|
50
|
+
'답 쓰는 중',
|
|
51
|
+
'정리해서 말하는 중',
|
|
52
|
+
],
|
|
53
|
+
접기: [
|
|
54
|
+
'지난 대화 접는 중',
|
|
55
|
+
'자리 만드는 중',
|
|
56
|
+
],
|
|
57
|
+
// 무슨 일인지 아직 모를 때. 첫 걸음과 도구 사이의 짧은 틈이다.
|
|
58
|
+
기본: [
|
|
59
|
+
'열심히 일하는 중',
|
|
60
|
+
'바쁘게 움직이는 중',
|
|
61
|
+
'뭔가 하는 중',
|
|
62
|
+
],
|
|
63
|
+
/*
|
|
64
|
+
* 오래 걸릴 때.
|
|
65
|
+
*
|
|
66
|
+
* 45초가 넘어가면 갈래를 이걸로 바꾼다. '아직 하는 중' 이라고 말해 주는 것과
|
|
67
|
+
* 아까 하던 말을 계속 돌리는 것은 다르다 — 후자는 붙박이처럼 보인다.
|
|
68
|
+
* 여기서 중요한 것은 재치가 아니라 **아직 살아 있다는 신호**다.
|
|
69
|
+
*/
|
|
70
|
+
느긋: [
|
|
71
|
+
'아직 하는 중',
|
|
72
|
+
'조금만 더',
|
|
73
|
+
'생각보다 오래 걸리는 중',
|
|
74
|
+
'그래도 하는 중',
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// 도구 이름 → 갈래. 없는 이름은 기본으로 떨어진다.
|
|
79
|
+
const 도구갈래 = {
|
|
80
|
+
Read: '읽기', Glob: '읽기', Grep: '읽기', Skill: '읽기', Recall: '읽기',
|
|
81
|
+
Write: '쓰기', Edit: '쓰기', Append: '쓰기', Remember: '쓰기', TodoWrite: '쓰기',
|
|
82
|
+
Bash: '명령',
|
|
83
|
+
WebFetch: '웹',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** 도구 이름으로 갈래를 고른다. 밖에서 붙인 도구(mcp__…)는 무엇을 할지 모른다. */
|
|
87
|
+
export function 갈래고르기(도구이름) {
|
|
88
|
+
if (!도구이름) return '기본';
|
|
89
|
+
if (String(도구이름).startsWith('mcp__')) return '기본';
|
|
90
|
+
return 도구갈래[도구이름] ?? '기본';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 갈래와 회차로 문구 하나. 회차가 늘면 다음 것으로 넘어간다.
|
|
95
|
+
*
|
|
96
|
+
* 시각이 아니라 회차를 받는 이유: 이래야 검사가 잴 수 있다. 시각을 안에서
|
|
97
|
+
* 읽으면 '4초 뒤에 바뀌는가' 를 재려고 검사가 진짜로 4초를 기다려야 한다.
|
|
98
|
+
*/
|
|
99
|
+
export function 문구고르기(갈래, 회차 = 0) {
|
|
100
|
+
const 목록 = 문구[갈래] ?? 문구.기본;
|
|
101
|
+
const i = ((Math.trunc(회차) % 목록.length) + 목록.length) % 목록.length;
|
|
102
|
+
return 목록[i];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 돌아가는 표시. 점자 여덟 칸짜리는 폭이 한 칸이라 테두리를 안 밀어낸다.
|
|
106
|
+
export const 돌림틀 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
107
|
+
|
|
108
|
+
/** 걸린 시간을 짧게. 1분이 넘으면 분까지 적는다 — 90초보다 1분 30초가 읽힌다. */
|
|
109
|
+
export function 걸린시간(ms) {
|
|
110
|
+
const 초 = Math.max(0, Math.floor(ms / 1000));
|
|
111
|
+
if (초 < 60) return `${초}초`;
|
|
112
|
+
return `${Math.floor(초 / 60)}분 ${String(초 % 60).padStart(2, '0')}초`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 이 시간이 넘으면 '느긋' 갈래로 바꾼다.
|
|
116
|
+
export const 느긋해질때 = 45000;
|
|
117
|
+
// 문구를 바꾸는 주기.
|
|
118
|
+
export const 문구주기 = 4000;
|
package/src/ui/wrap.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 색을 유지하면서 폭에 맞춰 접기.
|
|
2
|
+
//
|
|
3
|
+
// 전에는 전체화면(tui.js)에만 쓰던 것이라 거기 있었다. 전체화면을 걷어내면서
|
|
4
|
+
// 여기로 옮긴다 — 입력 상자가 긴 입력을 접을 때 같은 것이 필요하다.
|
|
5
|
+
import { width, clip } from './ansi.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 색을 유지하면서 폭에 맞춰 접는다.
|
|
9
|
+
*
|
|
10
|
+
* clip 은 잘라 버리지만 사람이 친 글은 잘리면 안 된다 — 뒷부분이 통째로
|
|
11
|
+
* 사라진다. 그래서 접는다. 색 코드는 폭이 0 이므로 세지 않고, 줄을 넘길 때
|
|
12
|
+
* 마지막으로 켜져 있던 색을 다음 줄 앞에 다시 켜 준다. 안 그러면 접힌 줄부터
|
|
13
|
+
* 색이 풀려 화면이 얼룩덜룩해진다.
|
|
14
|
+
*/
|
|
15
|
+
export function 접어쓰기(글, 폭) {
|
|
16
|
+
const s = String(글 ?? '');
|
|
17
|
+
if (폭 < 2) return [s];
|
|
18
|
+
if (width(s) <= 폭) return [s];
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
* 접힌 줄에 앞 들여쓰기를 물려 준다.
|
|
22
|
+
*
|
|
23
|
+
* 안 물려 주면 ` ▌ 모델의 답…` 이 접히는 순간 다음 줄이 왼쪽 끝에서
|
|
24
|
+
* 시작한다. 화면에서 세로줄이 끊겨 보이고, 도구 결과의 ` └ …` 도
|
|
25
|
+
* 두 번째 줄부터 갑자기 튀어나온다 — 무엇에 딸린 글인지 알 수 없게 된다.
|
|
26
|
+
* 들여쓴 만큼은 글자 자리에서 빼야 하므로 접는 폭도 같이 줄인다.
|
|
27
|
+
*/
|
|
28
|
+
const 들여 = (/^ */.exec(s)[0] ?? '').slice(0, Math.max(0, 폭 - 8));
|
|
29
|
+
const 이어폭 = Math.max(2, 폭 - 들여.length);
|
|
30
|
+
|
|
31
|
+
const 줄들 = [];
|
|
32
|
+
let 지금 = '';
|
|
33
|
+
let w = 0;
|
|
34
|
+
let 색 = ''; // 지금 켜져 있는 SGR
|
|
35
|
+
for (let i = 0; i < s.length;) {
|
|
36
|
+
// 색 코드는 통째로 옮긴다. 폭에는 안 센다.
|
|
37
|
+
if (s[i] === '\x1b') {
|
|
38
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
39
|
+
if (m) {
|
|
40
|
+
지금 += m[0];
|
|
41
|
+
색 = /\x1b\[0?m/.test(m[0]) ? '' : 색 + m[0];
|
|
42
|
+
i += m[0].length;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const ch = s[i];
|
|
47
|
+
const cw = width(ch);
|
|
48
|
+
// 첫 줄은 원래 폭, 접힌 줄부터는 들여쓴 만큼 좁아진다.
|
|
49
|
+
const 한도 = 줄들.length === 0 ? 폭 : 이어폭 + 들여.length;
|
|
50
|
+
if (w + cw > 한도) {
|
|
51
|
+
줄들.push(지금 + (색 ? '\x1b[0m' : ''));
|
|
52
|
+
지금 = 들여 + 색;
|
|
53
|
+
w = 들여.length;
|
|
54
|
+
}
|
|
55
|
+
지금 += ch;
|
|
56
|
+
w += cw;
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
if (지금.trim() !== '' || 줄들.length === 0) 줄들.push(지금 + (색 ? '\x1b[0m' : ''));
|
|
60
|
+
return 줄들;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 폭에 맞춰 오른쪽을 공백으로 채운다. 안 채우면 앞 그림이 남는다. */
|
|
64
|
+
export function 채움(글, 폭) {
|
|
65
|
+
const w = width(글);
|
|
66
|
+
if (w > 폭) return clip(글, 폭);
|
|
67
|
+
return 글 + ' '.repeat(폭 - w);
|
|
68
|
+
}
|
package/src/ui/tui.js
DELETED
|
@@ -1,419 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 전체화면 화면.
|
|
3
|
-
*
|
|
4
|
-
* 줄화면(ui/screen.js 의 LineScreen)이 위에서 아래로 흘려보낸다면, 여기는
|
|
5
|
-
* 터미널을 칸으로 나눠 놓고 매번 다시 그린다.
|
|
6
|
-
*
|
|
7
|
-
* ┌─ 대화 ──────────────────────────┬─ 바뀐 파일 ───────┐
|
|
8
|
-
* │ ▌ 로그 호출을 통일했습니다 │ src/runner.js +3 │
|
|
9
|
-
* │ ⏺ Edit(runner.js) +3 -1 ├─ 할 일 ───────────┤
|
|
10
|
-
* │ │ ☑ 로그 형식 통일 │
|
|
11
|
-
* └─────────────────────────────────┴───────────────────┘
|
|
12
|
-
* ▏폴더 · 모델 ▏ ▰▰▱▱▱ 22% ▏ ◎ 종합 · auto
|
|
13
|
-
* ╭───────────────────────────────────────────────────╮
|
|
14
|
-
* │ ❯ 집계 함수도 줄여줘 │
|
|
15
|
-
* ╰───────────────────────────────────────────────────╯
|
|
16
|
-
*
|
|
17
|
-
* ── 지키기로 한 선 ──────────────────────────────────────────────────────
|
|
18
|
-
*
|
|
19
|
-
* 1) **입력은 readline 이 계속 맡는다.** 키를 직접 받아 줄 편집을 다시 짜면
|
|
20
|
-
* 한글 조합·붙여넣기·위아래 이력·백스페이스를 전부 우리가 떠안게 된다.
|
|
21
|
-
* 그 대신 그릴 때마다 커서를 입력 칸 안에 놓아 주고, **사람이 치는 동안에는
|
|
22
|
-
* 다시 그리지 않는다**(그리면 치던 글자가 지워진다).
|
|
23
|
-
*
|
|
24
|
-
* 2) **나갈 때 대화를 되살려 놓는다.** 전체화면은 vim 처럼 딴 화면을 쓰다가
|
|
25
|
-
* 끝나면 원래 화면으로 돌아간다. 그대로 두면 방금 나눈 대화가 통째로
|
|
26
|
-
* 사라진다 — 스크롤을 올려도 없다. 그래서 원래 화면으로 돌아간 **뒤에**
|
|
27
|
-
* 대화를 다시 찍어 준다. 터미널 스크롤백에 남아야 나중에 찾아본다.
|
|
28
|
-
*
|
|
29
|
-
* 3) **못 그릴 상황이면 안 켠다.** 창이 작거나 파이프면 screen.js 가
|
|
30
|
-
* 아예 이 파일을 안 부른다.
|
|
31
|
-
*/
|
|
32
|
-
import { c, width, clip, cursor } from './ansi.js';
|
|
33
|
-
import { statusLine, contextWarning } from './status.js';
|
|
34
|
-
|
|
35
|
-
const ALT_ON = '\x1b[?1049h'; // 딴 화면으로
|
|
36
|
-
const ALT_OFF = '\x1b[?1049l'; // 원래 화면으로
|
|
37
|
-
const 집 = '\x1b[H'; // 커서를 왼쪽 맨 위로
|
|
38
|
-
const 화면지움 = '\x1b[2J';
|
|
39
|
-
const 줄끝지움 = '\x1b[K';
|
|
40
|
-
const 돌림틀 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
41
|
-
|
|
42
|
-
// 스크롤백에 되살릴 대화의 최대 줄 수. 다 남기면 몇만 줄이 쏟아진다.
|
|
43
|
-
const 되살릴줄 = 600;
|
|
44
|
-
// 대화 칸이 들고 있을 줄 수. 이보다 오래된 것은 버린다(세션 기록에는 남는다).
|
|
45
|
-
const 버퍼최대 = 4000;
|
|
46
|
-
const 오른쪽폭 = 26;
|
|
47
|
-
|
|
48
|
-
const 자리 = (행, 열 = 1) => `\x1b[${행};${열}H`;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* 색을 유지하면서 폭에 맞춰 접는다.
|
|
52
|
-
*
|
|
53
|
-
* clip 은 잘라 버리지만 대화는 잘리면 안 된다 — 모델의 답 뒷부분이 통째로
|
|
54
|
-
* 사라진다. 그래서 접는다. 색 코드는 폭이 0 이므로 세지 않고, 줄을 넘길 때
|
|
55
|
-
* 마지막으로 켜져 있던 색을 다음 줄 앞에 다시 켜 준다. 안 그러면 접힌 줄부터
|
|
56
|
-
* 색이 풀려 화면이 얼룩덜룩해진다.
|
|
57
|
-
*/
|
|
58
|
-
export function 접어쓰기(글, 폭) {
|
|
59
|
-
const s = String(글 ?? '');
|
|
60
|
-
if (폭 < 2) return [s];
|
|
61
|
-
if (width(s) <= 폭) return [s];
|
|
62
|
-
|
|
63
|
-
/*
|
|
64
|
-
* 접힌 줄에 앞 들여쓰기를 물려 준다.
|
|
65
|
-
*
|
|
66
|
-
* 안 물려 주면 ` ▌ 모델의 답…` 이 접히는 순간 다음 줄이 왼쪽 끝에서
|
|
67
|
-
* 시작한다. 화면에서 세로줄이 끊겨 보이고, 도구 결과의 ` └ …` 도
|
|
68
|
-
* 두 번째 줄부터 갑자기 튀어나온다 — 무엇에 딸린 글인지 알 수 없게 된다.
|
|
69
|
-
* 들여쓴 만큼은 글자 자리에서 빼야 하므로 접는 폭도 같이 줄인다.
|
|
70
|
-
*/
|
|
71
|
-
const 들여 = (/^ */.exec(s)[0] ?? '').slice(0, Math.max(0, 폭 - 8));
|
|
72
|
-
const 이어폭 = Math.max(2, 폭 - 들여.length);
|
|
73
|
-
|
|
74
|
-
const 줄들 = [];
|
|
75
|
-
let 지금 = '';
|
|
76
|
-
let w = 0;
|
|
77
|
-
let 색 = ''; // 지금 켜져 있는 SGR
|
|
78
|
-
for (let i = 0; i < s.length;) {
|
|
79
|
-
// 색 코드는 통째로 옮긴다. 폭에는 안 센다.
|
|
80
|
-
if (s[i] === '\x1b') {
|
|
81
|
-
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
82
|
-
if (m) {
|
|
83
|
-
지금 += m[0];
|
|
84
|
-
색 = /\x1b\[0?m/.test(m[0]) ? '' : 색 + m[0];
|
|
85
|
-
i += m[0].length;
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
const ch = s[i];
|
|
90
|
-
const cw = width(ch);
|
|
91
|
-
// 첫 줄은 원래 폭, 접힌 줄부터는 들여쓴 만큼 좁아진다.
|
|
92
|
-
const 한도 = 줄들.length === 0 ? 폭 : 이어폭 + 들여.length;
|
|
93
|
-
if (w + cw > 한도) {
|
|
94
|
-
줄들.push(지금 + (색 ? '\x1b[0m' : ''));
|
|
95
|
-
지금 = 들여 + 색;
|
|
96
|
-
w = 들여.length;
|
|
97
|
-
}
|
|
98
|
-
지금 += ch;
|
|
99
|
-
w += cw;
|
|
100
|
-
i += 1;
|
|
101
|
-
}
|
|
102
|
-
if (지금.trim() !== '' || 줄들.length === 0) 줄들.push(지금 + (색 ? '\x1b[0m' : ''));
|
|
103
|
-
return 줄들;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** 폭에 맞춰 오른쪽을 공백으로 채운다. 안 채우면 앞 그림이 남는다. */
|
|
107
|
-
function 채움(글, 폭) {
|
|
108
|
-
const w = width(글);
|
|
109
|
-
if (w > 폭) return clip(글, 폭);
|
|
110
|
-
return 글 + ' '.repeat(폭 - w);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export class TuiScreen {
|
|
114
|
-
constructor() {
|
|
115
|
-
this.kind = 'tui';
|
|
116
|
-
this.줄버퍼 = []; // 확정된 줄 (색 포함, 안 접힌 원본)
|
|
117
|
-
this.열린줄 = null; // 아직 안 맺은 줄 — 스트리밍으로 붙는 중
|
|
118
|
-
this.임시글 = null; // '생각 중…' 처럼 곧 지워질 한 줄
|
|
119
|
-
this.상태 = ''; // 상태줄
|
|
120
|
-
this.경고 = null; // 컨텍스트 경고
|
|
121
|
-
this.파일 = []; // 오른쪽 위
|
|
122
|
-
this.할일 = []; // 오른쪽 아래
|
|
123
|
-
this.입력중 = false; // 사람이 치는 중이면 다시 그리지 않는다
|
|
124
|
-
this.돌림타이머 = null;
|
|
125
|
-
this.돌림글 = '';
|
|
126
|
-
this.돌림칸 = 0;
|
|
127
|
-
this.닫힘 = false;
|
|
128
|
-
|
|
129
|
-
this.행 = process.stdout.rows ?? 24;
|
|
130
|
-
this.열 = process.stdout.columns ?? 80;
|
|
131
|
-
|
|
132
|
-
process.stdout.write(ALT_ON + 화면지움 + 집);
|
|
133
|
-
cursor.hide();
|
|
134
|
-
|
|
135
|
-
// 창 크기가 바뀌면 칸을 다시 잰다. 안 하면 테두리가 어긋난 채로 남는다.
|
|
136
|
-
this.크기바뀜 = () => {
|
|
137
|
-
this.행 = process.stdout.rows ?? 24;
|
|
138
|
-
this.열 = process.stdout.columns ?? 80;
|
|
139
|
-
this.다시그림({ 입력중이어도: true });
|
|
140
|
-
};
|
|
141
|
-
process.stdout.on('resize', this.크기바뀜);
|
|
142
|
-
|
|
143
|
-
/*
|
|
144
|
-
* 어떻게 끝나든 터미널을 되돌려 놓는다.
|
|
145
|
-
*
|
|
146
|
-
* 이게 없으면 프로그램이 터지거나 kill 당했을 때 사용자 터미널이 **딴 화면에
|
|
147
|
-
* 갇힌 채** 남는다. 커서도 숨겨진 그대로다. 그 상태에서는 프롬프트도 안 보이고
|
|
148
|
-
* 친 글자도 안 보여서, 터미널을 닫는 것 말고는 방법이 없다 —
|
|
149
|
-
* 우리 잘못으로 남의 창을 못 쓰게 만드는 셈이다.
|
|
150
|
-
*
|
|
151
|
-
* 정상 종료(close)에서도 한 번 더 불리지만 닫힘 표시로 걸러진다.
|
|
152
|
-
*/
|
|
153
|
-
this.되돌리기 = () => {
|
|
154
|
-
if (this.닫힘) return;
|
|
155
|
-
this.닫힘 = true;
|
|
156
|
-
clearInterval(this.돌림타이머);
|
|
157
|
-
try { cursor.show(); process.stdout.write(ALT_OFF); } catch { /* 나가는 길이라 더 할 것이 없다 */ }
|
|
158
|
-
};
|
|
159
|
-
process.once('exit', this.되돌리기);
|
|
160
|
-
// 예상 못 한 오류로 죽을 때도 화면부터 되돌리고 오류를 원래 화면에 찍는다.
|
|
161
|
-
// 딴 화면에 찍으면 그 오류가 화면과 함께 사라져 원인을 못 본다.
|
|
162
|
-
this.죽을때 = (e) => {
|
|
163
|
-
this.되돌리기();
|
|
164
|
-
process.off('uncaughtException', this.죽을때);
|
|
165
|
-
process.off('unhandledRejection', this.죽을때);
|
|
166
|
-
throw e;
|
|
167
|
-
};
|
|
168
|
-
process.on('uncaughtException', this.죽을때);
|
|
169
|
-
process.on('unhandledRejection', this.죽을때);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── 흘러가는 글 ────────────────────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
줄(s = '') {
|
|
175
|
-
this.열린줄맺기();
|
|
176
|
-
this.임시글 = null;
|
|
177
|
-
this.줄버퍼.push(s);
|
|
178
|
-
this.줄이기();
|
|
179
|
-
this.다시그림();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
붙임(s) {
|
|
183
|
-
this.임시글 = null;
|
|
184
|
-
const 조각 = String(s ?? '');
|
|
185
|
-
const 부분 = 조각.split('\n');
|
|
186
|
-
if (this.열린줄 === null) this.열린줄 = '';
|
|
187
|
-
this.열린줄 += 부분[0];
|
|
188
|
-
for (let i = 1; i < 부분.length; i++) {
|
|
189
|
-
this.줄버퍼.push(this.열린줄);
|
|
190
|
-
this.열린줄 = 부분[i];
|
|
191
|
-
}
|
|
192
|
-
this.줄이기();
|
|
193
|
-
this.다시그림();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
열린줄맺기() {
|
|
197
|
-
if (this.열린줄 !== null) {
|
|
198
|
-
this.줄버퍼.push(this.열린줄);
|
|
199
|
-
this.열린줄 = null;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
줄이기() {
|
|
204
|
-
if (this.줄버퍼.length > 버퍼최대) this.줄버퍼.splice(0, this.줄버퍼.length - 버퍼최대);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ── 곧 지워질 표시 ─────────────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
기다림(s) { this.임시글 = s; this.다시그림(); }
|
|
210
|
-
|
|
211
|
-
생각(s) { this.임시글 = s; this.다시그림(); }
|
|
212
|
-
|
|
213
|
-
임시지움() { if (this.임시글 !== null) { this.임시글 = null; this.다시그림(); } }
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* 오래 걸리는 일에 돌아가는 표시.
|
|
217
|
-
*
|
|
218
|
-
* 줄화면은 한 줄을 \r 로 덮어쓰지만 여기서는 임시 자리를 갱신한다.
|
|
219
|
-
* 사람이 입력을 기다리는 중이면 애초에 이게 안 돌아간다.
|
|
220
|
-
*/
|
|
221
|
-
돌리기(label) {
|
|
222
|
-
this.돌림글 = label;
|
|
223
|
-
this.돌림칸 = 0;
|
|
224
|
-
clearInterval(this.돌림타이머);
|
|
225
|
-
const 한칸 = () => {
|
|
226
|
-
this.임시글 = `${c.cyan(돌림틀[this.돌림칸++ % 돌림틀.length])} ${c.gray(this.돌림글)}`;
|
|
227
|
-
this.다시그림();
|
|
228
|
-
};
|
|
229
|
-
한칸();
|
|
230
|
-
this.돌림타이머 = setInterval(한칸, 80);
|
|
231
|
-
// 타이머 하나 때문에 프로그램이 안 끝나면 안 된다.
|
|
232
|
-
if (this.돌림타이머.unref) this.돌림타이머.unref();
|
|
233
|
-
return { stop: (f) => this.돌림멈춤(f) };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
돌림멈춤(finalLine) {
|
|
237
|
-
clearInterval(this.돌림타이머);
|
|
238
|
-
this.돌림타이머 = null;
|
|
239
|
-
this.임시글 = null;
|
|
240
|
-
if (finalLine) this.줄버퍼.push(finalLine);
|
|
241
|
-
this.다시그림();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// ── 자리를 차지하는 것 ─────────────────────────────────────────────────
|
|
245
|
-
|
|
246
|
-
머리말(lines) {
|
|
247
|
-
for (const l of lines) this.줄버퍼.push(' ' + l);
|
|
248
|
-
this.다시그림();
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
파일칸(list) { this.파일 = Array.isArray(list) ? list : []; this.다시그림(); }
|
|
252
|
-
|
|
253
|
-
할일칸(list) { this.할일 = Array.isArray(list) ? list : []; this.다시그림(); }
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* 입력 자리.
|
|
257
|
-
*
|
|
258
|
-
* 상태줄과 입력 상자를 그린 뒤 커서를 상자 안에 놓고 보이게 한다.
|
|
259
|
-
* 그 다음부터 readline 이 그 자리에 글자를 되비춘다 — 여기서부터는
|
|
260
|
-
* 사람 차례이므로 다시 그리지 않는다.
|
|
261
|
-
*/
|
|
262
|
-
입력자리(session) {
|
|
263
|
-
this.열린줄맺기();
|
|
264
|
-
this.임시글 = null;
|
|
265
|
-
this.상태 = statusLine(session, { max: this.열 - 4 });
|
|
266
|
-
this.경고 = contextWarning(session);
|
|
267
|
-
this.입력중 = false;
|
|
268
|
-
// 다시그림 이 마지막으로 커서를 상자 안 '❯ ' 뒤에 놓아 준다.
|
|
269
|
-
// 여기서 또 옮기면 방금 찍은 ❯ 위에 글자가 겹친다.
|
|
270
|
-
this.다시그림();
|
|
271
|
-
this.입력중 = true;
|
|
272
|
-
cursor.show();
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
입력지움() {
|
|
276
|
-
// 전체화면에서는 입력이 제 칸에 따로 있다. 지울 것이 없고,
|
|
277
|
-
// 다음 그리기가 상자를 통째로 다시 그린다.
|
|
278
|
-
this.입력중 = false;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ── 그리기 ─────────────────────────────────────────────────────────────
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* 그릴 화면 한 장을 **값으로** 만든다.
|
|
285
|
-
*
|
|
286
|
-
* 그리기와 내보내기를 가른 이유: TUI 는 터미널일 때만 도니까 검사가 한 번도
|
|
287
|
-
* 안 밟는다. '검사 전부 통과' 가 '화면이 맞다' 를 뜻하지 않는 자리다.
|
|
288
|
-
* 화면을 글 배열로 내놓으면 파이프 안에서도 재 볼 수 있다 —
|
|
289
|
-
* 테두리가 한 칸 어긋난 것은 눈으로는 못 보고 자로만 잡힌다.
|
|
290
|
-
*
|
|
291
|
-
* @returns {{줄들: string[], 커서: {행: number, 열: number}}}
|
|
292
|
-
*/
|
|
293
|
-
프레임() {
|
|
294
|
-
const 행 = this.행;
|
|
295
|
-
const 열 = this.열;
|
|
296
|
-
const 아래 = 4; // 상태줄 1 + 입력 상자 3
|
|
297
|
-
const 본문높이 = Math.max(3, 행 - 아래 - 2); // 위아래 테두리 2
|
|
298
|
-
const 오른 = 열 >= 96 ? 오른쪽폭 : 0; // 좁으면 오른쪽 칸을 접는다
|
|
299
|
-
// 한 줄의 짜임 — │ + 왼쪽칸 + (┬ + 오른쪽칸) + │ 이 폭 합이 열과 같아야 한다.
|
|
300
|
-
const 왼폭 = 열 - 2 - (오른 ? 오른 + 1 : 0);
|
|
301
|
-
|
|
302
|
-
const 줄들 = [];
|
|
303
|
-
|
|
304
|
-
const 제목선 = (제목, 폭, 왼끝, 오른끝) => {
|
|
305
|
-
const 안 = ' ' + 제목 + ' ';
|
|
306
|
-
return 왼끝 + 안 + '─'.repeat(Math.max(0, 폭 - width(안))) + 오른끝;
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
// 위 테두리
|
|
310
|
-
let 위 = 제목선('대화', 왼폭, '┌', '');
|
|
311
|
-
위 += 오른 ? 제목선('바뀐 파일', 오른, '┬', '┐') : '┐';
|
|
312
|
-
줄들.push(c.gray(위));
|
|
313
|
-
|
|
314
|
-
const 본문 = this.본문줄(왼폭 - 1, 본문높이);
|
|
315
|
-
const 곁 = this.곁줄(오른 - 1, 본문높이);
|
|
316
|
-
for (let i = 0; i < 본문높이; i++) {
|
|
317
|
-
let l = c.gray('│') + 채움(' ' + (본문[i] ?? ''), 왼폭);
|
|
318
|
-
if (오른) l += c.gray(곁[i]?.칸막이 ?? '│') + 채움(곁[i]?.글 ?? '', 오른);
|
|
319
|
-
줄들.push(l + c.gray('│'));
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
let 아래테 = '└' + '─'.repeat(왼폭);
|
|
323
|
-
if (오른) 아래테 += '┴' + '─'.repeat(오른);
|
|
324
|
-
줄들.push(c.gray(아래테 + '┘'));
|
|
325
|
-
|
|
326
|
-
// 상태줄 — 컨텍스트가 위험하면 그 경고가 자리를 대신한다.
|
|
327
|
-
줄들.push(this.경고 ? ` ${this.경고}` : this.상태);
|
|
328
|
-
|
|
329
|
-
// 입력 상자. ' ' + ╭ + 안폭 + ╮ = 열
|
|
330
|
-
const 안폭 = Math.max(2, 열 - 3);
|
|
331
|
-
줄들.push(' ' + c.gray('╭' + '─'.repeat(안폭) + '╮'));
|
|
332
|
-
줄들.push(' ' + c.gray('│') + ' ' + c.hcyan('❯') + ' ' + ' '.repeat(안폭 - 3) + c.gray('│'));
|
|
333
|
-
줄들.push(' ' + c.gray('╰' + '─'.repeat(안폭) + '╯'));
|
|
334
|
-
|
|
335
|
-
// 커서는 입력 상자의 '❯ ' 바로 뒤. ' '(1) │(1) ' '(1) ❯(1) ' '(1) → 6번째 칸
|
|
336
|
-
return { 줄들, 커서: { 행: 줄들.length - 1, 열: 6 } };
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
다시그림({ 입력중이어도 = false } = {}) {
|
|
340
|
-
if (this.닫힘) return;
|
|
341
|
-
// 사람이 치는 중에 그리면 치던 글자가 지워진다. 창 크기가 바뀐 때만 예외다.
|
|
342
|
-
if (this.입력중 && !입력중이어도) return;
|
|
343
|
-
|
|
344
|
-
const { 줄들, 커서 } = this.프레임();
|
|
345
|
-
cursor.hide();
|
|
346
|
-
let out = 집;
|
|
347
|
-
for (let i = 0; i < this.행; i++) {
|
|
348
|
-
out += 자리(i + 1) + 줄끝지움 + (i < 줄들.length ? 줄들[i] : '');
|
|
349
|
-
}
|
|
350
|
-
out += 자리(커서.행, 커서.열);
|
|
351
|
-
process.stdout.write(out);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/** 대화 칸에 들어갈 줄들 — 뒤에서부터 채운다(항상 최근 것을 본다). */
|
|
355
|
-
본문줄(폭, 높이) {
|
|
356
|
-
const 원본 = this.줄버퍼.slice();
|
|
357
|
-
if (this.열린줄 !== null) 원본.push(this.열린줄);
|
|
358
|
-
if (this.임시글 !== null) 원본.push(' ' + this.임시글);
|
|
359
|
-
|
|
360
|
-
const 접힌 = [];
|
|
361
|
-
// 뒤에서부터 접어 담다가 화면을 채우면 멈춘다. 앞쪽 수천 줄을 접느라
|
|
362
|
-
// 매번 시간을 쓰지 않으려는 것이다.
|
|
363
|
-
for (let i = 원본.length - 1; i >= 0 && 접힌.length < 높이; i--) {
|
|
364
|
-
const 조각 = 접어쓰기(원본[i], 폭);
|
|
365
|
-
for (let j = 조각.length - 1; j >= 0; j--) 접힌.unshift(조각[j]);
|
|
366
|
-
}
|
|
367
|
-
while (접힌.length > 높이) 접힌.shift();
|
|
368
|
-
while (접힌.length < 높이) 접힌.push('');
|
|
369
|
-
return 접힌;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/** 오른쪽 칸 — 위는 바뀐 파일, 아래는 할 일. 사이에 가로줄을 넣는다. */
|
|
373
|
-
곁줄(폭, 높이) {
|
|
374
|
-
if (폭 <= 0) return [];
|
|
375
|
-
const 줄 = [];
|
|
376
|
-
const 파일칸높이 = Math.max(1, Math.floor((높이 - 1) / 2));
|
|
377
|
-
|
|
378
|
-
for (let i = 0; i < 파일칸높이; i++) {
|
|
379
|
-
const f = this.파일[i];
|
|
380
|
-
줄.push({ 칸막이: '│', 글: f ? ' ' + clip(f, 폭 - 1) : '' });
|
|
381
|
-
}
|
|
382
|
-
// 칸을 가르는 줄. 왼쪽 끝이 ├ 라 대화 칸 테두리와 이어져 보인다.
|
|
383
|
-
const 제목 = ' 할 일 ';
|
|
384
|
-
줄.push({ 칸막이: '├', 글: c.gray(제목 + '─'.repeat(Math.max(0, 폭 + 1 - width(제목)))) });
|
|
385
|
-
for (let i = 줄.length; i < 높이; i++) {
|
|
386
|
-
const t = this.할일[i - 파일칸높이 - 1];
|
|
387
|
-
줄.push({ 칸막이: '│', 글: t ? ' ' + clip(t, 폭 - 1) : '' });
|
|
388
|
-
}
|
|
389
|
-
return 줄.slice(0, 높이);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* 끝낸다 — 원래 화면으로 돌아가고, 나눈 대화를 스크롤백에 되살린다.
|
|
394
|
-
*
|
|
395
|
-
* 되살리지 않으면 방금 본 것이 통째로 사라진다. 그게 전체화면의 성질이지만,
|
|
396
|
-
* 코딩 도구에서 "방금 뭐라고 했더라" 를 못 찾는 건 곤란하다.
|
|
397
|
-
*/
|
|
398
|
-
close() {
|
|
399
|
-
if (this.닫힘) return;
|
|
400
|
-
this.닫힘 = true;
|
|
401
|
-
clearInterval(this.돌림타이머);
|
|
402
|
-
process.stdout.off?.('resize', this.크기바뀜);
|
|
403
|
-
process.off?.('exit', this.되돌리기);
|
|
404
|
-
process.off?.('uncaughtException', this.죽을때);
|
|
405
|
-
process.off?.('unhandledRejection', this.죽을때);
|
|
406
|
-
this.열린줄맺기();
|
|
407
|
-
|
|
408
|
-
cursor.show();
|
|
409
|
-
process.stdout.write(ALT_OFF);
|
|
410
|
-
|
|
411
|
-
const 남길것 = this.줄버퍼.slice(-되살릴줄);
|
|
412
|
-
if (남길것.length) {
|
|
413
|
-
if (this.줄버퍼.length > 남길것.length) {
|
|
414
|
-
process.stdout.write(c.gray(` (앞선 ${this.줄버퍼.length - 남길것.length}줄은 줄였습니다 — 전체는 .deel/sessions 에 남아 있습니다)\n`));
|
|
415
|
-
}
|
|
416
|
-
process.stdout.write(남길것.join('\n') + '\n');
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|