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/src/ui/status.js CHANGED
@@ -10,20 +10,23 @@ import { isLocalHost, isOffline } from '../safety/network.js';
10
10
  // 한 조각씩 따로 만든다. 좁은 화면에서는 뒤에서부터 떨군다.
11
11
  // 순서 = 중요도 순. 앞쪽이 끝까지 살아남는다.
12
12
  export const SEGMENTS = {
13
- dir: { desc: '작업 폴더', make: (s) => c.gray(base(s.root)) },
13
+ // 폴더 이름은 사람이 짓는다 한글로 길게 짓기도 한다. 안 자르면
14
+ // 이 조각 하나가 상태줄을 통째로 넘겨 줄이 접힌다(모델 이름은 이미 자르고 있었다).
15
+ dir: { desc: '작업 폴더', make: (s) => c.gray(clip(base(s.root), 20)) },
14
16
 
15
17
  model: { desc: '모델 이름', make: (s) => c.hcyan(clip(s.conn.model, 24)) },
16
18
 
17
19
  ctx: {
18
20
  desc: '컨텍스트 사용량',
19
21
  make: (s) => {
20
- const b = s.breakdown();
21
- // 넘칠 수는 있지만 화면에 219% 라고 적으면 고장난 것처럼 보인다. 100 에서 멈춘다.
22
- const r = Math.min(1, b.total > 0 ? b.used / b.total : 0);
23
- const pct = Math.round(r * 100);
24
- const tone = r > 0.85 ? c.hred : r > 0.6 ? c.hyellow : c.gray;
22
+ const { r, pct, tone, b } = 참값(s);
25
23
  return `${gauge(r, 10)} ${tone(pct + '%')} ${c.gray(short(b.used) + '/' + short(b.total))}`;
26
24
  },
25
+ // 자리가 모자라면 정확한 숫자를 접는다. 게이지와 %가 이미 같은 말을 하고 있다.
26
+ short: (s) => {
27
+ const { r, pct, tone } = 참값(s);
28
+ return `${gauge(r, 10)} ${tone(pct + '%')}`;
29
+ },
27
30
  },
28
31
 
29
32
  // 지금 무슨 일을 하는 중인가. 파일을 못 바꾸는 모드면 자물쇠를 같이 그린다 —
@@ -31,9 +34,13 @@ export const SEGMENTS = {
31
34
  work: {
32
35
  desc: '작업 모드',
33
36
  make: (s) => {
34
- const w = workMode(s.work);
35
- const 잠김 = !canWrite(s.work);
36
- return `${c.hcyan(w.glyph)} ${잠김 ? c.green(w.name) : c.white(w.name)}${잠김 ? c.green(' 읽기만') : ''}`;
37
+ const 지금 = s.effectiveWork ? s.effectiveWork() : s.work;
38
+ const w = workMode(지금);
39
+ const 잠김 = !canWrite(지금);
40
+ // 저절로 옮겨 간 것인지 사람이 고른 것인지 구분해서 보여준다.
41
+ // 이게 없으면 "왜 갑자기 읽기만 되지" 를 알 길이 없다.
42
+ const 저절로 = s.routed ? c.gray('~') : '';
43
+ return `${c.hcyan(w.glyph)} ${저절로}${잠김 ? c.green(w.name) : c.white(w.name)}${잠김 ? c.green(' 읽기만') : ''}`;
37
44
  },
38
45
  },
39
46
 
@@ -43,6 +50,8 @@ export const SEGMENTS = {
43
50
  const p = PROFILES[s.effort] ?? PROFILES.save;
44
51
  return `${c.gray('◇')} ${c.white(s.think)}${c.gray('·')}${c.magenta(p.name)}`;
45
52
  },
53
+ // 배분은 강도보다 덜 급하다. 좁으면 강도만 남긴다.
54
+ short: (s) => `${c.gray('◇')} ${c.white(s.think)}`,
46
55
  },
47
56
 
48
57
  mode: {
@@ -76,35 +85,100 @@ export const SEGMENTS = {
76
85
  },
77
86
  };
78
87
 
79
- export const DEFAULT_SEGMENTS = ['dir', 'model', 'ctx', 'work', 'think', 'mode', 'tok'];
88
+ /**
89
+ * 상태줄에 무엇을, 어떤 순서로.
90
+ *
91
+ * 전에는 칸 여섯 개가 같은 굵기의 막대로 나란히 서 있었다.
92
+ * ▏폴더 ▏모델 ▏게이지 ▏◎ 종합 ▏◇ medium·절약 ▏auto
93
+ * 뒤의 셋은 셋 다 '모드' 처럼 생겨서, 무엇이 무엇인지 읽으려면 멈춰서 세어야 했다.
94
+ *
95
+ * 세 덩이로 묶는다 — **어디서(폴더·모델) · 얼마나 찼나(게이지) · 어떻게 도나(모드)**.
96
+ * 덩이 사이만 굵은 칸막이(▏)로 가르고, 덩이 안은 가운뎃점으로 잇는다.
97
+ * 그러면 눈이 세 번만 멈춘다.
98
+ */
99
+ export const SEGMENT_GROUPS = [
100
+ ['dir', 'model'],
101
+ ['ctx'],
102
+ ['work', 'think', 'mode'],
103
+ ['tok'],
104
+ ];
105
+
106
+ // 옛 이름. 조각 이름을 직접 넘기던 자리(검사·설정)가 그대로 돌게 남긴다.
107
+ export const DEFAULT_SEGMENTS = SEGMENT_GROUPS.flat();
108
+
109
+ // 컨텍스트 게이지가 쓰는 숫자. 자세한 꼴과 짧은 꼴이 같은 값을 봐야 한다.
110
+ function 참값(s) {
111
+ const b = s.breakdown();
112
+ // 넘칠 수는 있지만 화면에 219% 라고 적으면 고장난 것처럼 보인다. 100 에서 멈춘다.
113
+ const r = Math.min(1, b.total > 0 ? b.used / b.total : 0);
114
+ const pct = Math.round(r * 100);
115
+ const tone = r > 0.85 ? c.hred : r > 0.6 ? c.hyellow : c.gray;
116
+ return { b, r, pct, tone };
117
+ }
80
118
 
81
119
  function base(p) {
82
120
  const parts = String(p ?? '').split(/[\\/]/).filter(Boolean);
83
121
  return parts[parts.length - 1] ?? '~';
84
122
  }
85
123
 
86
- // 12345 → 12.3k. 상태줄은 자리가 없다.
124
+ // 12345 → 12.1k. 상태줄은 자리가 없다.
125
+ //
126
+ // 1000 이 아니라 1024 로 나눈다. 컨텍스트 길이는 죄다 2의 거듭제곱이라
127
+ // 1000 으로 나누면 131,072 가 '131k' 로 나온다 — 아무도 그렇게 안 부른다.
128
+ // 1024 로 나눠야 128k · 32k · 640k 처럼 실제로 부르는 이름이 나온다.
129
+ // 무엇보다 /ctx 가 받는 단위와 같아야 한다. 화면에 655k 라고 띄워 놓고
130
+ // 655k 를 치면 다른 값이 되는 게 가장 나쁘다.
87
131
  function short(n) {
88
132
  const v = Number(n) || 0;
89
- if (v < 1000) return String(v);
90
- if (v < 1000000) return (v / 1000).toFixed(v < 10000 ? 1 : 0) + 'k';
91
- return (v / 1000000).toFixed(1) + 'M';
133
+ if (v < 1024) return String(v);
134
+ if (v < 1024 * 1024) return (v / 1024).toFixed(v < 10240 ? 1 : 0) + 'k';
135
+ return (v / (1024 * 1024)).toFixed(1) + 'M';
92
136
  }
93
137
 
94
138
  /**
95
139
  * 상태줄 한 줄을 만든다. 폭이 모자라면 덜 중요한 조각부터 뺀다.
96
140
  * @param {import('../agent/session.js').Session} session
97
141
  */
98
- export function statusLine(session, { segments = DEFAULT_SEGMENTS, max = cols() - 2 } = {}) {
99
- const sep = ` ${c.gray('▏')} `;
100
- let parts = segments
101
- .map((k) => SEGMENTS[k]?.make(session))
102
- .filter((x) => x != null && x !== '');
103
-
104
- // 뒤에서부터 떨궈 폭을 맞춘다. 앞쪽(폴더·모델·컨텍스트) 마지막까지 남는다.
105
- while (parts.length > 1 && width(parts.join(sep)) + 2 > max) parts.pop();
106
-
107
- return ` ${c.gray('▏')}${parts.join(sep)}`;
142
+ export function statusLine(session, { segments = null, max = cols() - 2 } = {}) {
143
+ const 칸막이 = ` ${c.gray('▏')} `;
144
+ const 안쪽 = c.gray(' · ');
145
+
146
+ // 설정으로 조각 이름을 직접 경우에는 그대로 한 줄로 잇는다(옛 방식).
147
+ const 덩이들 = segments
148
+ ? segments.map((k) => [k])
149
+ : SEGMENT_GROUPS;
150
+
151
+ const 그리기 = (짧게) => 덩이들
152
+ .map((그룹) => 그룹
153
+ .map((k) => {
154
+ const seg = SEGMENTS[k];
155
+ if (!seg) return null;
156
+ return 짧게 && seg.short ? seg.short(session) : seg.make(session);
157
+ })
158
+ .filter((x) => x != null && x !== '')
159
+ .join(안쪽))
160
+ .filter((x) => x !== '');
161
+
162
+ const 맞나 = (ps) => width(ps.join(칸막이)) + 2 <= max;
163
+
164
+ /*
165
+ * 떨구기 전에 **먼저 줄여 본다.**
166
+ *
167
+ * 처음엔 곧바로 뒤에서부터 떨궜다. 그러면 80칸 터미널에서 모델 이름이 조금만
168
+ * 길어도 '어떻게 도나'(모드·강도·승인) 덩이가 통째로 사라졌다 — 세 덩이로
169
+ * 묶어 놓고 정작 세 번째를 못 보는 화면이 된 것이다.
170
+ *
171
+ * 컨텍스트의 '20k/32k' 는 게이지·%와 같은 말을 하고, 배분 이름은 강도보다
172
+ * 덜 급하다. 그 둘을 접으면 대개 다시 들어간다. 정보를 통째로 잃는 것보다
173
+ * 덜 급한 자리를 접는 편이 낫다.
174
+ */
175
+ let parts = 그리기(false);
176
+ if (!맞나(parts)) parts = 그리기(true);
177
+
178
+ // 그래도 모자라면 뒤에서부터 떨군다. 앞쪽(폴더·모델·컨텍스트)이 마지막까지 남는다.
179
+ while (parts.length > 1 && !맞나(parts)) parts.pop();
180
+
181
+ return ` ${c.gray('▏')}${parts.join(칸막이)}`;
108
182
  }
109
183
 
110
184
  // 컨텍스트가 위험하면 한마디 붙인다. 상태줄만 보고 넘기지 않도록.
package/src/ui/tui.js ADDED
@@ -0,0 +1,419 @@
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
+ }
package/src/version.js ADDED
@@ -0,0 +1,28 @@
1
+ // 판 번호를 한 곳에서만 읽는다.
2
+ //
3
+ // 왜 파일 하나를 따로 두나: 전에는 backend/mcp.js 가 '0.9.0' 을 직접 적어
4
+ // 들고 있었다. package.json 을 올릴 때 그건 안 올라간다 — 아무 데서도 안
5
+ // 터지고, 검사도 안 걸리고, MCP 서버 쪽 기록에만 옛 번호가 남는다.
6
+ // 이런 것은 반드시 어긋나므로 애초에 두 벌을 안 만든다.
7
+ import { readFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ /*
12
+ * 못 읽어도 죽지 않는다.
13
+ *
14
+ * package.json 은 npm 이 언제나 같이 깔아 주지만, 소스만 풀어 쓰는 길도
15
+ * 열어 둔 도구다(README 의 `git clone` 뒤 바로 실행). 판 번호 하나 때문에
16
+ * 프로그램이 안 뜨는 것은 말이 안 된다.
17
+ */
18
+ function 읽기() {
19
+ try {
20
+ const 여기 = dirname(fileURLToPath(import.meta.url));
21
+ const pkg = JSON.parse(readFileSync(join(여기, '..', 'package.json'), 'utf8'));
22
+ return String(pkg.version ?? '0.0.0');
23
+ } catch {
24
+ return '0.0.0';
25
+ }
26
+ }
27
+
28
+ export const VERSION = 읽기();