Files
AriWeb-Suite/hal/ui/public/term.mjs
T

79 lines
8.6 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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;
}