105 lines
10 KiB
JavaScript
105 lines
10 KiB
JavaScript
/**
|
|
* hal/ui/public/term.mjs — 작은 단말(VT100/xterm 부분집합) 에뮬레이터(0.3.9). 순수 모듈 — 브라우저(텍스트 셸)와 Node 테스트가 같이 쓴다.
|
|
*
|
|
* 왜 필요한가: Claude Code·Codex 의 로그인·대화형 화면은 Ink/TUI 라 커서 이동·줄 지우기·대체 화면으로 화면을 다시 그린다. 제어 문자를 걷어 내고 줄로 붙이면 글자가 겹치고 섞인다.
|
|
* 지원: 글자 출력(줄 끝 자동 줄 바꿈), \r \n \b \t, CSI(커서 이동 A B C D E F G H f d, 지우기 J K X P @, 삽입·삭제 줄 L M, 스크롤 S T, 저장·복원 s u, 스크롤 영역 r,
|
|
* 모드 h l(?25 커서, ?1049/?47 대체 화면), SGR m 은 무시), ESC 7 8 c ( ) = >, OSC(제목 등) 무시, 불완전한 시퀀스는 다음 feed 까지 보관.
|
|
* 한계: 색·굵게는 그리지 않는다(글자만). 한글 등 2칸 글자는 1칸으로 센다(TUI 가 2칸으로 계산하면 조금 어긋난다). 스크롤백 1000줄.
|
|
*/
|
|
export class Term {
|
|
constructor(cols = 100, rows = 30) { this.cols = Math.max(4, cols | 0); this.rows = Math.max(2, rows | 0); this.sb = []; this.pend = ''; this.alt = null; this.saved = null; this.cursorVisible = true; this._init(); }
|
|
_init() { this.grid = Array.from({ length: this.rows }, () => this._blank()); this.row = 0; this.col = 0; this.top = 0; this.bottom = this.rows - 1; }
|
|
_blank() { return new Array(this.cols).fill(' '); }
|
|
lines() { return this.grid.map((r) => r.join('').replace(/\s+$/, '')); }
|
|
scrollback() { return this.sb.slice(); }
|
|
cursor() { return { row: this.row, col: this.col }; }
|
|
_clamp() { this.row = Math.max(0, Math.min(this.rows - 1, this.row)); this.col = Math.max(0, Math.min(this.cols, this.col)); }
|
|
_scrollUp(n = 1) { for (let i = 0; i < n; i++) { const gone = this.grid.splice(this.top, 1)[0]; if (this.top === 0 && !this.alt) { this.sb.push(gone.join('').replace(/\s+$/, '')); if (this.sb.length > 1000) this.sb.shift(); } this.grid.splice(this.bottom, 0, this._blank()); } }
|
|
_scrollDown(n = 1) { for (let i = 0; i < n; i++) { this.grid.splice(this.bottom, 1); this.grid.splice(this.top, 0, this._blank()); } }
|
|
_lf() { if (this.row === this.bottom) this._scrollUp(1); else if (this.row < this.rows - 1) this.row++; }
|
|
_put(ch) { if (this.col >= this.cols) { this.col = 0; this._lf(); } this.grid[this.row][this.col] = ch; this.col++; }
|
|
_erase(rowI, from, to) { const r = this.grid[rowI]; for (let i = Math.max(0, from); i < Math.min(this.cols, to); i++) r[i] = ' '; }
|
|
_csi(params, inter, cmd) {
|
|
const p = params === '' ? [] : params.split(';').map((x) => (x === '' ? 0 : parseInt(x, 10))); const n = (i, d = 1) => (p[i] === undefined || p[i] === 0 ? d : p[i]);
|
|
switch (cmd) {
|
|
case 'A': this.row -= n(0); break; case 'B': this.row += n(0); break; case 'C': this.col += n(0); break; case 'D': this.col -= n(0); break;
|
|
case 'E': this.row += n(0); this.col = 0; break; case 'F': this.row -= n(0); this.col = 0; break; case 'G': this.col = n(0) - 1; break; case 'd': this.row = n(0) - 1; break;
|
|
case 'H': case 'f': this.row = n(0) - 1; this.col = n(1) - 1; break;
|
|
case 'J': { const m = p[0] || 0; if (m === 2 || m === 3) { for (let i = 0; i < this.rows; i++) this._erase(i, 0, this.cols); if (m === 3) this.sb = []; } else if (m === 0) { this._erase(this.row, this.col, this.cols); for (let i = this.row + 1; i < this.rows; i++) this._erase(i, 0, this.cols); } else { for (let i = 0; i < this.row; i++) this._erase(i, 0, this.cols); this._erase(this.row, 0, this.col + 1); } break; }
|
|
case 'K': { const m = p[0] || 0; if (m === 0) this._erase(this.row, this.col, this.cols); else if (m === 1) this._erase(this.row, 0, this.col + 1); else this._erase(this.row, 0, this.cols); break; }
|
|
case 'X': this._erase(this.row, this.col, this.col + n(0)); break;
|
|
case 'P': { const r = this.grid[this.row]; r.splice(this.col, n(0)); while (r.length < this.cols) r.push(' '); break; }
|
|
case '@': { const r = this.grid[this.row]; r.splice(this.col, 0, ...new Array(n(0)).fill(' ')); r.length = this.cols; break; }
|
|
case 'L': { for (let i = 0; i < n(0); i++) { this.grid.splice(this.bottom, 1); this.grid.splice(this.row, 0, this._blank()); } break; }
|
|
case 'M': { for (let i = 0; i < n(0); i++) { this.grid.splice(this.row, 1); this.grid.splice(this.bottom, 0, this._blank()); } break; }
|
|
case 'S': this._scrollUp(n(0)); break; case 'T': this._scrollDown(n(0)); break;
|
|
case 's': this.saved = { row: this.row, col: this.col }; break; case 'u': if (this.saved) { this.row = this.saved.row; this.col = this.saved.col; } break;
|
|
case 'r': this.top = Math.max(0, n(0) - 1); this.bottom = Math.min(this.rows - 1, n(1, this.rows) - 1); if (this.top >= this.bottom) { this.top = 0; this.bottom = this.rows - 1; } this.row = 0; this.col = 0; break;
|
|
case 'h': case 'l': { if (inter === '?') { for (const q of p) { if (q === 25) this.cursorVisible = cmd === 'h'; if (q === 1049 || q === 47 || q === 1047) { if (cmd === 'h' && !this.alt) { this.alt = { grid: this.grid, row: this.row, col: this.col, top: this.top, bottom: this.bottom }; this._init(); } else if (cmd === 'l' && this.alt) { Object.assign(this, this.alt); this.alt = null; } } } } break; }
|
|
default: break; // m(SGR)·n·t·c 등은 무시
|
|
}
|
|
this._clamp();
|
|
}
|
|
/** 바이트(문자열)를 먹여 화면을 갱신한다. 불완전한 ESC 시퀀스는 보관한다. */
|
|
feed(data) {
|
|
let s = this.pend + String(data); this.pend = ''; let i = 0;
|
|
while (i < s.length) {
|
|
const ch = s[i];
|
|
if (ch === '\x1b') {
|
|
const nx = s[i + 1]; if (nx === undefined) { this.pend = s.slice(i); break; }
|
|
if (nx === '[') { const m = /^\x1b\[([?>!=]?)([0-9;]*)([ -\/]*)([@-~])/.exec(s.slice(i)); if (!m) { if (s.length - i < 32) { this.pend = s.slice(i); break; } i += 2; continue; } this._csi(m[2], m[1] || m[3], m[4]); i += m[0].length; continue; }
|
|
if (nx === ']') { const end = s.indexOf('\x07', i); const end2 = s.indexOf('\x1b\\', i); let e = -1; if (end >= 0 && (end2 < 0 || end < end2)) e = end + 1; else if (end2 >= 0) e = end2 + 2; if (e < 0) { if (s.length - i < 4096) { this.pend = s.slice(i); break; } i += 2; continue; } i = e; continue; }
|
|
if (nx === '7') { this.saved = { row: this.row, col: this.col }; i += 2; continue; } if (nx === '8') { if (this.saved) { this.row = this.saved.row; this.col = this.saved.col; } i += 2; continue; }
|
|
if (nx === 'c') { this._init(); this.sb = []; this.alt = null; i += 2; continue; }
|
|
if (nx === 'D') { this._lf(); i += 2; continue; } if (nx === 'M') { if (this.row === this.top) this._scrollDown(1); else this.row--; i += 2; continue; } if (nx === 'E') { this.col = 0; this._lf(); i += 2; continue; }
|
|
if (nx === '(' || nx === ')' || nx === '#' || nx === '%') { i += 3; continue; }
|
|
i += 2; continue; // ESC = > N O 등 두 글자 시퀀스 무시
|
|
}
|
|
if (ch === '\r') { this.col = 0; } else if (ch === '\n' || ch === '\v' || ch === '\f') { this._lf(); } else if (ch === '\b') { if (this.col > 0) this.col--; } else if (ch === '\t') { this.col = Math.min(this.cols, (Math.floor(this.col / 8) + 1) * 8); } else if (ch === '\x07' || ch === '\x00' || ch === '\x0e' || ch === '\x0f') { /* 무시 */ } else if (ch >= ' ' || ch > '\x7f') { this._put(ch); }
|
|
i++;
|
|
}
|
|
return this;
|
|
}
|
|
}
|
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
/** 화면 → HTML(스크롤백 + 화면, 커서 표시). */
|
|
export function renderHtml(term, { cursor = true, scrollback = true } = {}) {
|
|
const out = []; if (scrollback) for (const l of term.scrollback().slice(-200)) out.push(esc(l));
|
|
const rows = term.grid.map((r) => r.join('')); const { row, col } = term.cursor();
|
|
rows.forEach((line, i) => { if (cursor && term.cursorVisible && i === row) { const c = Math.min(col, line.length); out.push(esc(line.slice(0, c)) + `<span class="term-cur">${esc(line[c] || ' ')}</span>` + esc(line.slice(c + 1).replace(/\s+$/, ''))); } else out.push(esc(line.replace(/\s+$/, ''))); });
|
|
return out.join('\n');
|
|
}
|
|
/** 브라우저 키 이벤트 → 단말 바이트. 돌려줄 게 없으면 null. */
|
|
export function keyToBytes(e) {
|
|
const k = e.key; const ctrl = e.ctrlKey || e.metaKey;
|
|
if (ctrl && k.length === 1 && /[a-z]/i.test(k)) return String.fromCharCode(k.toUpperCase().charCodeAt(0) - 64);
|
|
const map = { Enter: '\r', Backspace: '\x7f', Tab: '\t', Escape: '\x1b', ArrowUp: '\x1b[A', ArrowDown: '\x1b[B', ArrowRight: '\x1b[C', ArrowLeft: '\x1b[D', Home: '\x1b[H', End: '\x1b[F', Delete: '\x1b[3~', PageUp: '\x1b[5~', PageDown: '\x1b[6~', Insert: '\x1b[2~' };
|
|
if (map[k]) return map[k]; if (k.length === 1 && !ctrl && !e.altKey) return k; return null;
|
|
}
|
|
|
|
/** [0.3.10] 스크롤백 + 화면의 모든 줄. 줄이 꽉 찬 경우(열 수만큼) 다음 줄과 이어진 것으로 보고 붙인다(긴 주소가 줄 끝에서 끊긴 경우). */
|
|
export function joinedLines(term) {
|
|
const raw = [...term.scrollback(), ...term.grid.map((r) => r.join(''))].map((l) => l.replace(/\s+$/, ''));
|
|
const out = []; let cur = null;
|
|
for (const l of raw) { if (cur === null) cur = l; else cur += l; if (cur.length > 0 && l.length === term.cols) continue; out.push(cur); cur = null; }
|
|
if (cur !== null) out.push(cur); return out;
|
|
}
|
|
/** [0.3.10] 복사·붙여넣기용 전체 글(이어 붙인 줄 기준) */
|
|
export const plainText = (term) => joinedLines(term).join('\n');
|
|
const URL_RE = /https?:\/\/[^\s<>"'`│┃║|]+/g;
|
|
/** [0.3.10] 출력에 나온 주소(중복 제거, 순서 유지). 꼬리 구두점은 뗀다. */
|
|
export function extractUrls(term) {
|
|
const seen = new Set(); const out = [];
|
|
for (const line of joinedLines(term)) for (const m of line.matchAll(URL_RE)) { const u = m[0].replace(/[.,;:!?)\]}'"]+$/, ''); if (u.length > 10 && !seen.has(u)) { seen.add(u); out.push(u); } }
|
|
return out;
|
|
}
|
|
/** [0.3.10] 단말 영역의 키 처리: 'copy'(브라우저 복사에 맡김) · 'paste'(클립보드 붙여넣기) · 'send'(셸로 전송). 선택된 글이 있으면 Ctrl+C 는 복사. */
|
|
export function copyDecision(e, hasSelection) {
|
|
const k = String(e.key || '').toLowerCase(); const ctrl = !!(e.ctrlKey || e.metaKey);
|
|
if (ctrl && k === 'insert') return 'copy';
|
|
if (ctrl && k === 'c') return e.shiftKey || hasSelection ? 'copy' : 'send';
|
|
if (ctrl && k === 'v') return 'paste';
|
|
if (e.shiftKey && k === 'insert') return 'paste';
|
|
return 'send';
|
|
}
|