deel-local-cli 0.8.0 → 1.0.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/README.en.md +686 -28
- package/README.md +571 -31
- package/bin/deel.js +234 -160
- package/package.json +3 -2
- package/src/agent/compact.js +131 -138
- package/src/agent/effort.js +44 -11
- package/src/agent/loop.js +574 -251
- package/src/agent/memory.js +152 -0
- package/src/agent/mention.js +164 -0
- package/src/agent/modes.js +127 -20
- package/src/agent/recall.js +209 -0
- package/src/agent/route.js +156 -0
- package/src/agent/salvage.js +182 -0
- package/src/agent/session.js +155 -11
- package/src/agent/store.js +36 -11
- package/src/backend/adapter.js +266 -183
- package/src/backend/ctxsize.js +246 -0
- package/src/backend/detect.js +10 -1
- package/src/backend/http.js +23 -0
- package/src/backend/learn.js +102 -0
- package/src/backend/mcp.js +304 -0
- package/src/backend/probe.js +15 -14
- package/src/backend/scan.js +97 -3
- package/src/commands.js +825 -50
- package/src/oneshot.js +327 -0
- package/src/repl.js +715 -414
- package/src/report.js +20 -6
- package/src/safety/guard.js +123 -7
- package/src/safety/undo.js +96 -11
- package/src/tools/encoding.js +24 -2
- package/src/tools/fsutil.js +70 -0
- package/src/tools/index.js +471 -27
- package/src/tools/webfetch.js +37 -1
- package/src/ui/ansi.js +32 -2
- package/src/ui/diff.js +255 -0
- package/src/ui/level.js +6 -2
- package/src/ui/prompt.js +13 -1
- package/src/ui/screen.js +169 -0
- package/src/ui/status.js +98 -24
- package/src/ui/tui.js +419 -0
- package/src/version.js +28 -0
package/src/tools/webfetch.js
CHANGED
|
@@ -12,6 +12,22 @@
|
|
|
12
12
|
// · 오프라인이면 아예 거절.
|
|
13
13
|
// · 받은 것은 글자만 뽑고 길이를 자른다.
|
|
14
14
|
import { allowTemporarily, isOffline, isLocalHost } from '../safety/network.js';
|
|
15
|
+
import { decode as decodeBytes } from './encoding.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 받아 온 바이트를 글로. 머리글에 적힌 인코딩이 있으면 그것부터 믿는다.
|
|
19
|
+
*
|
|
20
|
+
* 파일을 읽을 때 쓰는 것과 같은 판단기(encoding.js)를 쓴다. 두 자리에 서로 다른
|
|
21
|
+
* 잣대를 두면, 같은 CP949 글이 파일로는 읽히고 웹으로는 깨지는 상태가 된다.
|
|
22
|
+
*/
|
|
23
|
+
function 웹글읽기(buf, 머리글) {
|
|
24
|
+
if (머리글 && !/^utf-?8$/.test(머리글)) {
|
|
25
|
+
try { return new TextDecoder(머리글, { fatal: false }).decode(buf); }
|
|
26
|
+
catch { /* 이 Node 가 모르는 이름이면 아래에서 알아서 본다 */ }
|
|
27
|
+
}
|
|
28
|
+
// euc-kr 인 페이지를 위해 힌트를 준다. 내용이 분명하면 내용이 이긴다.
|
|
29
|
+
return decodeBytes(buf, { fallback: 'euc-kr' }).text;
|
|
30
|
+
}
|
|
15
31
|
|
|
16
32
|
export const 방문기록 = [];
|
|
17
33
|
|
|
@@ -74,8 +90,28 @@ export async function webFetch(args, { allowPrivate = false } = {}) {
|
|
|
74
90
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
75
91
|
if (buf.length > MAX_BYTES) return { error: `너무 큽니다 (${(buf.length / 1024 / 1024).toFixed(1)}MB).` };
|
|
76
92
|
|
|
77
|
-
|
|
93
|
+
/*
|
|
94
|
+
* 무엇으로 쓰여 있는지 알아보고 읽는다.
|
|
95
|
+
*
|
|
96
|
+
* 전에는 무조건 UTF-8 이었다. 사내 위키·공공기관 페이지는 아직 EUC-KR 이
|
|
97
|
+
* 흔한데, 그걸 UTF-8 로 읽으면 한글이 통째로 깨진다. 그 깨진 글이 그대로
|
|
98
|
+
* 모델에게 가고, 모델은 깨진 채로 요약한다 — 사용자는 왜 엉뚱한 답이
|
|
99
|
+
* 나오는지 알 수 없다. 파일을 읽을 때는 이미 알아보고 읽는데(encoding.js)
|
|
100
|
+
* 웹만 안 하고 있었다.
|
|
101
|
+
*
|
|
102
|
+
* 머리글(charset)이 있으면 그게 답이다. 없으면 내용을 보고 짐작한다.
|
|
103
|
+
*/
|
|
104
|
+
const 머리글 = /charset=["']?([\w-]+)/i.exec(type)?.[1]?.toLowerCase() ?? null;
|
|
105
|
+
let text = 웹글읽기(buf, 머리글);
|
|
78
106
|
if (/html/.test(type)) text = 태그벗기기(text);
|
|
107
|
+
// <meta charset> 이 머리글과 다르게 적혀 있는 페이지가 있다. 깨졌으면 그걸 믿고 다시 읽는다.
|
|
108
|
+
if (!머리글 && text.includes('�')) {
|
|
109
|
+
const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(buf.toString('latin1').slice(0, 2000))?.[1]?.toLowerCase();
|
|
110
|
+
if (meta) {
|
|
111
|
+
text = 웹글읽기(buf, meta);
|
|
112
|
+
if (/html/.test(type)) text = 태그벗기기(text);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
79
115
|
const cut = text.length > max;
|
|
80
116
|
if (cut) text = text.slice(0, max);
|
|
81
117
|
|
package/src/ui/ansi.js
CHANGED
|
@@ -5,6 +5,15 @@ const ON = (process.stdout.isTTY || process.env.FORCE_COLOR === '1') && process.
|
|
|
5
5
|
|
|
6
6
|
const E = (n) => (s) => (ON ? `\x1b[${n}m${s}\x1b[0m` : String(s));
|
|
7
7
|
|
|
8
|
+
// 흐린 글자의 밝기. 256색을 못 쓰는 옛 콘솔이면 90 으로 되돌린다.
|
|
9
|
+
function GRAY() {
|
|
10
|
+
const 뜻 = String(process.env.DEEL_CONTRAST ?? '').toLowerCase();
|
|
11
|
+
if (뜻 === 'low') return 90;
|
|
12
|
+
const 옛콘솔 = process.env.TERM === 'dumb';
|
|
13
|
+
if (옛콘솔) return 뜻 === 'high' ? 37 : 90;
|
|
14
|
+
return 뜻 === 'high' ? '38;5;252' : '38;5;245';
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
export const c = {
|
|
9
18
|
dim: E(2),
|
|
10
19
|
bold: E(1),
|
|
@@ -17,7 +26,16 @@ export const c = {
|
|
|
17
26
|
magenta: E(35),
|
|
18
27
|
cyan: E(36),
|
|
19
28
|
white: E(37),
|
|
20
|
-
|
|
29
|
+
// 흐린 글자.
|
|
30
|
+
//
|
|
31
|
+
// 예전에는 90(밝은 검정)이었는데, 배경이 어두우면 배경에 묻히고 밝으면
|
|
32
|
+
// 더 안 보인다. 실제로 "연한 글자가 잘 안 보인다" 는 말을 들었다.
|
|
33
|
+
// 그래서 어느 배경에서도 읽히는 중간 회색을 기본으로 쓴다.
|
|
34
|
+
//
|
|
35
|
+
// 눈에 맞게 바꿀 수 있다:
|
|
36
|
+
// DEEL_CONTRAST=high 더 밝게 (밝은 배경이나 눈이 피로할 때)
|
|
37
|
+
// DEEL_CONTRAST=low 예전처럼 흐리게
|
|
38
|
+
gray: E(GRAY()),
|
|
21
39
|
// 밝은 계열 — 어두운 배경에서 본문과 구분이 필요할 때
|
|
22
40
|
hred: E(91),
|
|
23
41
|
hgreen: E(92),
|
|
@@ -41,6 +59,12 @@ export const cursor = {
|
|
|
41
59
|
// 한글·한자·가나는 터미널에서 두 칸을 차지한다. 표 정렬이 이걸 모르면 어긋난다.
|
|
42
60
|
export function width(str) {
|
|
43
61
|
let w = 0;
|
|
62
|
+
// 없는 값은 빈 글자로 본다.
|
|
63
|
+
//
|
|
64
|
+
// String(null) 은 'null' 이라 폭이 4 로 나온다. 그러면 상태줄이 네 칸씩
|
|
65
|
+
// 어긋나고, 화면에는 'null' 이라는 글자가 그대로 찍힌다. 모델 이름이나
|
|
66
|
+
// 곁말은 없을 수 있는 값이라 실제로 여기로 들어온다.
|
|
67
|
+
if (str === null || str === undefined) return 0;
|
|
44
68
|
for (const ch of String(str).replace(/\x1b\[[0-9;]*m/g, '')) {
|
|
45
69
|
const cp = ch.codePointAt(0);
|
|
46
70
|
if (
|
|
@@ -106,7 +130,13 @@ export function bar(used, total, cells = 32) {
|
|
|
106
130
|
|
|
107
131
|
// 상태줄용 얇은 막대.
|
|
108
132
|
export function gauge(ratio, cells = 10) {
|
|
109
|
-
|
|
133
|
+
// 숫자가 아니면 0 으로 본다.
|
|
134
|
+
//
|
|
135
|
+
// Math.min/max 는 NaN 을 그대로 흘린다. 그러면 repeat(NaN) 이 빈 글자가 되어
|
|
136
|
+
// 막대가 통째로 사라지고, 그만큼 상태줄이 밀린다. 컨텍스트 총량이 0 일 때
|
|
137
|
+
// used/total 이 실제로 NaN 이 된다 — 새 연결에서 드물게 나온다.
|
|
138
|
+
const n = Number(ratio);
|
|
139
|
+
const r = Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
|
|
110
140
|
const filled = Math.round(r * cells);
|
|
111
141
|
const tone = r > 0.85 ? c.hred : r > 0.6 ? c.hyellow : c.hgreen;
|
|
112
142
|
return tone('▰'.repeat(filled)) + c.gray('▱'.repeat(cells - filled));
|
package/src/ui/diff.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// 바뀐 자리를 줄 단위로 보여 준다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 있어야 하나:
|
|
4
|
+
// auto 모드는 안 물어보고 고친다. 그게 이 도구의 속도인데, 화면에 "3군데"
|
|
5
|
+
// 만 남으면 사람은 무엇이 바뀐지 모른 채 넘어간다. 되돌리기가 안전망이라도
|
|
6
|
+
// 뭐가 바뀐지 모르면 되돌릴지 말지조차 못 정한다. 그래서 고친 자리는
|
|
7
|
+
// 반드시 눈에 보여야 한다.
|
|
8
|
+
//
|
|
9
|
+
// 조심한 것:
|
|
10
|
+
// 1) 큰 파일. LCS 표는 줄 수의 곱만큼 자리를 먹는다 — 1만 줄짜리 둘이면
|
|
11
|
+
// 1억 칸이다. 그래서 앞뒤로 똑같은 부분을 먼저 잘라낸다. 파일 한 줄만
|
|
12
|
+
// 고치는 흔한 경우는 이것만으로 표가 1×1 이 된다.
|
|
13
|
+
// 그러고도 크면 LCS 를 포기하고 '이만큼이 통째로 바뀌었다' 로 물러선다.
|
|
14
|
+
// 느린 것보다 대충이라도 빨리 보이는 편이 낫다.
|
|
15
|
+
// 2) 줄 끝 표시(CRLF/LF). 눈에는 똑같은 줄이 전부 바뀐 것으로 나오면
|
|
16
|
+
// 진짜 바뀐 곳을 못 찾는다. 그래서 그 경우를 따로 알아채서 말해 준다.
|
|
17
|
+
// 3) 곁줄은 통째로 만들지 않는다. 20,000줄짜리에서 한 줄 고쳤다고 20,000개를
|
|
18
|
+
// 늘어놓을 이유가 없다 — 필요한 자리만 그때그때 집어 온다.
|
|
19
|
+
import { c, clip, cols } from './ansi.js';
|
|
20
|
+
|
|
21
|
+
// LCS 표를 여기까지만 만든다. 넘으면 대충으로 물러선다. (4M 칸 = 16MB)
|
|
22
|
+
const MAX_CELLS = 4_000_000;
|
|
23
|
+
// 대충으로 물러섰을 때 실제로 만들어 둘 줄 수. 나머지는 세기만 한다.
|
|
24
|
+
const MAX_COARSE = 2000;
|
|
25
|
+
const TAB = ' ';
|
|
26
|
+
|
|
27
|
+
/** 글을 줄로 나눈다. 마지막 개행이 있었는지는 따로 들고 있는다. */
|
|
28
|
+
function 줄나누기(text) {
|
|
29
|
+
if (text === null || text === undefined) return { lines: [], eof: false, none: true };
|
|
30
|
+
const s = String(text);
|
|
31
|
+
if (s === '') return { lines: [], eof: false, none: false };
|
|
32
|
+
const eof = s.endsWith('\n');
|
|
33
|
+
const lines = (eof ? s.slice(0, -1) : s).split('\n');
|
|
34
|
+
return { lines, eof, none: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const 줄끝뗀것 = (arr) => arr.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
|
|
38
|
+
const 같은가 = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 두 글을 줄 단위로 비교한다.
|
|
42
|
+
*
|
|
43
|
+
* @param {string|null} before 없던 파일이면 null
|
|
44
|
+
* @param {string|null} after 지운 파일이면 null
|
|
45
|
+
* @returns {{
|
|
46
|
+
* changed: boolean, added: number, removed: number,
|
|
47
|
+
* isNew: boolean, isGone: boolean, eolOnly: boolean, eofChanged: boolean,
|
|
48
|
+
* tooBig: boolean, omitted: number, hunks: Array<{from:number,to:number}>,
|
|
49
|
+
* opAt: (p:number) => {t:string,a:number,b:number}|null, total: number,
|
|
50
|
+
* linesA: string[], linesB: string[],
|
|
51
|
+
* }}
|
|
52
|
+
*/
|
|
53
|
+
export function diffLines(before, after, { context = 3, maxCells = MAX_CELLS } = {}) {
|
|
54
|
+
const A = 줄나누기(before);
|
|
55
|
+
const B = 줄나누기(after);
|
|
56
|
+
const linesA = A.lines;
|
|
57
|
+
const linesB = B.lines;
|
|
58
|
+
|
|
59
|
+
// 줄 끝 표시만 바뀐 것인가 — 내용은 그대로인데 \r 만 붙거나 떨어진 경우.
|
|
60
|
+
const 뗀A = 줄끝뗀것(linesA);
|
|
61
|
+
const 뗀B = 줄끝뗀것(linesB);
|
|
62
|
+
const eolOnly = !같은가(linesA, linesB) && 같은가(뗀A, 뗀B) && A.eof === B.eof;
|
|
63
|
+
|
|
64
|
+
// 앞뒤로 똑같은 부분을 잘라낸다. 여기서 대부분의 일이 끝난다.
|
|
65
|
+
let pre = 0;
|
|
66
|
+
const 짧은쪽 = Math.min(linesA.length, linesB.length);
|
|
67
|
+
while (pre < 짧은쪽 && linesA[pre] === linesB[pre]) pre++;
|
|
68
|
+
let suf = 0;
|
|
69
|
+
while (suf < 짧은쪽 - pre && linesA[linesA.length - 1 - suf] === linesB[linesB.length - 1 - suf]) suf++;
|
|
70
|
+
|
|
71
|
+
const midA = linesA.slice(pre, linesA.length - suf);
|
|
72
|
+
const midB = linesB.slice(pre, linesB.length - suf);
|
|
73
|
+
|
|
74
|
+
let script = [];
|
|
75
|
+
let added = 0;
|
|
76
|
+
let removed = 0;
|
|
77
|
+
let tooBig = false;
|
|
78
|
+
let omitted = 0;
|
|
79
|
+
|
|
80
|
+
if (midA.length === 0 && midB.length === 0) {
|
|
81
|
+
// 줄은 똑같다. 남은 차이는 마지막 개행뿐일 수 있다.
|
|
82
|
+
} else if (midA.length === 0 || midB.length === 0 || midA.length * midB.length > maxCells) {
|
|
83
|
+
// 한쪽이 통째로 비었거나, 표가 너무 크다 — 통째로 바뀐 것으로 본다.
|
|
84
|
+
tooBig = midA.length > 0 && midB.length > 0;
|
|
85
|
+
added = midB.length;
|
|
86
|
+
removed = midA.length;
|
|
87
|
+
const 몫 = tooBig ? Math.max(1, Math.floor(MAX_COARSE / 2)) : Infinity;
|
|
88
|
+
for (let i = 0; i < midA.length && i < 몫; i++) script.push({ t: '-', a: pre + i, b: -1 });
|
|
89
|
+
for (let j = 0; j < midB.length && j < 몫; j++) script.push({ t: '+', a: -1, b: pre + j });
|
|
90
|
+
omitted = Math.max(0, midA.length - 몫) + Math.max(0, midB.length - 몫);
|
|
91
|
+
} else {
|
|
92
|
+
script = LCS대본(midA, midB, pre);
|
|
93
|
+
for (const op of script) {
|
|
94
|
+
if (op.t === '+') added++;
|
|
95
|
+
else if (op.t === '-') removed++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 줄은 다 같은데 마지막 개행만 다른 경우. 마지막 줄이 바뀐 것으로 그린다.
|
|
100
|
+
let eofChanged = false;
|
|
101
|
+
if (added === 0 && removed === 0 && !A.none && !B.none && A.eof !== B.eof && linesA.length) {
|
|
102
|
+
eofChanged = true;
|
|
103
|
+
added = 1;
|
|
104
|
+
removed = 1;
|
|
105
|
+
const 끝 = linesA.length - 1;
|
|
106
|
+
script = [{ t: '-', a: 끝, b: -1 }, { t: '+', a: -1, b: linesB.length - 1 }];
|
|
107
|
+
// 대본이 맨 끝 한 줄을 대신하므로 꼬리 곁줄은 없다.
|
|
108
|
+
suf = 0;
|
|
109
|
+
pre = 끝;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const aTail = linesA.length - suf;
|
|
113
|
+
const bTail = linesB.length - suf;
|
|
114
|
+
const total = pre + script.length + suf;
|
|
115
|
+
|
|
116
|
+
// 자리 하나를 그때그때 집어 온다. 앞뒤 공통 부분은 만들어 두지 않는다.
|
|
117
|
+
const opAt = (p) => {
|
|
118
|
+
if (p < 0 || p >= total) return null;
|
|
119
|
+
if (p < pre) return { t: ' ', a: p, b: p };
|
|
120
|
+
const k = p - pre;
|
|
121
|
+
if (k < script.length) return script[k];
|
|
122
|
+
const t = k - script.length;
|
|
123
|
+
return { t: ' ', a: aTail + t, b: bTail + t };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// 바뀐 자리들을 곁줄을 붙여 덩어리로 묶는다.
|
|
127
|
+
const 바뀐자리 = [];
|
|
128
|
+
for (let k = 0; k < script.length; k++) if (script[k].t !== ' ') 바뀐자리.push(pre + k);
|
|
129
|
+
|
|
130
|
+
const hunks = [];
|
|
131
|
+
for (const p of 바뀐자리) {
|
|
132
|
+
const 마지막 = hunks[hunks.length - 1];
|
|
133
|
+
// 곁줄끼리 겹치거나 맞닿으면 한 덩어리로 합친다.
|
|
134
|
+
if (마지막 && p - 마지막.last <= context * 2 + 1) {
|
|
135
|
+
마지막.last = p;
|
|
136
|
+
마지막.to = Math.min(total - 1, p + context);
|
|
137
|
+
} else {
|
|
138
|
+
hunks.push({ from: Math.max(0, p - context), to: Math.min(total - 1, p + context), last: p });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const h of hunks) delete h.last;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
changed: added > 0 || removed > 0,
|
|
145
|
+
added, removed,
|
|
146
|
+
isNew: A.none && !B.none,
|
|
147
|
+
isGone: !A.none && B.none,
|
|
148
|
+
eolOnly, eofChanged, tooBig, omitted,
|
|
149
|
+
hunks, opAt, total, linesA, linesB,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 가장 긴 공통 부분 수열로 대본을 만든다.
|
|
155
|
+
*
|
|
156
|
+
* 표를 통째로 잡는다 — 되짚어 올라가려면 표가 있어야 한다. 크기는 부르는 쪽에서
|
|
157
|
+
* 이미 걸러 놨다. Uint32Array 를 쓰는 이유는 보통 배열보다 자리를 덜 먹고
|
|
158
|
+
* 빈칸 채우기가 빨라서다.
|
|
159
|
+
*/
|
|
160
|
+
function LCS대본(a, b, base) {
|
|
161
|
+
const n = a.length;
|
|
162
|
+
const m = b.length;
|
|
163
|
+
const W = m + 1;
|
|
164
|
+
const 표 = new Uint32Array((n + 1) * W);
|
|
165
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
166
|
+
const 줄 = i * W;
|
|
167
|
+
const 다음 = (i + 1) * W;
|
|
168
|
+
const ai = a[i];
|
|
169
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
170
|
+
표[줄 + j] = ai === b[j] ? 표[다음 + j + 1] + 1 : Math.max(표[다음 + j], 표[줄 + j + 1]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const out = [];
|
|
174
|
+
let i = 0;
|
|
175
|
+
let j = 0;
|
|
176
|
+
while (i < n && j < m) {
|
|
177
|
+
if (a[i] === b[j]) { out.push({ t: ' ', a: base + i, b: base + j }); i++; j++; }
|
|
178
|
+
else if (표[(i + 1) * W + j] >= 표[i * W + j + 1]) { out.push({ t: '-', a: base + i, b: -1 }); i++; }
|
|
179
|
+
else { out.push({ t: '+', a: -1, b: base + j }); j++; }
|
|
180
|
+
}
|
|
181
|
+
while (i < n) { out.push({ t: '-', a: base + i, b: -1 }); i++; }
|
|
182
|
+
while (j < m) { out.push({ t: '+', a: -1, b: base + j }); j++; }
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const 보기좋게 = (s) => String(s ?? '').replace(/\t/g, TAB).replace(/\r/g, '');
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 화면에 그릴 줄들을 만든다.
|
|
190
|
+
*
|
|
191
|
+
* @param {ReturnType<typeof diffLines>|null} d
|
|
192
|
+
* @param {{maxLines?:number, width?:number, indent?:string}} opts
|
|
193
|
+
*/
|
|
194
|
+
export function renderDiff(d, { maxLines = 40, width = 0, indent = ' ' } = {}) {
|
|
195
|
+
if (!d || !d.hunks) return [];
|
|
196
|
+
const 폭 = width > 0 ? width : Math.max(30, cols() - 18);
|
|
197
|
+
const out = [];
|
|
198
|
+
|
|
199
|
+
if (d.eolOnly) {
|
|
200
|
+
out.push(`${indent}${c.yellow('※')} ${c.gray('줄 끝 표시(CRLF/LF)만 바뀌었습니다 — 글자는 그대로입니다.')}`);
|
|
201
|
+
}
|
|
202
|
+
if (d.eofChanged) {
|
|
203
|
+
out.push(`${indent}${c.gray('※ 마지막 줄의 개행만 바뀌었습니다.')}`);
|
|
204
|
+
}
|
|
205
|
+
if (d.tooBig) {
|
|
206
|
+
out.push(`${indent}${c.gray('※ 너무 많이 바뀌어 자세히는 못 맞춥니다 — 통째로 바뀐 것으로 봅니다.')}`);
|
|
207
|
+
}
|
|
208
|
+
if (!d.hunks.length) return out;
|
|
209
|
+
|
|
210
|
+
const 번호폭 = String(Math.max(d.linesA?.length ?? 0, d.linesB?.length ?? 0)).length;
|
|
211
|
+
let 그린줄 = 0;
|
|
212
|
+
let 남은줄 = 0;
|
|
213
|
+
let 끊김 = false;
|
|
214
|
+
|
|
215
|
+
for (let h = 0; h < d.hunks.length; h++) {
|
|
216
|
+
const { from, to } = d.hunks[h];
|
|
217
|
+
if (h > 0) {
|
|
218
|
+
if (그린줄 >= maxLines) { 끊김 = true; }
|
|
219
|
+
else { out.push(`${indent}${c.gray('⋯')}`); }
|
|
220
|
+
}
|
|
221
|
+
for (let p = from; p <= to; p++) {
|
|
222
|
+
if (그린줄 >= maxLines) { 끊김 = true; 남은줄 += to - p + 1; break; }
|
|
223
|
+
const op = d.opAt(p);
|
|
224
|
+
if (!op) continue;
|
|
225
|
+
const 글 = clip(보기좋게(op.t === '-' ? d.linesA[op.a] : d.linesB[op.b]), 폭);
|
|
226
|
+
// 번호는 '지금 파일' 의 번호만 적는다.
|
|
227
|
+
//
|
|
228
|
+
// 없어진 줄에 옛 번호를 달면, 바로 위 곁줄의 새 번호와 같은 숫자가 나란히
|
|
229
|
+
// 찍힌다 — 서로 다른 파일의 번호가 한 줄에 섞여 보인다. 실제로 8번이
|
|
230
|
+
// 두 번 찍히는 화면이 나왔다. 없어진 줄은 지금 파일에 없으니 번호도 없다.
|
|
231
|
+
const n = op.t === '-' ? ' '.repeat(번호폭) : String(op.b + 1).padStart(번호폭);
|
|
232
|
+
if (op.t === '+') out.push(`${indent}${c.hgreen('+')} ${c.gray(n)} ${c.green(글)}`);
|
|
233
|
+
else if (op.t === '-') out.push(`${indent}${c.hred('-')} ${c.gray(n)} ${c.red(글)}`);
|
|
234
|
+
else out.push(`${indent}${c.gray(' ')} ${c.gray(n)} ${c.gray(글)}`);
|
|
235
|
+
그린줄++;
|
|
236
|
+
}
|
|
237
|
+
if (끊김) {
|
|
238
|
+
for (let k = h + 1; k < d.hunks.length; k++) 남은줄 += d.hunks[k].to - d.hunks[k].from + 1;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
남은줄 += d.omitted ?? 0;
|
|
244
|
+
if (남은줄 > 0) out.push(`${indent}${c.gray(`⋯ 외 ${남은줄.toLocaleString()}줄 더`)}`);
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** `+3 −1` 한 줄 요약. 안 바뀌었으면 빈 글. */
|
|
249
|
+
export function shortStat(d) {
|
|
250
|
+
if (!d || !d.changed) return '';
|
|
251
|
+
const 조각 = [];
|
|
252
|
+
if (d.added) 조각.push(c.hgreen(`+${d.added}`));
|
|
253
|
+
if (d.removed) 조각.push(c.hred(`−${d.removed}`));
|
|
254
|
+
return 조각.join(' ');
|
|
255
|
+
}
|
package/src/ui/level.js
CHANGED
|
@@ -18,8 +18,12 @@ export const LEVELS = {
|
|
|
18
18
|
hint: '권장값으로 바로 시작',
|
|
19
19
|
// 목록에 띄울 것. 나머지는 쳐도 먹지만 안 보인다.
|
|
20
20
|
show: [
|
|
21
|
-
'help', 'code', 'plan', 'ask',
|
|
22
|
-
|
|
21
|
+
'help', 'work', 'auto', 'code', 'plan', 'ask',
|
|
22
|
+
// ctx 를 초보 목록에 넣는다. 컨텍스트가 작게 잡히면 "왜 파일을 조금만 읽지"
|
|
23
|
+
// 가 되는데, 초보일수록 그 원인을 못 찾는다. 명령 하나로 끝나는 문제다.
|
|
24
|
+
// diff 는 초보에게 특히 필요하다. auto 모드는 안 물어보고 고치니,
|
|
25
|
+
// '무엇이 바뀌었나' 를 볼 통로가 없으면 되돌릴지 말지도 못 정한다.
|
|
26
|
+
'model', 'ctx', 'scan', 'diff', 'undo', 'clear', 'sessions', 'cost', 'level', 'exit',
|
|
23
27
|
],
|
|
24
28
|
// 첫 실행에서 훑어 추천까지 해 준다
|
|
25
29
|
autoScan: true,
|
package/src/ui/prompt.js
CHANGED
|
@@ -30,11 +30,23 @@ export function ask(label, { mask = false, def = '' } = {}) {
|
|
|
30
30
|
process.stdout.write(prefix + (def ? c.gray(`[${def}] `) : ''));
|
|
31
31
|
let buf = '';
|
|
32
32
|
return readKeys((ch, done) => {
|
|
33
|
-
|
|
33
|
+
// 글자 단위로 훑되 자리를 기억한다. 엔터에서 멈출 때 '남은 것' 을 알아야 해서다.
|
|
34
|
+
const 글자들 = [...ch];
|
|
35
|
+
for (let i = 0; i < 글자들.length; i++) {
|
|
36
|
+
const ch1 = 글자들[i];
|
|
34
37
|
const code = ch1.charCodeAt(0);
|
|
35
38
|
if (code === 3) { say(''); process.exit(130); } // Ctrl+C
|
|
36
39
|
if (code === 13 || code === 10) { // Enter
|
|
37
40
|
say('');
|
|
41
|
+
// 한 덩어리에 여러 줄이 실려 올 수 있다 — 붙여넣기, 파이프 입력, 느린 터미널.
|
|
42
|
+
// 예전에는 엔터를 만나면 그 덩어리의 나머지를 그냥 버렸다. 그러면 이어서
|
|
43
|
+
// 물어보는 쪽이 아무것도 못 받고 멈춘 것처럼 보인다. 무엇이 사라졌는지도
|
|
44
|
+
// 화면에 안 남아서, 사람은 자기가 안 친 줄 안다.
|
|
45
|
+
// 그래서 되돌려 놓는다 — 다음에 읽는 쪽이 받아 간다.
|
|
46
|
+
let 다음 = i + 1;
|
|
47
|
+
if (code === 13 && 글자들[다음] === '\n') 다음++; // CRLF 는 한 번으로 친다
|
|
48
|
+
const 남은것 = 글자들.slice(다음).join('');
|
|
49
|
+
if (남은것) { try { process.stdin.unshift(남은것); } catch { /* 못 돌려놔도 이 줄은 살린다 */ } }
|
|
38
50
|
return done(buf.length ? buf : def);
|
|
39
51
|
}
|
|
40
52
|
if (code === 127 || code === 8) { // Backspace
|
package/src/ui/screen.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 화면 한 장.
|
|
3
|
+
*
|
|
4
|
+
* 왜 이 파일이 생겼나:
|
|
5
|
+
* repl.js 안에 say()·process.stdout.write 가 79군데 흩어져 있었다. 그 상태로는
|
|
6
|
+
* 전체화면 화면(TUI)을 얹을 수가 없다 — 어디로 무엇이 나가는지 한 군데서 못 잡으니
|
|
7
|
+
* 두 화면이 같은 코드를 나눠 쓰려다 결국 둘 다 어중간해진다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 '무엇을 그린다' 와 '어떻게 그린다' 를 가른다. repl.js 는 이제
|
|
10
|
+
* `화면.줄(...)`·`화면.답조각(...)` 처럼 **뜻**만 말하고, 그것을 줄로 흘릴지
|
|
11
|
+
* 칸에 담아 다시 그릴지는 여기서 정한다.
|
|
12
|
+
*
|
|
13
|
+
* 두 가지 구현이 있다:
|
|
14
|
+
* 줄화면(LineScreen) 지금까지의 그 화면. 위에서 아래로 흘러간다.
|
|
15
|
+
* 파이프·기록·CI·`deel run`·검사가 전부 이것을 읽는다.
|
|
16
|
+
* 전체화면(TuiScreen) ui/tui.js. 사람이 터미널 앞에 앉아 있을 때만.
|
|
17
|
+
*
|
|
18
|
+
* **줄화면을 없애지 않는 것이 핵심이다.** 전체화면은 터미널을 통째로 점유하고
|
|
19
|
+
* 커서를 옮겨 가며 다시 그린다. 그 출력을 파일로 넘기면 제어문자 덩어리가 되고,
|
|
20
|
+
* CI 로그에서는 읽을 수가 없다. 그래서 두 벌을 갖고 상황에 따라 고른다.
|
|
21
|
+
*/
|
|
22
|
+
import { c, say, cursor, box, cols } from './ansi.js';
|
|
23
|
+
import { statusLine, contextWarning } from './status.js';
|
|
24
|
+
import { spin } from './spinner.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 지금 이 자리에서 전체화면을 써도 되는가.
|
|
28
|
+
*
|
|
29
|
+
* 셋 다 맞아야 한다. 하나라도 아니면 줄화면이다 —
|
|
30
|
+
* 애매하면 줄화면이 맞다. 잘못 켜면 사용자 화면이 깨지지만,
|
|
31
|
+
* 잘못 안 켜면 그냥 지금까지의 화면일 뿐이다.
|
|
32
|
+
*/
|
|
33
|
+
export function 전체화면쓸까({ tui = null } = {}) {
|
|
34
|
+
if (tui === false) return false; // deel --no-tui
|
|
35
|
+
if (!process.stdout.isTTY) return false; // 파이프·기록·CI
|
|
36
|
+
if (!process.stdin.isTTY) return false; // 입력이 파이프로 들어옴 (검사·데모)
|
|
37
|
+
if (process.env.TERM === 'dumb') return false;
|
|
38
|
+
if (process.env.CI) return false;
|
|
39
|
+
// 창이 너무 작으면 칸을 나눌 자리가 없다. 억지로 나누면 글자가 겹친다.
|
|
40
|
+
if ((process.stdout.columns ?? 0) < 60 || (process.stdout.rows ?? 0) < 16) return false;
|
|
41
|
+
if (tui === true) return true;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 줄로 흘려보내는 화면 — 지금까지의 그 화면.
|
|
47
|
+
*
|
|
48
|
+
* 여기 있는 메서드 하나하나가 전에 repl.js 에 흩어져 있던 출력 한 줄과
|
|
49
|
+
* **글자 하나까지 같아야 한다.** 검사가 전부 이 화면을 글로 읽고 있어서,
|
|
50
|
+
* 다르면 그 자리에서 잡힌다. 그게 이 갈라내기가 맞는지 재는 자다.
|
|
51
|
+
*/
|
|
52
|
+
export class LineScreen {
|
|
53
|
+
constructor() {
|
|
54
|
+
this.kind = 'line';
|
|
55
|
+
// 곧 지워질 줄이 화면에 있나. \r 로 커서만 앞으로 보내 놓고 안 지우면
|
|
56
|
+
// 다음에 오는 짧은 글이 그 줄 위에 겹쳐 찍힌다.
|
|
57
|
+
this.임시중 = false;
|
|
58
|
+
this.돌림 = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── 흘러가는 글 ────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** 한 줄. 이미 색이 입혀진 글을 받는다. */
|
|
64
|
+
줄(s = '') { this.임시지움(); say(s); }
|
|
65
|
+
|
|
66
|
+
/** 줄바꿈 없이 이어 붙인다 — 스트리밍으로 오는 답. */
|
|
67
|
+
붙임(s) { this.임시지움(); process.stdout.write(s); }
|
|
68
|
+
|
|
69
|
+
// ── 곧 지워질 표시 ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 기다리는 중. \r 로 커서만 앞으로 보낸다.
|
|
73
|
+
*
|
|
74
|
+
* 파이프일 때도 찍는다 — 데모·기록에서 '무엇을 기다리는 중이었나' 가
|
|
75
|
+
* 남아야 한다. 다만 지워야 할 줄로 세는 것은 터미널일 때만이다.
|
|
76
|
+
*/
|
|
77
|
+
기다림(s) {
|
|
78
|
+
process.stdout.write(` ${s}\r`);
|
|
79
|
+
if (process.stdout.isTTY) this.임시중 = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 생각하는 중. 글자 수가 계속 바뀌므로 터미널일 때만 그린다. */
|
|
83
|
+
생각(s) {
|
|
84
|
+
if (!process.stdout.isTTY) return;
|
|
85
|
+
cursor.clearLine();
|
|
86
|
+
process.stdout.write(` ${s}`);
|
|
87
|
+
this.임시중 = true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
임시지움() {
|
|
91
|
+
if (this.임시중) { cursor.clearLine(); this.임시중 = false; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 사람이 치고 있던 입력 줄을 지운다.
|
|
96
|
+
*
|
|
97
|
+
* Shift+Tab 으로 작업 모드를 돌릴 때처럼, 입력을 기다리는 도중에 화면에
|
|
98
|
+
* 한 줄을 끼워 넣어야 하는 자리가 있다. 안 지우면 `❯ ` 뒤에 새 글이 붙는다.
|
|
99
|
+
* 전체화면에서는 입력이 제 칸에 따로 있어서 지울 것이 없다.
|
|
100
|
+
*/
|
|
101
|
+
입력지움() { cursor.clearLine(); }
|
|
102
|
+
|
|
103
|
+
/** 오래 걸리는 일에 돌아가는 표시를 세운다. */
|
|
104
|
+
돌리기(label) { this.돌림 = spin(label); return this.돌림; }
|
|
105
|
+
|
|
106
|
+
// spinner 가 멈추면서 이미 줄을 지우고 커서를 되살린다. 여기서 또 지우지 않는다 —
|
|
107
|
+
// 화면에 나가는 제어문자가 한 벌이라도 달라지면 그게 갈라내기의 흔적이 된다.
|
|
108
|
+
돌림멈춤(finalLine) {
|
|
109
|
+
if (!this.돌림) return;
|
|
110
|
+
this.돌림.stop(finalLine);
|
|
111
|
+
this.돌림 = null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 자리를 차지하는 것 ─────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** 켤 때 한 번 그리는 머리말 상자. */
|
|
117
|
+
머리말(lines) {
|
|
118
|
+
say('');
|
|
119
|
+
for (const l of box(lines, { tone: c.gray })) say(' ' + l);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 입력 자리. 위에 상태줄을 한 줄 깔고 그 아래에 커서를 둔다.
|
|
124
|
+
*
|
|
125
|
+
* 줄화면에서는 이게 매번 새로 찍힌다 — 대화가 길어지면 상태줄이 화면에
|
|
126
|
+
* 여러 번 남는다. 그게 흘러가는 화면의 성질이고, 기록으로 읽을 때는
|
|
127
|
+
* 오히려 그때그때 상태를 알 수 있어 낫다.
|
|
128
|
+
*/
|
|
129
|
+
입력자리(session) {
|
|
130
|
+
say('');
|
|
131
|
+
say(statusLine(session));
|
|
132
|
+
const w = contextWarning(session);
|
|
133
|
+
if (w) say(` ${w}`);
|
|
134
|
+
process.stdout.write(` ${c.hcyan('❯')} `);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 오른쪽 칸에 들어갈 것들. 줄화면에는 오른쪽 칸이 없다. */
|
|
138
|
+
파일칸() { /* 줄화면은 안 그린다 */ }
|
|
139
|
+
|
|
140
|
+
할일칸() { /* 할 일은 나올 때마다 줄로 흘려보낸다 (repl.js 가 그린다) */ }
|
|
141
|
+
|
|
142
|
+
/** 창 크기가 바뀌었다. 줄화면은 다시 그릴 것이 없다. */
|
|
143
|
+
다시그림() { }
|
|
144
|
+
|
|
145
|
+
close() {
|
|
146
|
+
this.돌림멈춤();
|
|
147
|
+
this.임시지움();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 상황에 맞는 화면을 하나 고른다.
|
|
153
|
+
*
|
|
154
|
+
* 전체화면 쪽은 필요할 때만 읽어 들인다. 줄화면으로 돌 때 tui.js 를
|
|
155
|
+
* 파싱조차 안 하게 하려는 것이다 — `deel run` 이 조금이라도 빨리 시작해야 한다.
|
|
156
|
+
*/
|
|
157
|
+
export async function 화면고르기(opts = {}) {
|
|
158
|
+
if (!전체화면쓸까(opts)) return new LineScreen();
|
|
159
|
+
try {
|
|
160
|
+
const { TuiScreen } = await import('./tui.js');
|
|
161
|
+
return new TuiScreen(opts);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// 전체화면을 못 세우면 조용히 줄화면으로 간다. 화면 하나 때문에
|
|
164
|
+
// 프로그램이 안 뜨는 일은 없어야 한다.
|
|
165
|
+
const s = new LineScreen();
|
|
166
|
+
s.줄(` ${c.gray(`전체화면을 못 켰습니다 — 줄 화면으로 갑니다. (${e.message})`)}`);
|
|
167
|
+
return s;
|
|
168
|
+
}
|
|
169
|
+
}
|