Pi từ mã nguồnpi v1.4.0

Chương 13Phần 3: Coding agent

pi-tui: giao diện terminal vẽ theo vi sai

Component, frame và thuật toán vẽ vi sai; overlay, phím, editor và markdown trong terminal.

  • 57 phút đọc
  • 54 đoạn code
  • 18 mục
Trong chương này18 mục
  1. Vai trò của pi-tui
  2. Mô hình tư duy: component, frame và vi sai
  3. Data model: Component, Container và TUI
  4. Vòng render: requestRender, nhịp 16 ms và renderNow
  5. Thuật toán vi sai của TuiMainScreen
  6. Trace: tin nhắn đang stream dài thêm một dòng
  7. TuiAltScreen: chế độ toàn màn hình
  8. Overlay, focus và định tuyến input
  9. Phân tích phím và keybinding
  10. Editor: soạn nhiều dòng, autocomplete, paste, undo
  11. Markdown và hình ảnh
  12. Phần native
  13. Interactive mode dựng giao diện trên pi-tui
  14. Theme và system theme
  15. Quyết định thiết kế: từ scrollback tới fullscreen
  16. Cạm bẫy và trường hợp biên
  17. Đọc code ở đâu
  18. Tóm tắt

Vai trò của pi-tui#

Một coding agent chạy trong terminal phải vẽ cùng lúc nhiều thứ thay đổi liên tục: câu trả lời đang stream từng token, các hàng tool với trạng thái đang chạy, ô soạn thảo nơi người dùng vẫn gõ được, footer với số token và phần trăm cửa sổ ngữ cảnh (context window). Terminal không có DOM. Thứ duy nhất chương trình có là một luồng byte ghi ra stdout, gồm chữ xen với các chuỗi điều khiển (escape sequence) để di chuyển con trỏ, xoá dòng, đổi màu. Nếu mỗi token mới lại xoá màn hình rồi vẽ lại tất cả, màn hình nhấp nháy, CPU bận vô ích, và scrollback (phần lịch sử mà terminal giữ phía trên màn hình) bị phá. pi-tui giải quyết đúng việc này: ứng dụng mô tả giao diện bằng một cây component, mỗi component chỉ biết trả về các dòng văn bản cho một độ rộng; pi-tui so khung hình mới với khung trước rồi chỉ ghi phần khác.

Gói là @earendil-works/pi-tui, thư mục packages/tui, khoảng 19.600 dòng TypeScript trong src/. README mô tả nó là “Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications” (README.md:3). Lúc chạy, gói chỉ phụ thuộc ba thư viện: get-east-asian-width để đo độ rộng ký tự Đông Á, marked và marked-cjk-friendly để phân tích Markdown (package.json:53–57). Phần còn lại, từ phân tích phím, độ rộng grapheme, bố cục, cuộn, chọn văn bản tới ảnh inline, đều tự viết.

Ai dùng gói này:

  • Chế độ interactive của coding agent (packages/coding-agent/src/modes/interactive/) là người dùng chính: nó dựng toàn bộ giao diện chat trên pi-tui (mục Interactive mode). Các chế độ print, JSON, RPC và SDK không vẽ TUI; xem chương Các chế độ chạy.
  • Lệnh pi config dựng riêng một TuiMainScreen nhỏ cho bộ chọn cấu hình (config-selector.ts:25).
  • Extension vẽ hộp thoại, widget, overlay qua ctx.ui bằng chính các component của pi-tui. Bề mặt API đó thuộc chương Extensions; tài liệu dặn rõ “Do not create a second terminal renderer inside an extension” (docs/tui.md:18).
InteractiveMode (coding agent)
Một cây component duy nhất, theme, keybinding app.*; nối sự kiện của session vào component.
TUI: TuiMainScreen | TuiAltScreen
Lịch render, so khung hình, focus, overlay, định tuyến phím và chuột. Hai cài đặt cùng một interface.
Component
Container, Text, Markdown, Editor, Image, SelectList, VStack, ScrollView... Mỗi cái trả về string[] cho một độ rộng.
Terminal: ProcessTerminal
Raw mode, bracketed paste, thương lượng giao thức bàn phím Kitty, tách luồng stdin thành từng chuỗi, ghi stdout.
Native helper
Clipboard, trạng thái phím bổ trợ, chế độ nhập VT của console Windows. Không bắt buộc; thiếu thì có đường lui.

Mô hình tư duy: component, frame và vi sai#

Mọi thứ trong chương xoay quanh một vòng: có sự kiện, component đổi state và xin vẽ lại; TUI gộp các lời xin đó thành một khung hình (frame), dựng khung bằng cách gọi render(width) trên cây, rồi so với khung trước để quyết định ghi byte nào.

sự kiện: token mới, phím bấm, resize, nhịp spinner, màu terminal trả về
   │  component đổi state, gọi tui.requestRender()
   ▼
requestRender()   gộp lại: tối đa một khung mỗi 16 ms; phím bấm thì vẽ ngay ở nextTick
   ▼
doRender()
   1. lines = root.render(width)        mỗi phần tử một dòng, rộng không quá width
   2. resolveFakeCursors(lines)         con trỏ giả thành video đảo (SGR 7 / 27)
   3. compositeOverlays(lines)          chồng overlay lên các dòng nền
   4. extractCursorPosition(lines)      tìm và gỡ CURSOR_MARKER, nhớ hàng/cột
   5. applyLineResets(lines)            nối \x1b[0m\x1b]8;;\x07 vào cuối mỗi dòng
   6. so với khung trước
        ├─ TuiMainScreen (regular):    từ dòng đầu tiên khác, ghi đè tới hết;
        │                               dòng dư ở đáy đẩy dòng cũ lên scrollback
        └─ TuiAltScreen  (fullscreen): so từng hàng của viewport, hàng khác thì ghi
   7. bọc trong \x1b[?2026h … \x1b[?2026l, ghi ra stdout, lưu làm khung trước
Đường đi của một khung hình (giản lược; thứ tự bước 2 tới 5 trong TuiAltScreen có thêm tô kết quả tìm kiếm, vùng chọn và thông báo nổi).

Từ vựng dùng trong phần còn lại:

Component
Đối tượng có render(width): string[]. Mỗi chuỗi là một dòng terminal, có thể chứa mã ANSI, và độ rộng hiển thị không được vượt width.
Retained mode (giữ cây)
Ứng dụng giữ một cây đối tượng component sống lâu, đổi state của chúng, còn TUI gọi lại render() trên cả cây mỗi khung. Ngược với immediate mode, nơi ứng dụng tự vẽ lại từ đầu mỗi khung. Component tự cache kết quả nên phần lớn lời gọi render() chỉ trả về mảng cũ.
Khung hình (frame)
Mảng dòng TUI có được sau bước 5. Khung trước được giữ trong previousLines (regular) hoặc previousScreen (fullscreen).
Viewport, scrollback
Viewport là phần đang hiện trên màn hình, cao bằng số hàng của terminal. Scrollback là lịch sử phía trên viewport mà terminal tự giữ; chương trình không sửa lại được những gì đã trôi vào đó.
Main screen, alternate screen
Terminal có hai bộ đệm. Main screen có scrollback. Alternate screen (bật bằng \x1b[?1049h) là một tấm cố định đúng bằng màn hình, không có scrollback, và khi tắt thì main screen hiện lại nguyên vẹn; vim và less dùng nó.
Synchronized output
Cặp CSI ?2026h / CSI ?2026l (\x1b[?2026h … \x1b[?2026l): terminal hỗ trợ sẽ gom mọi thay đổi ở giữa rồi hiện một lần, nên người dùng không thấy trạng thái vẽ dở. Terminal không hỗ trợ thì bỏ qua cặp mã này.
Vi sai (differential rendering)
Chỉ ghi những dòng (hoặc hàng) khác với khung trước, thay vì vẽ lại toàn bộ.

Các chuỗi điều khiển xuất hiện nhiều nhất trong code:

ChuỗiNghĩaAi dùng
\x1b[?2026h, \x1b[?2026lBắt đầu, kết thúc synchronized outputCả hai renderer, mỗi khung
\x1b[nA, \x1b[nB, \r, \r\nLên, xuống n dòng; về cột 0; xuống dòng (ở hàng cuối thì cuộn màn hình)TuiMainScreen: di chuyển tương đối
\x1b[r;cH, \x1b[nGĐặt con trỏ tuyệt đối; về cột nTuiAltScreen đặt từng hàng; regular đặt cột con trỏ phần cứng
\x1b[2K, \x1b[2J\x1b[H\x1b[3JXoá cả dòng; xoá màn hình, về góc, xoá scrollbackGhi đè một dòng; vẽ lại toàn bộ ở regular
\x1b[?1049h, \x1b[?7l, \x1b[?1006hVào alternate screen; tắt tự xuống dòng; báo chuột dạng SGRTuiAltScreen
\x1b[?2004h, \x1b[>7u, \x1b[>4;2mBracketed paste; đẩy cờ bàn phím Kitty; modifyOtherKeys mức 2ProcessTerminal lúc khởi động
\x1b]10;?\x07, \x1b]11;?\x07, \x1b]4;i;?\x07, \x1b[cHỏi màu chữ, màu nền, màu bảng i; DA1 làm mốc kết thúcSystem theme
\x1b]8;;url\x1b\\, \x1b]133;A\x07, \x1b]52;c;…\x07Hyperlink OSC 8; dấu đầu lời nhắc OSC 133; ghi clipboard OSC 52Markdown, tin nhắn, chọn văn bản ở fullscreen

Data model: Component, Container và TUI#

Component: hợp đồng duy nhất#

Toàn bộ thư viện dựa trên một interface rất nhỏ:

Component: render, handleInput, handleMouse, invalidate
pi/packages/tui/src/tui.tsdòng 117–142TypeScript
export interface Component {	/**	 * Render the component to lines for the given viewport width	 * @param width - Current viewport width	 * @returns Array of strings, each representing a line	 */	render(width: number): string[];	/** Optional handler for keyboard input when component has focus. */	handleInput?(data: string): void;	/** Optional normalized mouse handler. */	handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;	/**	 * If true, component receives key release events (Kitty protocol).	 * Default is false - release events are filtered out.	 */	wantsKeyRelease?: boolean;	/**	 * Invalidate any cached rendering state.	 * Called when theme changes or when component needs to re-render from scratch.	 */	invalidate(): void;}
  • render(width) là bắt buộc và thuần: nhận độ rộng, trả về các dòng. Component không biết mình nằm ở hàng nào trên màn hình, cũng không tự ghi ra terminal. README nhấn mạnh mỗi dòng “must not exceed width or the TUI will error” và khuyên dùng truncateToWidth() (README.md:260). “Độ rộng” ở đây là số cột hiển thị, không phải string.length: visibleWidth() bỏ qua mã ANSI, OSC, APC và tính ký tự CJK, emoji là hai cột (utils.ts:250).
  • handleInput(data) nhận dữ liệu thô từ terminal (có thể là một chuỗi escape), chỉ khi component đang giữ focus.
  • handleMouse(event) nhận sự kiện chuột đã chuẩn hoá, chỉ ở chế độ fullscreen (mục Overlay, focus và định tuyến input).
  • wantsKeyRelease: với giao thức Kitty, terminal báo cả lúc nhả phím; mặc định TUI lọc bỏ các sự kiện này.
  • invalidate() bắt buộc: xoá cache để lần render() sau dựng lại từ đầu. Ứng dụng gọi tui.invalidate() (lan xuống cả cây) khi đổi theme; chính TUI gọi khi terminal báo kích thước ô ảnh mới.

Vì sao invalidate() bắt buộc mà không tuỳ chọn? Vì cache là mặc định. Mẫu chuẩn trong README là giữ cachedWidth và cachedLines, trả mảng cũ nếu độ rộng không đổi (README.md:888–915). Một component đã cache chuỗi có mã màu thì khi theme đổi sẽ vẽ màu cũ mãi mãi nếu không có đường xoá cache; tài liệu của coding agent cảnh báo đúng điều này (docs/tui.md:170).

Một hợp đồng ngầm nữa: style không được kéo sang dòng sau. TUI nối \x1b[0m (reset SGR) và \x1b]8;;\x07 (đóng hyperlink OSC 8) vào cuối mọi dòng (tui.ts:1450–1459, README.md:265). Nhờ vậy mỗi dòng độc lập: thuật toán vi sai ghi lại dòng 23 mà không cần biết dòng 22 đã để lại màu gì.

Con trỏ: CURSOR_MARKER và con trỏ giả#

Component trả về chuỗi, không trả về toạ độ. Vậy làm sao TUI biết đặt con trỏ phần cứng ở đâu? Vị trí đó cần cho bộ gõ IME (tiếng Trung, Nhật, Hàn...), vì cửa sổ gợi ý của bộ gõ hiện cạnh con trỏ của terminal. Component chèn một dấu vô hình vào đúng vị trí:

Focusable, CURSOR_MARKER và renderFakeCursor()
pi/packages/tui/src/tui.tsdòng 180–207TypeScript
export interface Focusable {	/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */	focused: boolean;}/** Type guard to check if a component implements Focusable */export function isFocusable(component: Component | null): component is Component & Focusable {	return component !== null && "focused" in component;}/** * Cursor position marker - APC (Application Program Command) sequence. * This is a zero-width escape sequence that terminals ignore. * Components emit this at the cursor position when focused. * TUI finds and strips this marker, then positions the hardware cursor there. */export const CURSOR_MARKER = "\x1b_pi:c\x07";/** Fake cursor markers - zero-width APC sequences around the text drawn as the cursor. See resolveFakeCursors(). */export const FAKE_CURSOR_START = "\x1b_pi:fc\x07";export const FAKE_CURSOR_END = "\x1b_pi:/fc\x07";// CURSOR_MARKER + FAKE_CURSOR_START + text + FAKE_CURSOR_END. Truncating the line right after the cursor cell drops the end.const FOCUSED_FAKE_CURSOR = /(\x1b_pi:c\x07)\x1b_pi:fc\x07(.*?)(?:\x1b_pi:\/fc\x07|$)/s;/** Wrap text in fake cursor markers. */export function renderFakeCursor(text: string): string {	return `${FAKE_CURSOR_START}${text}${FAKE_CURSOR_END}`;}

CURSOR_MARKER là một chuỗi APC (ESC _ … BEL) mà terminal bỏ qua. Mỗi khung, TUI quét dấu này trong các dòng thuộc viewport, tính cột bằng visibleWidth của phần trước dấu, gỡ dấu đi, rồi đặt con trỏ phần cứng ở đó (tui.ts:1479–1497). Con trỏ phần cứng mặc định vẫn ẩn; thứ người dùng thấy là con trỏ giả: ô dưới con trỏ được bọc bởi hai dấu APC khác và TUI đổi chúng thành \x1b[7m/\x1b[27m (video đảo). Khi bật showHardwareCursor, con trỏ giả ngay sau CURSOR_MARKER bị bỏ để chỉ còn con trỏ thật (tui.ts:1504–1510). Cơ chế dấu APC thay cho việc component tự ghi \x1b[7m là thay đổi phá vỡ tương thích của 1.2.0 (CHANGELOG.md:22).

Container: ghép cây#

Container: render() là phép nối các dòng của con
pi/packages/tui/src/tui.tsdòng 358–381, 405–418TypeScript
export class Container implements Component {	children: Component[] = [];	private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };	addChild(component: Component): void {		this.children.push(component);	}	removeChild(component: Component): void {		const index = this.children.indexOf(component);		if (index !== -1) {			this.children.splice(index, 1);		}	}	clear(): void {		this.children = [];	}	invalidate(): void {		for (const child of this.children) {			child.invalidate?.();		}	}	render(width: number): string[] {		const lines: string[] = [];		const mouseChildren: Array<{ component: Component; height: number }> = [];		for (const child of this.children) {			const childLines = child.render(width);			mouseChildren.push({ component: child, height: childLines.length });			for (const line of childLines) {				lines.push(line);			}		}		this.mouseLayout = { width, children: mouseChildren };		return lines;	}}
handleMouse() ở giữa (bị lược) dùng mouseLayout, tức chiều cao của từng con ghi lại ở lần render trước, để biết con trỏ chuột rơi vào con nào mà không phải render lại.

Đây là toàn bộ “bố cục” ở chế độ regular: xếp dọc, nối mảng. Không có toạ độ, không có lề, không có z-index. Một Container chứa Text, Spacer(1) và Markdown trả về các dòng của Text, rồi một dòng rỗng, rồi các dòng của Markdown. Vòng for thay cho push(...childLines) là có chủ ý: spread một mảng quá dài vượt giới hạn đối số của V8 và gây tràn ngăn xếp ở session dài (CHANGELOG.md:616). Bố cục có chiều cao ràng buộc (VStack, HStack, ScrollView) chỉ có nghĩa ở fullscreen; mục TuiAltScreen giải thích.

TUI: một interface, hai renderer#

TUI mở rộng Component: bản thân TUI là container gốc (children, addChild). Ngoài ra nó gom các trách nhiệm toàn cục: focus (setFocus), overlay, lắng nghe input thô (addInputListener trả về hàm huỷ), vòng đời (start/stop), lịch render (requestRender/renderNow), và hỏi màu terminal. fullRedraws là bộ đếm số lần vẽ lại toàn bộ, tiện cho test và cho việc đo đạc. ViewportTUI chỉ thêm setLayoutRoot(); hàm isViewportTUI() nhận ra nó qua một khoá Symbol.for(...), tức một symbol trong bảng đăng ký toàn cục, nên phép kiểm tra vẫn đúng với đối tượng tạo từ một bản sao khác của gói.

Hai cài đặt cùng kế thừa một lớp trừu tượng nội bộ, TuiBase (không export). Nó giữ focus, overlay, danh sách input listener và lịch render, còn việc ghi ra terminal được để lại cho lớp con theo kiểu template method:

TuiBase: phần chung, doRender() và các hook để lớp con cài
pi/packages/tui/src/tui.tsdòng 506–522, 552–562TypeScript
export abstract class TuiBase extends Container implements TUI {	abstract readonly mode: TuiMode;	public terminal: Terminal;	private focusedComponent: Component | null = null;	private inputListeners = new Set<TuiInputListener>();	/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */	public onDebug?: () => void;	private renderRequested = false;	private immediateRenderScheduled = false;	private renderTimer: NodeJS.Timeout | undefined;	private lastRenderAt = 0;	private static readonly MIN_RENDER_INTERVAL_MS = 16;	private showHardwareCursor = false;	private clearOnShrink = false;	protected fullRedrawCount = 0;	protected stopped = false;	protected abstract doRender(): void;	protected resetRenderState(): void {}	protected beforeTerminalStart(): void {}	protected afterTerminalStart(): void {}	protected beforeTerminalStop(_options: TuiStopOptions): void {}	protected afterTerminalStop(_options: TuiStopOptions): void {}
TuiMainScreenTuiAltScreen
mode"regular""fullscreen", là ViewportTUI
Bộ đệm terminalMain screen, giữ scrollbackAlternate screen, không scrollback
Khung trướcpreviousLines: cả tài liệupreviousScreen: đúng height hàng
Ai cuộnTerminal (scrollback gốc)Ứng dụng (ScrollView, chuột, phím)
ChuộtKhông bắt; terminal tự chọn chữ, cuộnBắt chuột SGR, tự chọn chữ, tự copy, tự mở link
Khi thoátĐể nguyên nội dung, đưa con trỏ xuống dướiRời alternate screen, in lại toàn bộ tài liệu vào main screen

Terminal: thứ duy nhất TUI nói chuyện#

TUI không đụng trực tiếp process.stdout; nó nhận một Terminal:

Interface thật lớn hơn bản trong README: README chỉ liệt kê start, stop, write, kích thước và vài lệnh con trỏ (README.md:757–773), còn code ở v1.4.0 đòi thêm drainInput, kittyProtocolActive, setTitle, setProgress (thanh tiến trình OSC 9;4 trên tab terminal), setProgramStatus (OSC 7501, bắt buộc từ 1.1.0, CHANGELOG.md:44) và restoreOnExit tuỳ chọn. Cài đặt thật là ProcessTerminal; các test dùng một VirtualTerminal dựng trên @xterm/headless (packages/tui/test/virtual-terminal.ts), nhờ đó kiểm tra được chính xác màn hình sau mỗi khung mà không cần terminal thật.

Vòng render: requestRender, nhịp 16 ms và renderNow#

Vấn đề. Khi mô hình stream, các sự kiện message_update tới dồn dập, mỗi sự kiện chỉ mang vài token. Interactive mode gọi ui.requestRender() sau mỗi sự kiện. Nếu mỗi lời gọi là một lần vẽ, phần lớn công sức là vẽ những khung không ai kịp thấy. Ngược lại, khi người dùng gõ phím, độ trễ dù 16 ms cũng cảm nhận được.

Giải pháp. TuiBase có ba lối vào và một bộ hẹn giờ:

renderNow(), requestRender() và requestImmediateRender()
pi/packages/tui/src/tui.tsdòng 1019–1053TypeScript
renderNow(force = false): void {	if (force) this.resetRenderState();	this.renderRequested = false;	this.cancelRenderTimer();	this.lastRenderAt = performance.now();	this.doRender();}requestRender(force = false): void {	if (force) {		this.resetRenderState();		this.requestImmediateRender();		return;	}	if (this.renderRequested) return;	this.renderRequested = true;	process.nextTick(() => this.scheduleRender());}private requestImmediateRender(): void {	this.cancelRenderTimer();	this.renderRequested = true;	if (this.immediateRenderScheduled) return;	this.immediateRenderScheduled = true;	process.nextTick(() => {		this.immediateRenderScheduled = false;		if (this.stopped || !this.renderRequested) return;		// A previously queued scheduleRender() can create a timer before this		// callback runs. User input must preempt that throttled frame.		this.cancelRenderTimer();		this.renderRequested = false;		this.lastRenderAt = performance.now();		this.doRender();	});}
scheduleRender(): giữ khoảng cách tối thiểu MIN_RENDER_INTERVAL_MS = 16 ms
pi/packages/tui/src/tui.tsdòng 1061–1079TypeScript
private scheduleRender(): void {	if (this.stopped || this.renderTimer || !this.renderRequested) {		return;	}	const elapsed = performance.now() - this.lastRenderAt;	const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed);	this.renderTimer = setTimeout(() => {		this.renderTimer = undefined;		if (this.stopped || !this.renderRequested) {			return;		}		this.renderRequested = false;		this.lastRenderAt = performance.now();		this.doRender();		if (this.renderRequested) {			this.scheduleRender();		}	}, delay);}
  1. requestRender()Đặt cờ renderRequested; nếu cờ đã bật thì không làm gì. Lời xin đầu tiên hẹn scheduleRender() ở process.nextTick, nên mọi lời xin trong cùng một lượt xử lý đồng bộ gộp thành một.
  2. scheduleRender()Tính thời gian từ khung trước; nếu chưa đủ 16 ms thì setTimeout cho phần còn thiếu, đủ rồi thì hẹn 0 ms. Trong lúc chờ, mọi requestRender() đều rơi vào cờ đã bật. Sau khi vẽ, nếu trong lúc vẽ có ai xin thêm, nó tự hẹn khung tiếp theo.
  3. requestImmediateRender()Đường nhanh cho input: huỷ bộ hẹn giờ đang chờ và vẽ ở nextTick. handleTerminalInput() gọi nó ngay sau khi component đang focus xử lý phím (tui.ts:1145–1156). Chú thích ghi lý do: trên Windows ngay cả setTimeout(0) có thể mất trọn một nhịp 16 ms (CHANGELOG 0.84.0, CHANGELOG.md:268).
  4. renderNow() và forcerenderNow() vẽ đồng bộ ngay. force = true gọi resetRenderState() trước, xoá khung trước nên khung kế tiếp là vẽ lại toàn bộ. Interactive mode dùng requestRender(true) sau khi quay lại từ Ctrl+Z (SIGCONT) và từ trình soạn thảo ngoài, vì lúc đó không còn biết màn hình đang hiện gì (interactive-mode.ts:4424–4428).

Cách gộp theo nhịp 16 ms (khoảng 60 khung mỗi giây) có từ 0.65.2, để sửa việc vẽ dồn dập khi stream nhiều (CHANGELOG.md:628). Hệ quả cho người viết component: gọi requestRender() thoải mái, nó rẻ; đừng tự gọi renderNow() trong vòng lặp nóng.

start() và stop()#

start() (tui.ts:950–966) đăng ký một handler cho sự kiện exit của tiến trình, gọi hook beforeTerminalStart() (fullscreen vào alternate screen ở đây), bật terminal với hai callback: input đi vào handleTerminalInput(), resize chỉ là một requestRender(). Rồi nó ẩn con trỏ, hỏi kích thước ô bằng pixel nếu terminal vẽ được ảnh (CSI 16 t), và xin khung đầu tiên. Handler exit là lưới an toàn của 1.4.0: nếu tiến trình gọi thẳng process.exit() mà không qua stop(), nó vẫn ghi đồng bộ các mã tắt chế độ chuột, bàn phím Kitty, bracketed paste và alternate screen, để shell của người dùng không bị kẹt (tui.ts:574–585, CHANGELOG.md:13). stop() làm ngược lại theo cùng khung: gỡ handler, huỷ bộ hẹn giờ render, gọi beforeTerminalStop(), hiện con trỏ, dừng terminal, rồi afterTerminalStop() (tui.ts:1006–1017).

Phía terminal, ProcessTerminal.start() làm phần việc với hệ điều hành:

ProcessTerminal.start(): raw mode, bracketed paste, resize, thương lượng bàn phím
pi/packages/tui/src/terminal.tsdòng 199–230TypeScript
start(onInput: (data: string) => void, onResize: () => void): void {	this.inputHandler = onInput;	this.resizeHandler = onResize;	// Save previous state and enable raw mode	this.wasRaw = process.stdin.isRaw || false;	if (process.stdin.setRawMode) {		process.stdin.setRawMode(true);	}	process.stdin.setEncoding("utf8");	process.stdin.resume();	// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~	process.stdout.write("\x1b[?2004h");	// Set up resize handler immediately	process.stdout.on("resize", this.resizeHandler);	// Refresh terminal dimensions - they may be stale after suspend/resume	// (SIGWINCH is lost while process is stopped). Unix only, best-effort.	refreshTerminalDimensions();	// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends	// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console	// events that lose modifier information. Must run AFTER setRawMode(true)	// since that resets console mode flags.	this.enableWindowsVTInput();	// Query Kitty keyboard protocol and fall back to modifyOtherKeys when DA confirms no Kitty response.	// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/	this.queryAndEnableKittyProtocol();}

Raw mode tắt việc terminal tự xử lý dòng: mỗi phím tới ngay, và Ctrl+C không còn phát SIGINT mà thành byte \x03 cho ứng dụng tự quyết (README nhắc điều này trong ví dụ đầu tiên, README.md:43). Bracketed paste (\x1b[?2004h) bảo terminal bọc nội dung dán trong \x1b[200~ … \x1b[201~, nhờ đó editor phân biệt được dán với gõ. Lệnh gửi SIGWINCH cho chính mình làm mới kích thước sau khi tiến trình bị tạm dừng rồi chạy lại, vì tín hiệu resize bị mất trong lúc dừng. Thương lượng bàn phím được trình bày ở mục Phân tích phím.

Thuật toán vi sai của TuiMainScreen#

Vấn đề. Ở chế độ regular, pi viết vào main screen như mọi chương trình dòng lệnh: nội dung cũ trôi lên scrollback, người dùng cuộn bằng con lăn của terminal và chọn chữ bằng chuột của terminal. TUI chỉ thật sự kiểm soát phần đuôi tài liệu đang nằm trong viewport. Nó phải tự nhớ con trỏ đang đứng ở dòng nào của tài liệu và viewport bắt đầu từ dòng nào, rồi di chuyển con trỏ một cách tương đối (lên, xuống n dòng), vì trên main screen hàng màn hình của một dòng tài liệu thay đổi mỗi khi nội dung cuộn.

State của TuiMainScreen: khung trước và các con trỏ dòng
pi/packages/tui/src/tui-main-screen.tsdòng 113–133TypeScript
export interface TuiMainScreenRenderState {	previousLines: string[];	previousWidth: number;	previousHeight: number;	cursorRow: number;	hardwareCursorRow: number;	maxLinesRendered: number;	previousViewportTop: number;}/** TUI implementation that renders into the terminal's main screen and scrollback. */export class TuiMainScreen extends TuiBase implements TUI {	readonly mode = "regular" as const;	private previousLines: string[] = [];	private previousKittyImageIds = new Set<number>();	private previousWidth = 0;	private previousHeight = 0;	private cursorRow = 0;	private hardwareCursorRow = 0;	private maxLinesRendered = 0;	private previousViewportTop = 0;
  • previousLines: khung trước, gồm cả tài liệu chứ không chỉ phần đang hiện, ở dạng đã nối reset.
  • previousViewportTop: chỉ số của dòng tài liệu đang nằm ở hàng trên cùng màn hình.
  • hardwareCursorRow: dòng tài liệu mà con trỏ terminal đang thật sự đứng; mọi lệnh di chuyển tính từ đây. cursorRow là dòng cuối của nội dung.
  • maxLinesRendered: mức cao nhất của “vùng làm việc”, chỉ dùng cho tuỳ chọn clearOnShrink.
  • previousKittyImageIds: id các ảnh Kitty đang hiện, để xoá khi dòng chứa chúng đổi.

Cặp captureRenderState()/restoreRenderState() chuyển state này sang một renderer mới khi interactive mode đổi qua lại giữa regular và fullscreen lúc đang chạy (tui-main-screen.ts:135–156; mục Interactive mode).

Chuẩn bị khung#

doRender(): kích thước, viewport, rồi bốn bước chuẩn bị chung
pi/packages/tui/src/tui-main-screen.tsdòng 247–275TypeScript
protected doRender(): void {	if (this.stopped) return;	const width = this.terminal.columns;	const height = this.terminal.rows;	const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;	const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;	const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;	let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;	let viewportTop = prevViewportTop;	let hardwareCursorRow = this.hardwareCursorRow;	const computeLineDiff = (targetRow: number): number => {		const currentScreenRow = hardwareCursorRow - prevViewportTop;		const targetScreenRow = targetRow - viewportTop;		return targetScreenRow - currentScreenRow;	};	// Render all components to get new lines	// Resolve fake cursors before compositing so overlays and line slicing see plain SGR codes	let newLines = this.resolveFakeCursors(this.render(width));	// Composite overlays into the rendered lines (before differential compare)	if (this.hasOverlayEntries) {		newLines = this.compositeOverlays(newLines, width, height);	}	// Extract cursor position before applying line resets (marker must be found first)	const cursorPos = this.extractCursorPosition(newLines, height);	newLines = this.applyLineResets(newLines);

computeLineDiff(targetRow) đổi cả hai đầu sang toạ độ màn hình (dòng tài liệu trừ dòng đầu viewport) rồi lấy hiệu: con trỏ đang ở hàng màn hình 8, cần tới hàng 5, thì kết quả là −3 và TUI ghi \x1b[3A. Thứ tự chuẩn bị có ý nghĩa. Overlay được ghép vào trước khi so sánh, nên với thuật toán, một hộp thoại nổi chỉ là vài dòng đổi nội dung. Dấu con trỏ phải được tìm trước khi nối reset, vì sau bước đó vị trí của nó trong chuỗi không còn ý nghĩa.

Chuỗi quyết định#

Bốn trường hợp vẽ lại toàn bộ được xét trước tiên
pi/packages/tui/src/tui-main-screen.tsdòng 331–361TypeScript
// First render - just output everything without clearing (assumes clean screen)if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {	logRedraw("first render");	fullRender(false);	return;}// Width changes always need a full re-render because wrapping changes.if (widthChanged) {	logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);	fullRender(true);	return;}// Height changes normally need a full re-render to keep the visible viewport aligned,// but Termux changes height when the software keyboard shows or hides.// In that environment, a full redraw causes the entire history to replay on every toggle.if (heightChanged && !isTermuxSession()) {	logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);	fullRender(true);	return;}// Content shrunk below the working area and no overlays - re-render to clear empty rows// (overlays need the padding, so only do this when no overlays are active)// Configurable via setClearOnShrink()if (this.getClearOnShrink() && newLines.length < this.maxLinesRendered && !this.hasOverlayEntries) {	logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);	fullRender(true);	return;}
Điều kiện (theo thứ tự xét)Hành độngVì sao
Khung đầu tiên: chưa có previousLines, kích thước chưa đổifullRender(false): in mọi dòng, không xoá gìGiả định màn hình sạch; giữ nguyên những gì shell in trước đó
Độ rộng đổifullRender(true)Mọi đoạn văn phải ngắt dòng lại, không dòng cũ nào còn đúng
Chiều cao đổi, trừ TermuxfullRender(true)Giữ viewport thẳng hàng; Termux đổi chiều cao mỗi lần bàn phím ảo hiện hay ẩn, vẽ lại sẽ phát lại cả lịch sử
clearOnShrink bật, nội dung ngắn hơn maxLinesRendered, không có overlayfullRender(true)Xoá các hàng trống còn sót. Mặc định tắt để đỡ vẽ lại trên terminal chậm (tui.ts:608–615)
Không dòng nào đổiChỉ đặt lại con trỏ phần cứngKhông ghi khung nào
Chỉ còn đổi ở các dòng đã bị xoá khỏi cuốiXoá dòng thừa tại chỗ; nếu phải lùi lên trên viewport, hoặc thừa hơn một màn hình, thì fullRender(true)Không vẽ được những gì không nhìn thấy
Dòng đầu tiên đổi nằm trên viewportfullRender(true)Phần đã trôi vào scrollback không sửa được bằng di chuyển tương đối
Còn lạiGhi từ firstChanged tới lastChangedĐường nhanh, xảy ra ở hầu hết các khung

Lịch sử của dòng “chiều cao đổi” cho thấy đây là thoả hiệp chứ không phải chân lý. Ở 0.51.1, đổi chiều cao thôi không vẽ lại toàn bộ để bớt nhấp nháy (CHANGELOG.md:904); ở 0.62.0 lại bật lại vì editor và footer bị lệch khi resize (CHANGELOG.md:778); ngoại lệ Termux có sau đó.

Vẽ lại toàn bộ#

fullRender(): xoá màn hình và scrollback, in lại cả tài liệu
pi/packages/tui/src/tui-main-screen.tsdòng 278–288, 301–320TypeScript
const fullRender = (clear: boolean): void => {	this.fullRedrawCount += 1;	const output = new BoundedTerminalWriter((data) => this.terminal.write(data));	output.append("\x1b[?2026h"); // Begin synchronized output	if (clear) {		output.append(this.deleteKittyImages(this.previousKittyImageIds));		output.append("\x1b[2J\x1b[H\x1b[3J"); // Clear screen, home, then clear scrollback	}	for (let i = 0; i < newLines.length; i++) {		if (i > 0) output.append("\r\n");		const line = newLines[i];		output.append(line);	}	output.append("\x1b[?2026l"); // End synchronized output	output.flush();	this.cursorRow = Math.max(0, newLines.length - 1);	this.hardwareCursorRow = this.cursorRow;	// Reset max lines when clearing, otherwise track growth	if (clear) {		this.maxLinesRendered = newLines.length;	} else {		this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);	}	const bufferLength = Math.max(height, newLines.length);	this.previousViewportTop = Math.max(0, bufferLength - height);	this.positionHardwareCursor(cursorPos, newLines.length);	this.previousLines = newLines;	this.previousKittyImageIds = this.collectKittyImageIds(newLines);	this.previousWidth = width;	this.previousHeight = height;};
Nhánh bị lược (289–300) xử lý ảnh Kitty cao nhiều hàng: chừa trước các hàng trống rồi mới đặt ảnh, để ảnh không làm cuộn lệch.

\x1b[2J\x1b[H\x1b[3J xoá màn hình, đưa con trỏ về góc trên trái, rồi xoá scrollback; sau đó toàn bộ tài liệu được in lại. Ở một session dài, đó là hàng nghìn dòng. BoundedTerminalWriter chia đầu ra thành từng khúc 1 MiB và không cắt đôi cặp surrogate UTF-16, để một lần vẽ lớn không bao giờ dựng ra một chuỗi vượt giới hạn độ dài chuỗi của V8 (tui-main-screen.ts:9–17).

Một chi tiết nhỏ quyết định nhánh nào được chọn: resetRenderState() đặt previousWidth = -1 chứ không phải 0 (tui-main-screen.ts:158–166). TUI mới tạo có previousWidth = 0, được hiểu là “chưa vẽ lần nào” và đi vào nhánh khung đầu tiên không xoá màn hình. Còn sau requestRender(true), giá trị −1 khác mọi độ rộng thật nên được hiểu là “độ rộng đổi”, đi vào nhánh có xoá.

Tìm vùng thay đổi#

So từng cặp dòng cùng chỉ số; dòng nối thêm kéo lastChanged tới cuối
pi/packages/tui/src/tui-main-screen.tsdòng 363–390TypeScript
// Find first and last changed lineslet firstChanged = -1;let lastChanged = -1;const maxLines = Math.max(newLines.length, this.previousLines.length);for (let i = 0; i < maxLines; i++) {	const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";	const newLine = i < newLines.length ? newLines[i] : "";	if (oldLine !== newLine) {		if (firstChanged === -1) {			firstChanged = i;		}		lastChanged = i;	}}const appendedLines = newLines.length > this.previousLines.length;if (appendedLines) {	if (firstChanged === -1) {		firstChanged = this.previousLines.length;	}	lastChanged = newLines.length - 1;}if (firstChanged !== -1) {	const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);	firstChanged = expandedRange.firstChanged;	lastChanged = expandedRange.lastChanged;}const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;

Phép so sánh là so chuỗi tại cùng chỉ số, không có thuật toán dãy con chung dài nhất kiểu diff. Nếu một dòng được chèn vào giữa, mọi dòng bên dưới lệch đi một chỉ số và đều bị coi là đổi. Cách đơn giản này chấp nhận được nhờ quy tắc ở mục sau: firstChanged không được nằm trên viewport, nên đoạn phải ghi lại không bao giờ dài hơn một màn hình cộng số dòng mới. appendStart đánh dấu trường hợp chỉ có dòng nối thêm ở cuối, không dòng cũ nào đổi.

Hai lối tắt và một lối ra#

Không có gì đổi; hoặc thay đổi nằm trên viewport
pi/packages/tui/src/tui-main-screen.tsdòng 392–398, 450–456TypeScript
// No changes - but still need to update hardware cursor position if it movedif (firstChanged === -1) {	this.positionHardwareCursor(cursorPos, newLines.length);	this.previousViewportTop = prevViewportTop;	this.previousHeight = height;	return;}// Differential rendering can only touch what was actually visible.// If the first changed line is above the previous viewport, we need a full redraw.if (firstChanged < prevViewportTop) {	logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);	fullRender(true);	return;}

Kiểm tra ở dòng 452 là giới hạn căn bản của chế độ regular. Những dòng đã trôi vào scrollback thuộc về terminal. Không có lệnh di chuyển tương đối nào với tới chúng, và nếu có cũng không an toàn vì người dùng có thể đang cuộn xem. Cách duy nhất để sửa là xoá sạch và in lại.

Ghi phần thay đổi#

Đường nhanh: tới dòng đầu tiên đổi, ghi đè tới lastChanged
pi/packages/tui/src/tui-main-screen.tsdòng 458–494, 517, 546–547TypeScript
// Render from first changed line to end// Keep updates wrapped in synchronized output while writing bounded chunks.const output = new BoundedTerminalWriter((data) => this.terminal.write(data));output.append("\x1b[?2026h"); // Begin synchronized outputoutput.append(this.deleteChangedKittyImages(firstChanged, lastChanged));const prevViewportBottom = prevViewportTop + height - 1;const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;if (moveTargetRow > prevViewportBottom) {	const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));	const moveToBottom = height - 1 - currentScreenRow;	if (moveToBottom > 0) {		output.append(`\x1b[${moveToBottom}B`);	}	const scroll = moveTargetRow - prevViewportBottom;	output.append("\r\n".repeat(scroll));	prevViewportTop += scroll;	viewportTop += scroll;	hardwareCursorRow = moveTargetRow;}// Move cursor to first changed line (use hardwareCursorRow for actual position)const lineDiff = computeLineDiff(moveTargetRow);if (lineDiff > 0) {	output.append(`\x1b[${lineDiff}B`); // Move down} else if (lineDiff < 0) {	output.append(`\x1b[${-lineDiff}A`); // Move up}output.append(appendStart ? "\r\n" : "\r"); // Move to column 0// Only render changed lines (firstChanged to lastChanged), not all lines to end// This reduces flicker when only a single line changes (e.g., spinner animation)const renderEnd = Math.min(lastChanged, newLines.length - 1);for (let i = firstChanged; i <= renderEnd; i++) {	if (i > firstChanged) output.append("\r\n");	const line = newLines[i];	const isImage = isImageLine(line);	output.append("\x1b[2K"); // Clear current line	output.append(line);}
Đoạn bị lược ở giữa vòng lặp xử lý ảnh Kitty nhiều hàng và kiểm tra độ rộng dòng (mục Cạm bẫy).
  • Cuộn trước nếu cần (465–476). Nếu dòng đích nằm dưới đáy viewport cũ, TUI xuống hàng cuối rồi ghi đủ số \r\n để terminal cuộn. Lệnh CSI n B không làm màn hình cuộn khi con trỏ đã ở hàng cuối; chỉ ký tự xuống dòng mới làm được.
  • Dòng nối thêm (464, 486). Cùng lý do: khi chỉ có dòng mới ở cuối, TUI đứng ở dòng ngay trước rồi ghi \r\n, chứ không “di chuyển xuống” một dòng chưa tồn tại.
  • Ghi đè (517, 546). Mỗi dòng: \x1b[2K xoá cả hàng, rồi in nội dung mới. Nhờ xoá trước, TUI không cần biết dòng cũ dài bao nhiêu.
  • Dừng ở lastChanged (488–490). Chú thích trong code ghi rõ: chỉ ghi các dòng đổi để giảm nhấp nháy khi chỉ một dòng giữa màn hình đổi, ví dụ spinner.

Dọn dòng thừa và ghi sổ#

Xoá các dòng cũ dư ra, đóng synchronized output, cập nhật state
pi/packages/tui/src/tui-main-screen.tsdòng 549–568, 601–616TypeScript
// Track where cursor ended up after renderinglet finalCursorRow = renderEnd;// If we had more lines before, clear them and move cursor backif (this.previousLines.length > newLines.length) {	// Move to end of new content first if we stopped before it	if (renderEnd < newLines.length - 1) {		const moveDown = newLines.length - 1 - renderEnd;		output.append(`\x1b[${moveDown}B`);		finalCursorRow = newLines.length - 1;	}	const extraLines = this.previousLines.length - newLines.length;	for (let i = newLines.length; i < this.previousLines.length; i++) {		output.append("\r\n\x1b[2K");	}	// Move cursor back to end of new content	output.append(`\x1b[${extraLines}A`);}output.append("\x1b[?2026l"); // End synchronized output// Track cursor position for next render// cursorRow tracks end of content (for viewport calculation)// hardwareCursorRow tracks actual terminal cursor position (for movement)this.cursorRow = Math.max(0, newLines.length - 1);this.hardwareCursorRow = finalCursorRow;// Track terminal's working area (grows but doesn't shrink unless cleared)this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);// Position hardware cursor for IMEthis.positionHardwareCursor(cursorPos, newLines.length);this.previousLines = newLines;this.previousKittyImageIds = this.collectKittyImageIds(newLines);this.previousWidth = width;this.previousHeight = height;

Dòng 608 là chỗ TUI “theo dõi” cú cuộn mà chính nó gây ra: nếu con trỏ đã xuống quá đáy viewport cũ thì terminal đã cuộn, nên dòng đầu viewport mới là finalCursorRow - height + 1. Cuối cùng positionHardwareCursor() đưa con trỏ terminal tới chỗ CURSOR_MARKER, bằng CSI A/B cho hàng và CSI G (cột tuyệt đối) cho cột, rồi ghi lại hardwareCursorRow (tui-main-screen.ts:624–655). Lệnh này được ghi sau khi đã đóng synchronized output. Vì vậy sau mỗi khung, con trỏ terminal đứng ở ô soạn thảo chứ không ở cuối nội dung, và khung tiếp theo bắt đầu tính đường đi từ đó.

Trace: tin nhắn đang stream dài thêm một dòng#

Giờ ghép các mảnh lại trong một kịch bản cụ thể ở chế độ regular. Terminal rộng 40 cột, cao 12 hàng. Session đã có hơn một màn hình nội dung; mô hình đang stream một đoạn văn, và token vừa tới làm đoạn văn xuống thêm một dòng. Các con số bên dưới là ví dụ minh hoạ, nhưng mỗi bước đi đúng nhánh code đã trích ở mục trước.

1. Từ token tới requestRender()#

  1. message_updateAgentSession phát sự kiện mang assistant message từng phần (xem chương AgentSession). InteractiveMode.handleEvent() lưu message và gọi streamingComponent.updateContent(message, true) (interactive-mode.ts:3481–3484).
  2. updateContent()AssistantMessageComponent xoá container nội dung rồi dựng lại: một Spacer(1), rồi một Markdown mới cho mỗi khối text (assistant-message.ts:95–117). Chưa có byte nào ra terminal; chỉ có cây đổi.
  3. requestRender()handleEvent() gọi ui.requestRender() (:3513). Giả sử khung trước mới vẽ cách đây 5 ms: scheduleRender() hẹn 11 ms. Những message_update tới trong 11 ms đó chỉ thay cây, không thêm khung nào.
  4. doRender()Bộ hẹn giờ chạy; TuiMainScreen.doRender() gọi render(40) trên cả cây.

2. render() trả về gì#

Gốc là chính TUI, một container với bảy con: tài liệu (header, danh sách tài nguyên, chat), tin nhắn chờ, trạng thái, widget phía trên, editor, widget phía dưới, footer (mục Interactive mode). Khi đang stream, tin nhắn chờ và trạng thái rỗng (chỉ báo “working” được nhúng vào viền trên của editor), widget phía trên là một Spacer(1), editor ba dòng, footer hai dòng. AssistantMessageComponent.render() còn gắn dấu OSC 133 vào dòng đầu (A) và dòng cuối (B, C) của tin nhắn không có tool call (assistant-message.ts:80–89). Đuôi của hai khung:

Ví dụ minh hoạ: đuôi khung trước (30 dòng) và khung mới (31 dòng), 40 × 12

dòng   khung trước                         khung mới
 18    nội dung cũ   (hàng 0 màn hình)     nội dung cũ
 ..    ...                                 ...
 21    [A] dòng trống đầu tin nhắn         [A] dòng trống đầu tin nhắn
 22    " Hàm render() trả về một mảng"     " Hàm render() trả về một mảng"
 23    [B][C] " chuỗi, mỗi chuỗi là"       " chuỗi, mỗi chuỗi là một dòng"    đổi
 24    Spacer của widget                   [B][C] " đã cắt vừa độ rộng."      đổi
 25    viền trên editor ── ⠋ Working       Spacer của widget                  đổi
 26    ‸ dòng nhập (CURSOR_MARKER)         viền trên editor ── ⠙ Working      đổi
 27    viền dưới editor                    ‸ dòng nhập (CURSOR_MARKER)        đổi
 28    footer: thư mục, nhánh git          viền dưới editor                   đổi
 29    footer: token, ngữ cảnh (hàng 11)  footer: thư mục, nhánh git         đổi
 30                                        footer: token, ngữ cảnh            mới

[A] = \x1b]133;A\x07      [B][C] = \x1b]133;B\x07\x1b]133;C\x07
mỗi dòng Markdown được đệm dấu cách đủ 40 cột; mỗi dòng kết thúc bằng \x1b[0m\x1b]8;;\x07
Dòng 23 đổi vì hai lẽ: nó không còn là dòng cuối nên mất dấu [B][C], và đoạn văn ngắt dòng lại khi có thêm chữ. Mọi dòng phía dưới lệch xuống một chỉ số.

3. Thuật toán quyết định#

BướcGiá trịNhánh
Kích thước40 × 12, như khung trướcKhông vẽ lại toàn bộ vì resize
Viewport và con trỏprevViewportTop = 18, hardwareCursorRow = 26 (dòng nhập, do khung trước đặt)
Vùng đổifirstChanged = 23; có dòng nối thêm nên lastChanged = 30appendStart = false vì 23 ≠ 30
Trên viewport?23 < 18 là saiĐi đường nhanh
Cần cuộn trước?prevViewportBottom = 18 + 12 − 1 = 29; đích 23 không vượt đáyKhông
Di chuyển(23 − 18) − (26 − 18) = −3\x1b[3A, rồi \r
Phạm vi ghirenderEnd = min(30, 30) = 30Tám dòng, 23 tới 30

4. Byte ghi ra#

Ví dụ minh hoạ: ba lần ghi ra stdout (ESC viết là \x1b, R = \x1b[0m\x1b]8;;\x07)

lần 1: BoundedTerminalWriter, một khúc
\x1b[?2026h                                bắt đầu synchronized output
\x1b[3A\r                                  từ dòng 26 lên dòng 23, cột 0
\x1b[2K" chuỗi, mỗi chuỗi là một dòng"…R    dòng 23 ở hàng màn hình 5
\r\n\x1b[2K[B][C]" đã cắt vừa độ rộng."…R   dòng 24, hàng 6
\r\n\x1b[2KR                               dòng 25, Spacer
\r\n\x1b[2K── ⠙ Working ───…R                dòng 26, hàng 8
\r\n\x1b[2K\x1b[7m \x1b[27m + 39 dấu cách R   dòng 27, con trỏ giả
\r\n\x1b[2K────…R                           dòng 28
\r\n\x1b[2K(footer: thư mục)R               dòng 29, hàng 11, hàng cuối
\r\n\x1b[2K(footer: token)R                 dòng 30: \r\n ở hàng cuối làm terminal
                                           cuộn lên một hàng; dòng 18 vào scrollback
\x1b[?2026l                                kết thúc synchronized output

lần 2: positionHardwareCursor()
\x1b[3A\x1b[1G                              từ dòng 30 lên dòng 27, cột 1
lần 3
\x1b[?25l                                  con trỏ phần cứng vẫn ẩn
Không có lệnh “cuộn” nào. Cú cuộn là tác dụng phụ của ký tự xuống dòng ở hàng cuối, và đó đúng là cách một dòng đi vào scrollback gốc của terminal.

5. Sau khung#

TrườngTrướcSau
previousLines.length3031
previousViewportTop1819 = max(18, 30 − 12 + 1)
hardwareCursorRow2627, do positionHardwareCursor()
cursorRow2930
maxLinesRendered3031

Giả sử trong cùng khung đó còn có một thay đổi ở dòng 10, ví dụ người dùng vừa bấm phím mở rộng output của tool, hay một hàng tool đã trôi lên trên viewport đổi nội dung. Khi đó firstChanged = 10 < 18 và TUI đi nhánh vẽ lại toàn bộ: \x1b[?2026h, \x1b[2J\x1b[H\x1b[3J, cả 31 dòng nối bằng \r\n, \x1b[?2026l. Với 31 dòng thì rẻ; với một session năm nghìn dòng thì terminal phải nhận lại cả năm nghìn dòng, và trên terminal không hỗ trợ synchronized output người dùng thấy lịch sử chạy lại.

TuiAltScreen: chế độ toàn màn hình#

Từ 1.0.0, interactive mode chạy fullscreen mặc định; muốn giữ scrollback như cũ thì đặt tuiMode là "regular" hoặc chạy --tui-mode regular (coding-agent CHANGELOG.md:328, :345). Chế độ này dùng TuiAltScreen, và khác TuiMainScreen ở ba điểm căn bản. Khung hình không còn là cả tài liệu mà đúng height hàng. Màn hình được vẽ bằng toạ độ tuyệt đối. Và vì alternate screen không có scrollback, không có gì bị “đóng băng”: dòng nào của tài liệu cũng vẽ lại được bất cứ lúc nào.

Vào và ra alternate screen#

beforeTerminalStart(): vào alternate screen, tắt autowrap, bật chuột
pi/packages/tui/src/tui-alt-screen.tsdòng 372–385TypeScript
const term = process.env.TERM?.toLowerCase() ?? "";// Multiplexers can lag when every pointer movement is forwarded. Button-motion// tracking preserves clicks, wheel events, selections, and scrollbar dragging.const mouseSequence =	process.env.TMUX !== undefined ||	process.env.ZELLIJ !== undefined ||	process.env.STY !== undefined ||	term.startsWith("tmux") ||	term.startsWith("screen")		? ENABLE_BUTTON_MOTION_MOUSE		: ENABLE_ALL_MOTION_MOUSE;this.terminal.write(	`${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`,);

Giá trị của các hằng nằm ở đầu file (tui-alt-screen.ts:63–77). Mã chuột gồm 1000 (nhấn và nhả), 1002 (kéo khi giữ nút), 1003 (mọi chuyển động), 1004 (terminal báo được hay mất focus bằng \x1b[I/\x1b[O) và 1006 (toạ độ dạng SGR, không giới hạn 223 cột như dạng cũ). Dưới tmux, Zellij hay GNU Screen, TUI bỏ 1003 vì chuyển tiếp mọi chuyển động chuột làm các multiplexer này chậm (CHANGELOG.md:233). Tắt autowrap (\x1b[?7l) để việc ghi kín cột cuối không làm terminal tự xuống dòng và cuộn. Cũng trong hook này, nếu terminal là iTerm2 thì khả năng vẽ ảnh bị tạm tắt: giao thức ảnh của iTerm2 không xoá hay cắt được một ảnh đã đặt, nên khi viewport cuộn ảnh cũ sẽ đè lên chữ mới (tui-alt-screen.ts:356–360, README.md:688).

afterTerminalStop(): rời alternate screen và in lại cả tài liệu
pi/packages/tui/src/tui-alt-screen.tsdòng 403–423TypeScript
protected override afterTerminalStop(options: TuiStopOptions): void {	if (!this.altScreenActive) return;	this.altScreenActive = false;	if (options.preserveScreen) {		this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`);	} else {		const width = Math.max(1, this.terminal.columns);		const documentLines = this.resolveFakeCursors(this.render(width)).map((line) =>			line.replace(OSC133_ZONE_PREFIX, ""),		);		this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(			(line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)),		);		let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`;		for (let row = 0; row < this.lastDocument.length; row++) {			if (row > 0) buffer += "\r\n";			buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`;		}		buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`;		this.terminal.write(buffer);	}

Khi thoát, alternate screen biến mất cùng nội dung của nó. Để nội dung không mất khỏi terminal, mặc định TuiAltScreen render cả cây một lần nữa ở dạng tài liệu không giới hạn chiều cao (gỡ dấu OSC 133 và dấu con trỏ), rồi in vào main screen (README.md:62); gọi stop({ preserveScreen: true }) thì bỏ bước này. Coding agent dùng đúng lựa chọn đó: nó dừng TuiAltScreen mà giữ màn hình, chuyển hẳn sang một TuiMainScreen, và để renderer này in transcript; nếu phiên từng chạy ở regular rồi mới chuyển sang fullscreen, state vi sai đã chụp giúp phần in trước đó ở main screen không bị in lặp (mục Interactive mode).

Bố cục: VStack, HStack, ScrollView#

Ở chế độ regular, bố cục là phép nối dọc vô hạn và terminal lo việc cuộn. Ở fullscreen, ứng dụng phải nói vùng nào được bao nhiêu hàng, vùng nào cuộn được. pi-tui có ba component bố cục, chỉ có nghĩa đầy đủ trên ViewportTUI (README.md:121). Chúng tự khai báo bằng một phương thức đặt tên bằng symbol LAYOUT_NODE, trả về một nút bố cục: vstack/hstack với danh sách mục, hoặc scroll với một con duy nhất (layout-node.ts:10–42). Mỗi mục của stack có basis, grow, shrink, minSize, maxSize và visible, như flexbox thu gọn; allocateStackSizes() chia chỗ theo trọng số (stack.ts:135–154). Bố cục của coding agent:

createChatViewport(): transcript cuộn được, phía dưới là dock cố định
pi/packages/coding-agent/src/modes/interactive/chat-viewport.tsdòng 22–46TypeScript
export function createChatViewport(options: ChatViewportOptions): ChatViewport {	const transcript = new ScrollView(options.document, {		follow: "end",		primary: true,		overscroll: "chain",		scrollbar: options.scrollbar ?? "auto",		...(options.scrollbarTrackStyle === undefined ? {} : { scrollbarTrackStyle: options.scrollbarTrackStyle }),		...(options.scrollbarThumbStyle === undefined ? {} : { scrollbarThumbStyle: options.scrollbarThumbStyle }),	});	const dock = new VStack([		{ component: options.pendingMessages, shrink: 1, minSize: 0 },		{ component: options.status, shrink: 1, minSize: 0 },		...(options.widgetsAbove === undefined ? [] : [{ component: options.widgetsAbove, shrink: 1, minSize: 0 }]),		{ component: options.editor, shrink: 1, minSize: 3 },		...(options.widgetsBelow === undefined ? [] : [{ component: options.widgetsBelow, shrink: 1, minSize: 0 }]),		{ component: options.footer, shrink: 1, minSize: 0 },	]);	return {		transcript,		root: new VStack([			{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },			{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },		]),	};}

Transcript có basis: 0, grow: 1: nó lấy mọi hàng còn lại. Dock (tin nhắn chờ, trạng thái, widget, editor, footer) có basis: "auto": nó cao đúng bằng nội dung, và mỗi phần có thể co lại, riêng editor không dưới ba hàng. ScrollView của transcript là primary, tức là nơi nhận phím cuộn và con lăn khi con trỏ chuột không nằm trên vùng cuộn nào khác, với follow: "end": dính đáy khi đang ở đáy.

layoutComponent(): nhánh scroll, nơi scrollTop được quyết định mỗi khung
pi/packages/tui/src/layout.tsdòng 136–167TypeScript
if (node.type === "scroll") {	const previousScrollTop = node.state.scrollTop;	const contentWidth = node.state.getContentWidth(safeWidth);	const childBox = layoutComponent(		context,		node.component,		x,		y - previousScrollTop,		contentWidth,		undefined,		clip,	);	const contentHeight = childBox.rect.height;	const viewportHeight = height === undefined ? contentHeight : Math.max(0, Math.floor(height));	node.state.updateLayout(contentHeight, viewportHeight, context.requestRender);	translateBox(childBox, previousScrollTop - node.state.scrollTop);	const scrollView = node.state as ScrollView;	if (node.state.primary || !context.primaryScrollView) context.primaryScrollView = scrollView;	const rect = { x, y, width: safeWidth, height: viewportHeight };	const childClip = intersect(clip, rect);	const box: LayoutBox = {		component,		rect,		clip: childClip,		children: [childBox],		scrollView,		scrollContentLines: renderCached(context, node.component, contentWidth),		layer: 0,	};	childBox.parent = box;	updateClips(childBox, childClip);	return box;

Con của ScrollView được bố trí với chiều cao không giới hạn tại toạ độ y - scrollTop. Biết chiều cao nội dung rồi, updateLayout() quyết định scrollTop mới, và cây con được dịch theo độ chênh:

ScrollView.updateLayout(): đang theo đáy thì nhảy tới đáy, không thì giữ chỗ
pi/packages/tui/src/components/scroll-view.tsdòng 189–201TypeScript
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void {	this.contentHeight = Math.max(0, Math.floor(contentHeight));	this.currentViewportHeight = Math.max(0, Math.floor(viewportHeight));	this.requestRenderCallback = requestRender;	const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);	if (this.followingEnd) this.currentScrollTop = maxScrollTop;	else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));	if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false;	if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) {		this.followingEnd = true;	}	if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar();}

Đây là toàn bộ cơ chế “tự cuộn theo output khi đang ở đáy, giữ nguyên chỗ khi người dùng đã cuộn lên” mà README mô tả (README.md:751). Cuộn lên bằng con lăn gọi scrollBy(), hàm này tắt followingEnd nếu vị trí mới không phải đáy, và trả về phần delta chưa dùng để chuyển tiếp cho ScrollView bao ngoài (scroll-view.ts:148–162).

renderLayoutFrame() dựng một cây LayoutBox (toạ độ, vùng cắt, các dòng của từng component) rồi sơn nó vào đúng height chuỗi (layout.ts:379–408). Mỗi khung có một cache render riêng, nên mỗi component được render tối đa một lần cho mỗi độ rộng (layout.ts:68–81); cache dài hạn vẫn là cache bên trong component. Khi sơn, một hộp rộng kín màn hình lên một hàng chưa có gì thì gán thẳng tham chiếu chuỗi thay vì ghép lại qua phân tích ANSI và grapheme (layout.ts:345–354), một thay đổi giảm lượng cấp phát mỗi khung khoảng 9 tới 18 lần (CHANGELOG.md:209). Không có layout root thì TUI tự bọc các con trong một ScrollView ngầm, theo đáy và primary (tui-alt-screen.ts:262–269).

doRender(): so từng hàng#

  1. Dựng màn hìnhrenderLayoutFrame(root, width, height) trả về đúng height dòng. Nếu đang tìm kiếm và kết quả cần cuộn để lộ ra, khung được dựng lại lần hai.
  2. Trang tríGỡ dấu OSC 133 ở đầu dòng, đổi con trỏ giả, tô kết quả tìm kiếm, ghép nhãn “nhảy tới cuối”, ghép overlay, cắt về height hàng, tô vùng chọn, ghép thông báo nổi (flash).
  3. Chuẩn hoáLấy vị trí con trỏ, nối reset, và cắt dòng quá rộng. Chế độ regular ném lỗi ở tình huống này; fullscreen thì im lặng cắt bớt.
  4. So và ghiKhung đầu hoặc đổi kích thước thì \x1b[2J và vẽ mọi hàng. Nếu không, chỉ những hàng có chuỗi khác previousScreen[row], mỗi hàng bằng \x1b[r;1H\x1b[2K rồi nội dung. Cuối cùng đặt con trỏ tuyệt đối và đóng synchronized output, tất cả trong một lần ghi.

Cùng sự kiện ở fullscreen#

Chạy lại kịch bản của mục Trace trên TuiAltScreen, vẫn 40 × 12. Dock cao 6 hàng (Spacer, ba hàng editor, hai hàng footer), nên allocateStackSizes() cho transcript basis 0 nâng lên minSize 1, rồi chia phần dư 5 hàng cho mục duy nhất có grow: transcript được 6 hàng, dock 6 hàng. Tài liệu bây giờ chỉ gồm header, danh sách tài nguyên và chat, tức 24 dòng cũ (0–23) và 25 dòng mới. Đang theo đáy, nên scrollTop đi từ 24 − 6 = 18 lên 25 − 6 = 19.

Ví dụ minh hoạ: cùng khung đó ở TuiAltScreen, 40 × 12

hàng   trước (scrollTop 18)        sau (scrollTop 19)
  0    tài liệu 18                 tài liệu 19                ghi
  1    tài liệu 19                 tài liệu 20                ghi
  2    tài liệu 20                 tài liệu 21 (dòng trống)   ghi
  3    tài liệu 21 (dòng trống)    tài liệu 22                ghi
  4    tài liệu 22                 tài liệu 23                ghi
  5    tài liệu 23                 tài liệu 24                ghi
  6    Spacer                      Spacer                     giữ
  7    ── ⠋ Working ──             ── ⠙ Working ──            ghi, vì spinner đổi khung
  8    ‸ dòng nhập                 ‸ dòng nhập                giữ
  9    viền dưới editor            viền dưới editor           giữ
 10    footer: thư mục             footer: thư mục            giữ
 11    footer: token               footer: token              giữ

\x1b[?2026h
\x1b[1;1H\x1b[2K(tài liệu 19)R  \x1b[2;1H\x1b[2K(tài liệu 20)R  …  \x1b[6;1H\x1b[2K(tài liệu 24)R
\x1b[8;1H\x1b[2K── ⠙ Working ───…R
\x1b[9;1H\x1b[?25l                         con trỏ về dòng nhập (hàng 8), vẫn ẩn
\x1b[?2026l
Dock không dịch chuyển nên các hàng của nó giữ nguyên chuỗi và không được ghi lại. Dòng 18 không trôi vào đâu cả: nó vẫn ở trong tài liệu, cuộn lên là thấy, và TUI vẽ lại nó từ cây component.

Ở kịch bản này hai chế độ tốn gần như nhau: tám dòng so với bảy hàng. Khác biệt lộ ra ở các trường hợp khác. Khi người dùng đã cuộn lên đọc lại, scrollTop đứng yên, không hàng nào của transcript đổi, và thứ được ghi ra chỉ còn lệnh đặt con trỏ, cộng hàng spinner nếu nó vừa đổi khung. Một thay đổi ở bất kỳ đâu trong tài liệu tốn tối đa height hàng, không bao giờ là phát lại cả lịch sử. Đổi kích thước cửa sổ cũng chỉ vẽ lại một màn hình.

Bàn phím của viewport#

Constructor đăng ký handleViewportInput() làm input listener đầu tiên (tui-alt-screen.ts:281), nên nó thấy mọi byte trước component đang focus. Báo focus và sự kiện chuột bị nuốt ở đây; phần còn lại là các phím của viewport (tìm kiếm, cuộn trang, nửa trang, từng dòng, lời nhắc trước và sau, đầu và cuối), mỗi phím khớp thì trả về { consume: true } để editor không nhận nữa (tui-alt-screen.ts:739–805).

Các phím này cố ý che phím cùng tên của editor ở fullscreen (keybindings.ts:159): PageUp cuộn transcript chứ không cuộn ô soạn thảo; editor vẫn còn Ctrl+PageUp. Khi một overlay khác ô tìm kiếm đang giữ focus, các phím cuộn được nhường cho overlay (:680–682). Hai phím “lời nhắc trước/sau” dò dấu \x1b]133;A mà tin nhắn của người dùng và của assistant gắn vào dòng đầu, và nhảy tới đó (:517–529). Ở chế độ regular, chính các dấu đó được ghi thẳng ra terminal, nên terminal nào hỗ trợ OSC 133 tự có phím nhảy giữa các lời nhắc. Ctrl+Shift+F mở ô tìm kiếm, bản thân nó là một overlay neo góc trên phải, rộng 40% (:531–556); chỉ mục tìm kiếm giữ kết quả cũ chừng nào các dòng của transcript chưa đổi (alt-screen-search.ts:155).

Khi bắt chuột, ứng dụng nhận lại mọi việc terminal vốn tự làm. handleMouseEvent() xét theo thứ tự: component đang “bắt” chuỗi kéo thả; nút của ô tìm kiếm; overlay trên cùng dưới con trỏ; nhãn “nhảy tới cuối”; thanh cuộn (kéo, bấm vào rãnh, phóng to khi rê qua); component trong bố cục (handleMouse); dán bằng chuột phải trên Windows; cuối cùng là chọn văn bản (:912–973). Con lăn đi đường riêng: phần delta chưa dùng chuyển từ ScrollView trong ra ScrollView ngoài, trừ khi vùng trong đặt overscroll: "contain" (:1004–1016); Alt cộng con lăn cuộn gấp năm.

Vùng chọn được neo bằng toạ độ trong nội dung của ScrollView chứ không phải toạ độ màn hình, nên nó đúng cả khi transcript cuộn. Kéo tới mép trên hay dưới thì tự cuộn mỗi 50 ms. Bấm đúp chọn một từ và giữ nguyên đường dẫn hay từ nối bằng gạch ngang, vì / và - được coi là phần của từ (:83–85); bấm ba lần chọn cả dòng. Nhả chuột sau một cú bấm (không kéo) lên một hyperlink OSC 8 thì mở link; còn lại thì copy nếu copyOnSelect bật. copyTextToClipboard() ưu tiên hàm clipboard do ứng dụng truyền vào và chỉ báo “Copied!” khi hàm đó thành công; không có hàm nào thì ghi OSC 52 (tui-alt-screen.ts:1480–1497).

Chú thích giải thích vì sao không tin OSC 52: một lần ghi \x1b]52;c;… có thể hiện “Copied!” trong khi clipboard hệ thống không đổi, ví dụ trên Terminal.app của macOS hay tmux không chuyển tiếp OSC 52. Coding agent vì thế truyền vào một copySelection dùng clipboard native và các công cụ của hệ điều hành (tui-renderer.ts:39–46).

Overlay, focus và định tuyến input#

Overlay: vẽ đè lên dòng, không có lớp#

Vấn đề. Hộp thoại, menu, ô tìm kiếm cần nổi lên trên nội dung mà không đẩy nội dung đi chỗ khác. Trong một mô hình chỉ có dòng, không có lớp (layer) hay z-index. Overlay vì thế phải được ghép vào chính các dòng của khung: cắt dòng nền tại cột bắt đầu, chèn dòng của overlay, nối phần còn lại của dòng nền.

Một mục của ngăn xếp overlay và showOverlay()
pi/packages/tui/src/tui.tsdòng 326–333, 752–766TypeScript
type OverlayStackEntry = {	component: Component;	options?: OverlayOptions;	preFocus: Component | null;	hidden: boolean;	focusOrder: number;	bounds?: OverlayBounds;};	showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {		const entry: OverlayStackEntry = {			component,			...(options === undefined ? {} : { options }),			preFocus: this.focusedComponent,			hidden: false,			focusOrder: ++this.focusOrderCounter,		};		this.overlayStack.push(entry);		// Only focus if overlay is actually visible		if (!options?.nonCapturing && this.isOverlayVisible(entry)) {			this.setFocus(component);		}		this.hideTerminalCursor();		this.requestRender();

Mỗi overlay nhớ preFocus, component đang giữ focus lúc nó hiện ra, để trả focus khi nó đóng; và focusOrder, một bộ đếm tăng dần quyết định overlay nào nằm trên. Tuỳ chọn vị trí và kích thước (width, maxHeight, anchor, row/col tuyệt đối hoặc phần trăm, margin, visible(w, h), nonCapturing) nằm trong OverlayOptions (tui.ts:254–290); thứ tự ưu tiên khi chúng xung đột được README ghi rõ (README.md:239–243). Ghép vào khung diễn ra trong compositeOverlays():

  • Overlay được sắp theo focusOrder, overlay sau đè overlay trước. Độ rộng được giải trước (không phụ thuộc chiều cao), component render ở đúng độ rộng đó, bị cắt theo maxHeight, rồi mới giải toạ độ với chiều cao thật. Hình chữ nhật cuối cùng được lưu lại cho getBounds() và cho việc dò chuột.
  • Khung được đệm dòng trống cho đủ ít nhất một màn hình, và toạ độ của overlay tính từ viewportStart, tức tương đối với màn hình chứ không với đầu tài liệu. Chú thích ở dòng 1421–1423 ghi lại một lỗi cũ: từng dùng mức cao nhất maxLinesRendered để đệm, và nó tự phình ra, đẩy nội dung vào scrollback mỗi khi terminal rộng ra.
  • Mỗi dòng được ghép bằng compositeTuiLine(): tách dòng nền thành phần trước và phần sau theo cột, có giữ trạng thái ANSI, rồi chèn \x1b[0m\x1b]8;;\x07 trước và sau đoạn overlay để màu của nền không loang vào hộp thoại và ngược lại. Dòng chứa ảnh thì không ghép (tui.ts:426–455).

Ở chế độ regular, overlay được ghép trước khi so khung, nên với thuật toán vi sai nó chỉ là vài dòng đổi nội dung: hiện ra thì ghi các dòng đó, đóng lại thì các dòng nền khác với khung trước và được ghi lại. Ở fullscreen, overlay được ghép vào màn hình sau bước tô kết quả tìm kiếm và trước bước cắt về height hàng.

Focus#

Mỗi lúc có đúng một component giữ focus. setFocus() bật cờ focused của component mới nếu nó là Focusable và tắt cờ của component cũ; cờ này quyết định component có phát CURSOR_MARKER hay không. Overlay giành focus khi hiện ra, trừ khi nonCapturing hoặc đang bị visible() ẩn; khi bị đóng, focus về overlay hiển thị cao nhất còn lại hoặc về preFocus (tui.ts:770–784). Phần phức tạp nhất của TuiBase là một máy trạng thái nhỏ, overlayFocusRestore, với các trạng thái inactive, eligible và blocked (tui.ts:343–353, :625–688). Nó cài đặt một hứa hẹn của README: một overlay đang hiện và đang giữ focus sẽ lấy lại bàn phím sau khi một giao diện thay thế tạm thời trả focus (README.md:226–228). Muốn chủ động nhường bàn phím cho component khác trong lúc overlay vẫn hiện thì gọi handle.unfocus({ target }).

Một container chứa Input hay Editor bên trong phải tự cài Focusable và chuyển cờ focused xuống con. Nếu không, TUI bật cờ trên container, ô nhập bên trong không phát CURSOR_MARKER, và cửa sổ gợi ý của bộ gõ IME hiện sai chỗ (README.md:336–360).

Đường đi của một chuỗi input#

Một phím bấm đi qua nhiều tầng trước khi tới component:

  1. StdinBufferGom các mảnh data của stdin thành từng chuỗi hoàn chỉnh; nội dung dán được gom thành một sự kiện và bọc lại bằng \x1b[200~ … \x1b[201~ (terminal.ts:265–270). Mục Phân tích phím giải thích vì sao cần.
  2. ProcessTerminalNuốt các câu trả lời thương lượng bàn phím Kitty và trạng thái chương trình; còn lại chuyển cho TuiBase.handleTerminalInput() (terminal.ts:244–263).
  3. Câu trả lời của terminalCâu trả lời màu (OSC 10, 11, 4 và DA1) khi có truy vấn màu đang chờ, và báo đổi sáng/tối (\x1b[?997;1n hoặc 2n), bị nuốt ở TuiBase trước mọi thứ khác, không tới component nào.
  4. Input listenerChạy theo thứ tự đăng ký; mỗi listener có thể nuốt chuỗi (consume) hoặc thay nó (data). Viewport của fullscreen là listener đầu tiên; listener của extension cũng ở đây (xem chương Extensions).
  5. FocusCâu trả lời kích thước ô ảnh (\x1b[6;h;wt) bị nuốt. Shift+Ctrl+D gọi onDebug. Nếu overlay đang giữ focus vừa bị ẩn (resize, visible()) thì chuyển focus; nếu có overlay chờ lấy lại focus thì trả cho nó.
  6. ComponentBỏ sự kiện nhả phím trừ khi component đặt wantsKeyRelease, gọi handleInput(data), rồi vẽ khung ngay.

Ctrl+C cũng chỉ là một chuỗi input như mọi phím khác. TUI không xử lý nó; component đang focus quyết định. Trong coding agent, CustomEditor chuyển nó cho hành động app.clear (xoá editor, bấm lần hai thì thoát).

Chuột, chỉ ở fullscreen#

TuiMainScreen không bắt chuột, vì terminal sở hữu scrollback và cần chuột để cuộn và chọn chữ (README.md:269). TuiAltScreen chuẩn hoá chuỗi SGR thành TuiMouseEvent có toạ độ cục bộ của component, toạ độ màn hình, kích thước, nút, phím bổ trợ, số lần bấm và delta con lăn (tui.ts:25–44), và component trả lời bằng:

TuiMouseEventResult: handled, capture, focus, render
pi/packages/tui/src/tui.tsdòng 46–58TypeScript
export interface TuiMouseEventResult {	/** Stop propagation and suppress renderer-level fallback behavior. */	handled?: boolean;	/** Route subsequent drag/release events to this component. Implies handled. */	capture?: boolean;	/** Give keyboard focus to this component. Implies handled. */	focus?: boolean;	/**	 * Explicitly request or suppress a render. Move and release default to false;	 * press, click, drag, and wheel default to true.	 */	render?: boolean;}

handled chặn hành vi mặc định của renderer (cuộn, chọn chữ); capture giữ các sự kiện kéo và nhả tiếp theo cho cùng component; focus xin focus bàn phím; render ghi đè quy tắc mặc định là nhấn, bấm, kéo, con lăn thì vẽ lại còn di chuột và nhả thì không (README.md:290). Để dò trúng con nào, Container.handleMouse() dùng chiều cao từng con ghi lại ở lần render trước, nên di chuột không làm cả cây render lại chỉ để dò (tui.ts:383–403). MouseRegion bọc một component bất kỳ để thêm hành vi chuột mà không đổi cách nó vẽ; coding agent dùng nó cho việc bấm vào khối thinking để ẩn hay hiện (assistant-message.ts:163–170). Từ 1.2.0, con lăn không được component nào xử lý thì cuộn transcript, kể cả khi một overlay đang giữ focus; overlay muốn cuộn bằng con lăn phải cài handleMouse (CHANGELOG.md:35).

Phân tích phím và keybinding#

Vấn đề: terminal mã hoá phím rất mơ hồ#

Terminal truyền thống gửi phím dưới dạng byte. Chữ a là 0x61, Ctrl+A là 0x01, Enter là \r, Tab là 0x09 và trùng với Ctrl+I, Esc là 0x1b và trùng với Ctrl+[, Alt+x là ESC rồi x, mũi tên lên là ESC [ A. Chú thích đầu keys.ts nhắc đúng những chỗ trùng này (keys.ts:8–11). Với một coding agent, ba hệ quả gây khó chịu nhất là: Shift+Enter thường gửi y hệt Enter, nên không phân biệt được “xuống dòng” với “gửi prompt”; một byte ESC đơn lẻ có thể là phím Esc hoặc là đầu của một chuỗi chưa tới hết; và một lần đọc stdin có thể chứa vài phím, hoặc chỉ nửa chuỗi.

Thương lượng giao thức: Kitty trước, modifyOtherKeys sau#

queryAndEnableKittyProtocol(): xin cờ 7, hỏi lại, dùng DA1 làm mốc
pi/packages/tui/src/terminal.tsdòng 278–306TypeScript
/** * Query terminal for Kitty keyboard protocol support and enable it if available. * * Kitty's progressive enhancement detection requires requesting the desired * flags before querying them. The trailing DA query is a sentinel supported by * terminals that do not know Kitty keyboard protocol; receiving DA before a * Kitty response enables modifyOtherKeys fallback without a startup timeout. * * The requested flags are: * - 1 = disambiguate escape codes * - 2 = report event types (press/repeat/release) * - 4 = report alternate keys (shifted key, base layout key) * * The OSC 7501 program status query shares the DA sentinel: a terminal that supports it replies * before DA. `PI_PROGRAM_STATUS=1` or `0` skips the query. */private queryAndEnableKittyProtocol(): void {	this.setupStdinBuffer();	process.stdin.on("data", this.stdinDataHandler!);	this.keyboardProtocolPushed = true;	this.pendingKeyboardProtocolDeviceAttributes += 1;	this.clearKeyboardProtocolNegotiationBuffer();	const programStatusOverride = process.env.PI_PROGRAM_STATUS;	this.programStatusSupported = programStatusOverride === "1";	this.programStatusQueryPending = programStatusOverride !== "1" && programStatusOverride !== "0";	const programStatusQuery = this.programStatusQueryPending ? PROGRAM_STATUS_QUERY : "";	process.stdout.write(`${KITTY_KEYBOARD_PROTOCOL_QUERY}${programStatusQuery}${DEVICE_ATTRIBUTES_QUERY}`);	this.writeProgramStatus();}

Giao thức bàn phím Kitty cho phép chương trình xin terminal mã hoá phím rõ ràng. \x1b[>7u đẩy cờ 7 = 1 | 2 | 4: phân biệt mã escape, báo loại sự kiện (nhấn, giữ lặp, nhả), báo phím thay thế. \x1b[?u hỏi cờ đang bật. \x1b[c (DA1) là câu hỏi mà mọi terminal đều trả lời, và terminal trả lời theo thứ tự; nên nếu câu trả lời DA1 tới mà chưa thấy câu trả lời Kitty, terminal không biết giao thức Kitty, và không cần một bộ hẹn giờ chờ đoán. Khi nhận được cờ khác 0, ProcessTerminal bật cờ toàn cục setKittyProtocolActive(true) mà bộ khớp phím tra cứu; nếu không, nó bật modifyOtherKeys của xterm bằng \x1b[>4;2m (terminal.ts:308–336, :408–412). Lúc dừng, \x1b[<u gỡ cờ Kitty và \x1b[>4;0m tắt modifyOtherKeys.

Dạng CSI u: mã phím, phím shift, phím theo bố cục gốc, phím bổ trợ, loại sự kiện
pi/packages/tui/src/keys.tsdòng 602–622TypeScript
function parseKittySequence(data: string): ParsedKittySequence | null {	// CSI u format with alternate keys (flag 4):	// \x1b[<codepoint>u	// \x1b[<codepoint>;<mod>u	// \x1b[<codepoint>;<mod>:<event>u	// \x1b[<codepoint>:<shifted>;<mod>u	// \x1b[<codepoint>:<shifted>:<base>;<mod>u	// \x1b[<codepoint>::<base>;<mod>u (no shifted key, only base)	//	// With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release	// With flag 4, alternate keys are appended after codepoint with colons	const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/);	if (csiUMatch) {		const codepoint = parseInt(csiUMatch[1]!, 10);		const shiftedKey = csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined;		const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined;		const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1;		const eventType = parseEventType(csiUMatch[5]);		_lastEventType = eventType;		return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType };	}

Giá trị phím bổ trợ trên dây lớn hơn bitmask một đơn vị; bitmask là Shift 1, Alt 2, Ctrl 4, Super 8, còn Caps Lock 64 và Num Lock 128 bị bỏ qua khi so (keys.ts:296–303). Tài liệu cấu hình terminal của Pi dùng đúng các chuỗi này khi hướng dẫn người dùng tự map phím:

PhímCSI uGiải mãNguồn
Shift+Enter\x1b[13;2u13 = Enter, 2 − 1 = Shiftterminal-setup.md:177
Alt+Enter\x1b[13;3u3 − 1 = Alt:88
Ctrl+-\x1b[45;5u45 = -, 5 − 1 = Ctrl:161
Ctrl+Alt+]\x1b[93;7u93 = ], 7 − 1 = Ctrl + Alt:162

modifyOtherKeys mã hoá cùng thông tin theo dạng CSI 27 ; mod ; code ~, ví dụ \x1b[27;2;13~ cho Shift+Enter (keys.ts:722–728). Đây là dạng mà tmux 3.2 tới 3.4 chuyển tiếp; tmux từ 3.5 chuyển tiếp được CSI u nếu bật extended-keys-format csi-u (tmux.md:13–22).

StdinBuffer: cắt luồng byte thành từng chuỗi#

Một sự kiện data của stdin không phải một phím. Chú thích của StdinBuffer đưa ví dụ chuỗi chuột \x1b[<35;20;5m tới thành ba mảnh \x1b, [<35, ;20;5m (stdin-buffer.ts:1–18). Bộ đệm nhận biết điểm kết thúc của từng loại chuỗi (CSI kết thúc bằng một byte trong khoảng 0x40–0x7e, OSC bằng BEL hoặc ESC \, DCS và APC bằng ESC \), phát từng chuỗi hoàn chỉnh, và giữ phần dở dang lại, xả nó ra như phím sau một thời gian chờ có hạn (stdin-buffer.ts:411–432).

Một ESC đơn lẻ chờ escapeTimeout, mặc định 10 ms; qua SSH (có SSH_CONNECTION hoặc SSH_TTY) là 100 ms, vì Alt+phím kiểu cũ là ESC cộng một byte và trên đường truyền chậm hai byte đó có thể tới cách xa nhau; biến PI_TUI_ESC_TIMEOUT ghi đè cả hai (terminal.ts:142–151). Chuỗi dở khác chờ 50 ms. Riêng câu trả lời của terminal có phần đầu nhận ra được (màu, trạng thái chương trình, XTVersion, ảnh Kitty) thì chờ tới khi có ký tự kết thúc, không xả theo thời gian (dòng 416). Lý do được ghi ngay trong chú thích: xả ra thì phần đuôi bị gõ vào prompt như chữ thường, và ký tự BEL kết thúc của OSC thành Ctrl+G, phím mở trình soạn thảo ngoài. Đó là lỗi đã sửa ở 1.2.0 (CHANGELOG.md:36). Bộ đệm cũng gom nội dung dán giữa \x1b[200~ và \x1b[201~ thành một sự kiện paste, nên một lần dán dài không bị phân tích thành hàng nghìn phím.

matchesKey(): một tên phím, nhiều cách mã hoá#

Tên phím có kiểu KeyId, một hợp của các template literal như "ctrl+shift+p", nên viết sai tên là lỗi biên dịch (keys.ts:141–152). matchesKey(data, keyId) chấp nhận mọi cách mà một terminal có thể đã gửi phím đó. Trường hợp Shift+Enter cho thấy rõ nhất:

Shift+Enter: CSI u (cả phím Enter của bàn phím số), modifyOtherKeys, và các map tự đặt
pi/packages/tui/src/keys.tsdòng 917–938TypeScript
case "enter":case "return":	if (modifier === MODIFIERS.shift) {		// CSI u sequences (standard Kitty protocol)		if (			matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||			matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift)		) {			return true;		}		// xterm modifyOtherKeys format (fallback when Kitty protocol not enabled)		if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) {			return true;		}		// When Kitty protocol is active, legacy sequences are custom terminal mappings		// \x1b\r = Kitty's "map shift+enter send_text all \e\r"		// \n = Ghostty's "keybind = shift+enter=text:\n"		if (_kittyProtocolActive) {			return data === "\x1b\r" || data === "\n";		}		return false;	}

Hai chuỗi ở dòng 935 là map người dùng hay tự đặt trong terminal (chú thích ghi map shift+enter send_text all \e\r của Kitty và keybind = shift+enter=text:\n của Ghostty). Chúng chỉ được hiểu là Shift+Enter khi giao thức Kitty đang bật; nếu không, \x1b\r là Alt+Enter kiểu cũ và \n là Ctrl+J. Tài liệu khuyên gỡ map Ghostty kiểu này vì Pi không phân biệt nó với Ctrl+J (terminal-setup.md:57–63). Vài chi tiết khác của bộ khớp:

  • Bố cục bàn phím không phải Latin. Với tiếng Nga, Ctrl+С (chữ Cyrillic) vẫn khớp ctrl+c nhờ “phím theo bố cục gốc” mà Kitty báo kèm. Nhưng bộ khớp chỉ dùng phím gốc khi mã phím không phải chữ Latin hay ký hiệu đã biết, để bố cục Dvorak hay Colemak không khớp nhầm Ctrl+K thành Ctrl+V (keys.ts:700–717).
  • Nhả phím. isKeyRelease() nhận ra sự kiện nhả bằng hậu tố :3u, :3~, :3A... và bỏ qua nội dung dán, vì một địa chỉ MAC như 90:62:3F chứa đúng chuỗi đó (keys.ts:542–566).
  • Gõ chữ. Khi bật cờ phân biệt, terminal có thể gửi cả chữ thường dưới dạng CSI u. Ô nhập dùng decodePrintableKey() để lấy lại ký tự, và chỉ nhận chữ không kèm Ctrl hay Alt (keys.ts:1404–1437).
  • Backspace mơ hồ. Byte 0x08 là Ctrl+Backspace trên Windows Terminal nhưng là Backspace thường ở vài terminal và cấu hình tmux; bộ khớp đoán theo WT_SESSION (keys.ts:747–760).

Keybinding: gọi phím bằng tên hành động#

Component không nên viết cứng matchesKey(data, "ctrl+k"); nó hỏi theo tên hành động, và người dùng đổi được phím cho hành động đó. Danh sách hành động là một interface mở rộng được bằng declaration merging:

Keybindings: sổ đăng ký hành động, mở rộng bằng declaration merging
pi/packages/tui/src/keybindings.tsdòng 3–12, 57–69TypeScript
/** * Global keybinding registry. * Downstream packages can add keybindings via declaration merging. */export interface Keybindings {	// Editor navigation and editing	"tui.editor.cursorUp": true;	"tui.editor.cursorDown": true;	"tui.editor.historyPrevious": true;	"tui.editor.historyNext": true;	"tui.altScreen.top": true;	"tui.altScreen.bottom": true;}export type Keybinding = keyof Keybindings;export interface KeybindingDefinition {	defaultKeys: KeyId | KeyId[];	description?: string;}export type KeybindingDefinitions = Record<string, KeybindingDefinition>;export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;

pi-tui định nghĩa các hành động tui.editor.*, tui.input.*, tui.select.*, tui.altScreen.* cùng phím mặc định trong TUI_KEYBINDINGS (keybindings.ts:71–210). KeybindingsManager.rebuild() dựng một bảng tra từ tên hành động tới danh sách phím, và matches(data, action) chỉ là thử matchesKey() với từng phím trong danh sách (keybindings.ts:243–276).

Cấu hình người dùng thay thế danh sách phím của một hành động chứ không cộng thêm, và một mảng rỗng tắt hành động đó (keybindings.md:21–27). Xung đột chỉ được ghi nhận giữa các phím do người dùng gán, không giữa phím người dùng với phím mặc định. Bảng tra là một biến toàn cục (setKeybindings/getKeybindings, keybindings.ts:309–320), nên editor và viewport của fullscreen đọc chung một bản mà không phải truyền qua constructor. Coding agent mở rộng sổ đăng ký với các hành động app.* và đổi vài mặc định theo nền tảng:

Coding agent: thêm app.* bằng declaration merging, đổi mặc định cho Windows và WSL
pi/packages/coding-agent/src/core/keybindings.tsdòng 176–199TypeScript
declare module "@earendil-works/pi-tui" {	interface Keybindings extends AppKeybindings {}}export const KEYBINDINGS = {	...TUI_KEYBINDINGS,	"tui.editor.undo": {		...TUI_KEYBINDINGS["tui.editor.undo"],		defaultKeys: process.platform === "win32" ? "ctrl+z" : windowsKeybindings ? "alt+z" : "ctrl+-",	},	"tui.altScreen.previousPrompt": {		...TUI_KEYBINDINGS["tui.altScreen.previousPrompt"],		defaultKeys: windowsKeybindings ? "ctrl+up" : ["ctrl+shift+up", "ctrl+up"],	},	"tui.altScreen.nextPrompt": {		...TUI_KEYBINDINGS["tui.altScreen.nextPrompt"],		defaultKeys: windowsKeybindings ? "ctrl+down" : ["ctrl+shift+down", "ctrl+down"],	},	"tui.altScreen.search": {		...TUI_KEYBINDINGS["tui.altScreen.search"],		defaultKeys: windowsKeybindings ? "ctrl+f" : "ctrl+shift+f",	},	...APP_KEYBINDINGS,} as const satisfies KeybindingDefinitions;

Trên Windows và WSL, tìm kiếm trong transcript mặc định là Ctrl+F. Undo thì tuỳ: Windows gốc không có job control nên Ctrl+Z rảnh và được dùng cho undo (keybindings.md:131); WSL vẫn dùng Ctrl+Z để tạm dừng tiến trình nên undo chuyển sang Alt+Z; các nền tảng khác dùng Ctrl+-. KeybindingsManager.create() của coding agent đọc <agent-dir>/keybindings.json, mặc định ~/.pi/agent/keybindings.json (keybindings.ts:337–341), và interactive mode cài nó làm bảng toàn cục ngay trong constructor (interactive-mode.ts:646–647).

Editor: soạn nhiều dòng, autocomplete, paste, undo#

Editor (components/editor.ts, 2.473 dòng) là component lớn nhất của gói, và là nơi người dùng ở lâu nhất. State cốt lõi rất nhỏ:

EditorState và EditorSnapshot
pi/packages/tui/src/components/editor.tsdòng 219–230TypeScript
interface EditorState {	lines: string[];	cursorLine: number;	cursorCol: number;}/** Undo snapshot: editor text state plus the paste registry. */interface EditorSnapshot {	state: EditorState;	pastes: Map<number, string>;	pasteCounter: number;}

Văn bản là một mảng dòng logic, con trỏ là cặp (dòng, cột) với cột là chỉ số trong chuỗi của dòng đó. Ngắt dòng không nằm trong state: mỗi lần render, layoutText() ngắt các dòng logic thành dòng hiển thị theo độ rộng hiện tại (editor.ts:998). Di chuyển lên xuống đi theo dòng hiển thị và nhớ một “cột ưa thích” (preferredVisualCol) để con trỏ không trôi dần sang trái khi đi qua các dòng ngắn. Ngoài ra editor giữ bảng nội dung dán, lịch sử prompt (tối đa 100 mục), kill ring kiểu Emacs, ngăn xếp undo và trạng thái autocomplete (editor.ts:337–372).

render(): chiều cao có trần, cuộn bên trong#

Editor cao tối đa 30% màn hình (ít nhất 5 dòng) và tự cuộn để con trỏ luôn hiện
pi/packages/tui/src/components/editor.tsdòng 536–557TypeScript
// Calculate max visible lines: 30% of terminal height, minimum 5 linesconst terminalRows = this.tui.terminal.rows;const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));// Find the cursor line index in layoutLineslet cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);if (cursorLineIndex === -1) cursorLineIndex = 0;// Adjust scroll offset to keep cursor visibleif (cursorLineIndex < this.scrollOffset) {	this.scrollOffset = cursorLineIndex;} else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {	this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;}// Clamp scroll offset to valid rangeconst maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));// Get visible lines sliceconst visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);this.renderedVisibleLineCount = visibleLines.length;

Editor đọc tui.terminal.rows, đó là lý do constructor bắt buộc nhận tui. Trần chiều cao có từ khi một prompt dài hơn màn hình làm hỏng hiển thị của terminal (CHANGELOG.md:1068). Phần còn lại của render() vẽ viền trên và viền dưới (kèm “↑ N more” khi có phần bị che), chèn CURSOR_MARKER và con trỏ giả vào đúng grapheme dưới con trỏ, đệm mỗi dòng đủ độ rộng, và nối danh sách autocomplete bên dưới viền dưới (editor.ts:559–630). Khi không có padding ngang, độ rộng bố trí chừa một cột cho con trỏ đứng cuối dòng (dòng 526–528).

handleInput(): thứ tự ưu tiên#

Một chuỗi input được thử lần lượt (editor.ts:696–996), và cách đầu tiên khớp thắng:

  1. Chế độ nhảySau Ctrl+], phím in được tiếp theo là ký tự đích để nhảy tới.
  2. Bracketed pasteTừ \x1b[200~, mọi thứ được gom cho tới \x1b[201~ rồi chuyển cho handlePaste(); phần sau dấu đóng được xử lý tiếp như input thường.
  3. Ctrl+C và undotui.input.copy trả về ngay để component cha quyết định; tui.editor.undo hoàn tác.
  4. Autocomplete đang mởEsc huỷ, lên xuống chọn, Tab áp dụng, Enter áp dụng; với lệnh slash, Enter áp dụng rồi đi tiếp xuống bước gửi.
  5. Soạn thảoTab gọi completion; các phím xoá; yank và yank-pop của kill ring; lịch sử; di chuyển con trỏ.
  6. Xuống dòng hay gửitui.input.newLine (Shift+Enter, Ctrl+J) chèn dòng. Enter gửi, trừ khi ký tự ngay trước con trỏ là \: khi đó dấu \ bị xoá và một dòng mới được chèn, một cách lách cho terminal không gửi được Shift+Enter (dòng 912–919).
  7. Lên, xuốngỞ dòng hiển thị đầu tiên, phím lên duyệt lịch sử nếu editor rỗng, đang duyệt dở, hoặc con trỏ ở cột 0; nếu không thì nhảy về đầu dòng (dòng 926–939).
  8. Ký tựThử decodePrintableKey() cho chữ đến dưới dạng CSI u, rồi tới byte thường từ 0x20 trở lên.

Dán: marker thay cho cả khối#

Dán lớn (hơn 10 dòng hoặc hơn 1.000 ký tự) thành một marker
pi/packages/tui/src/components/editor.tsdòng 1298–1316TypeScript
// Split into lines to check for large pasteconst pastedLines = filteredText.split("\n");// Check if this is a large paste (> 10 lines or > 1000 characters)const totalChars = filteredText.length;if (pastedLines.length > 10 || totalChars > 1000) {	// Store the paste and insert a marker	this.pasteCounter++;	const pasteId = this.pasteCounter;	this.pastes.set(pasteId, filteredText);	// Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"	const marker =		pastedLines.length > 10			? `[paste #${pasteId} +${pastedLines.length} lines]`			: `[paste #${pasteId} ${totalChars} chars]`;	this.insertTextAtCursorInternal(marker);	return;}

Trước đó, handlePaste() chuẩn hoá xuống dòng, đổi tab thành bốn dấu cách, lọc ký tự điều khiển, và giải mã các Ctrl+chữ mà vài terminal (ví dụ popup của tmux với extended-keys-format=csi-u) mã hoá lại thành CSI u bên trong nội dung dán (editor.ts:1267–1277). Một đường dẫn dán dính vào sau một chữ được thêm dấu cách phía trước. Nội dung lớn được cất vào bảng pastes, còn editor chỉ chứa marker như [paste #1 +123 lines]. segmentWithMarkers() gộp mọi grapheme của một marker hợp lệ thành một đơn vị, nên di chuyển, xoá và ngắt dòng coi marker như một ký tự (editor.ts:41–105). Marker chỉ được thay lại bằng nội dung thật ở hai chỗ. Khi gửi, submitValue() mở mọi marker, xoá state, bảng nội dung dán và ngăn xếp undo, rồi gọi onSubmit (editor.ts:1364–1378). Khi mở trình soạn thảo ngoài, getExpandedText() đưa ra văn bản đầy đủ (editor.ts:1099–1105).

README chỉ nói về ngưỡng “hơn 10 dòng” (README.md:467); code còn tạo marker cho một lần dán một dòng dài hơn 1.000 ký tự, với dạng [paste #2 1234 chars].

Undo kiểu fish#

Gộp undo theo từ; snapshot gồm cả bảng nội dung dán
pi/packages/tui/src/components/editor.tsdòng 1201–1214, 2116–2132TypeScript
private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {	this.exitHistoryBrowsing();	// Undo coalescing (fish-style):	// - Consecutive word chars coalesce into one undo unit	// - Space captures state before itself (so undo removes space+following word together)	// - Each space is separately undoable	// Skip coalescing when called from atomic operations (e.g., handlePaste)	if (!skipUndoCoalescing) {		if (isWhitespaceChar(char) || this.lastAction !== "type-word") {			this.pushUndoSnapshot();		}		this.lastAction = "type-word";	}private pushUndoSnapshot(): void {	this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });}private undo(): void {	this.exitHistoryBrowsing();	const snapshot = this.undoStack.pop();	if (!snapshot) return;	Object.assign(this.state, snapshot.state);	this.pastes = snapshot.pastes;	this.pasteCounter = snapshot.pasteCounter;	this.lastAction = null;	this.preferredVisualCol = null;	if (this.onChange) {		this.onChange(this.getText());	}}

Gõ liên tiếp các ký tự trong một từ chỉ tạo một snapshot ở đầu từ; mỗi khoảng trắng tạo snapshot ngay trước nó. Undo vì vậy xoá từng từ chứ không từng ký tự, giống shell fish như chú thích ghi. UndoStack.push() lưu một bản sao sâu bằng structuredClone, nên snapshot không bị sửa ngầm khi state đổi sau đó (undo-stack.ts:1–13). Snapshot gồm cả bảng pastes và bộ đếm, nên undo một lần dán xoá luôn nội dung đã cất. Kill ring là một vòng chuỗi riêng: các lần xoá liên tiếp được nối vào cùng một mục, Ctrl+Y dán mục mới nhất, Alt+Y xoay vòng (kill-ring.ts:1–28).

Autocomplete bất đồng bộ#

AutocompleteProvider: lấy gợi ý (có AbortSignal) và áp dụng gợi ý
pi/packages/tui/src/autocomplete.tsdòng 268–297TypeScript
export interface AutocompleteProvider {	/** Characters that should naturally trigger this provider at token boundaries. */	triggerCharacters?: string[];	// Get autocomplete suggestions for current text/cursor position	// Returns null if no suggestions available	getSuggestions(		lines: string[],		cursorLine: number,		cursorCol: number,		options: { signal: AbortSignal; force?: boolean },	): Promise<AutocompleteSuggestions | null>;	// Apply the selected item	// Returns the new text and cursor position	applyCompletion(		lines: string[],		cursorLine: number,		cursorCol: number,		item: AutocompleteItem,		prefix: string,	): {		lines: string[];		cursorLine: number;		cursorCol: number;	};	// Check if file completion should trigger for explicit Tab completion	shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;}

Editor tự gọi gợi ý khi gõ / ở đầu tin nhắn (lệnh slash), khi gõ @, # hoặc ký tự kích hoạt riêng của provider ở đầu một token, và khi gõ tiếp chữ trong một ngữ cảnh như vậy; ngoài ngữ cảnh lệnh slash, Tab ép gợi ý đường dẫn file (editor.ts:1228–1257). Vì getSuggestions() có thể chậm (tìm file qua fd, gọi completion của extension), editor xếp các yêu cầu nối tiếp nhau, huỷ yêu cầu cũ bằng AbortController, hoãn 20 ms với các token bắt đầu bằng ký tự kích hoạt như @ hay #, và bỏ kết quả tới muộn:

Kết quả chỉ được dùng nếu văn bản và con trỏ vẫn y như lúc gửi yêu cầu
pi/packages/tui/src/components/editor.tsdòng 2416–2430TypeScript
private isAutocompleteRequestCurrent(	requestId: number,	controller: AbortController,	snapshotText: string,	snapshotLine: number,	snapshotCol: number,): boolean {	return (		!controller.signal.aborted &&		requestId === this.autocompleteRequestId &&		this.getText() === snapshotText &&		this.state.cursorLine === snapshotLine &&		this.state.cursorCol === snapshotCol	);}

Khi Tab ép gợi ý và chỉ có đúng một kết quả, nó được áp dụng luôn mà không mở danh sách (editor.ts:2393–2410). CombinedAutocompleteProvider của pi-tui ghép lệnh slash với đường dẫn file; với tiền tố @, nó tìm mờ qua fd, có theo .gitignore, gồm file ẩn, bỏ thư mục .git (autocomplete.ts:145–175). Coding agent dựng provider từ lệnh có sẵn, prompt template, lệnh của extension và skill, rồi cho extension bọc thêm (interactive-mode.ts:827–843).

CustomEditor: phím của ứng dụng đứng trước#

Coding agent không sửa Editor; nó kế thừa. CustomEditor.handleInput() (custom-editor.ts:88–147) thử phím tắt do extension đăng ký, phím dán ảnh, Esc (chỉ ngắt agent khi danh sách autocomplete không mở, nếu không thì để Esc đóng danh sách), Ctrl+D (chỉ thoát khi editor rỗng), rồi mọi hành động app.* đã đăng ký, cuối cùng mới tới super.handleInput(). Nó cũng vẽ chỉ báo “working” ngay trong viền trên (custom-editor.ts:36–79), lý do status container trống trong trace.

Markdown và hình ảnh#

Markdown#

Câu trả lời của mô hình là Markdown, nên Markdown là component được render nhiều nhất. Nó dùng lexer của marked với ba phần tuỳ biến: gạch ngang chỉ nhận ~~ chặt chẽ, nhấn mạnh thân thiện với chữ CJK (marked-cjk-friendly), và hai extension LaTeX cho công thức inline và dạng khối, được vẽ thành ký tự Unicode bởi latex.ts (markdown.ts:190–197). Mỗi token thành các dòng có mã ANSI theo MarkdownTheme, một tập hàm tô màu do ứng dụng cung cấp, kể cả highlightCode cho khối code (markdown.ts:222–240). Hai lớp cache:

Markdown.render(): cache dòng theo (text, width), cache token qua WeakRef
pi/packages/tui/src/components/markdown.tsdòng 303–333TypeScript
render(width: number): string[] {	// Check cache	if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {		return this.cachedLines;	}	// Calculate available width for content (subtract horizontal padding)	const contentWidth = Math.max(1, width - this.paddingX * 2);	const text = this.options.transform?.(this.text, contentWidth) ?? this.text;	// Don't render anything if there's no actual text	if (!text || text.trim() === "") {		const result: string[] = [];		// Update cache		this.cachedText = this.text;		this.cachedWidth = width;		this.cachedLines = result;		return result;	}	// Replace tabs with 3 spaces for consistent rendering	const normalizedText = text.replace(/\t/g, "   ");	// Parse markdown to HTML-like tokens	const cached = this.cachedTokens?.deref();	let tokens = cached?.source === normalizedText ? cached.tokens : undefined;	if (!tokens) {		tokens = markdownParser.lexer(normalizedText);		trimPartialClosingFences(tokens);		this.cachedTokens = new WeakRef({ source: normalizedText, tokens });	}

Cache dòng trúng khi văn bản và độ rộng không đổi, tức là với mọi tin nhắn đã xong. Cache token chỉ phụ thuộc mã nguồn Markdown, nên sống qua được việc đổi theme hay đổi độ rộng; nó được giữ bằng WeakRef vì theo chú thích, cây token lớn khoảng mười lần mã nguồn và mỗi tin nhắn của một transcript dài đều giữ một Markdown (markdown.ts:271–274). Cuối render(), flattenLines() ép V8 làm phẳng các chuỗi được ghép từ nhiều mảnh, vốn giữ cây các mảnh con và tốn gấp mấy lần kích thước thật (utils.ts:891–898). Hai thay đổi này của 1.0.0 làm một tin nhắn dài chỉ còn giữ khoảng một phần năm bộ nhớ so với trước (CHANGELOG.md:86).

Tin nhắn đang stream thì khác. AssistantMessageComponent.updateContent() tạo một Markdown mới mỗi lần cập nhật, nên cả hai cache của nó không giúp gì: mỗi khung, đoạn văn đang stream được lex và ngắt dòng lại từ đầu. Chi phí đó tỷ lệ với độ dài của một tin nhắn, không phải của cả transcript, và bị chặn bởi nhịp 16 ms. Vài chỗ trong Markdown có mặt chỉ vì streaming: trimPartialClosingFences() bỏ dấu đóng khối code đang tới dở (dòng cuối chỉ gồm vài dấu backtick hay dấu ngã, ngắn hơn dấu mở), để khối code không co lại rồi giãn ra khi ký tự cuối tới (markdown.ts:165–188); công thức $$ chưa đóng được giữ ở dạng thô cho tới khi đóng, và từ 1.4.0 không còn nuốt bảng phía sau (CHANGELOG.md:11).

Ảnh inline: Kitty và iTerm2#

Terminal có hai giao thức ảnh phổ biến. pi-tui đoán terminal hỗ trợ cái nào từ biến môi trường: Kitty, Ghostty, WezTerm, Warp, Herdr, Rex dùng giao thức Kitty; iTerm2 dùng giao thức riêng; dưới tmux hay screen thì tắt ảnh vì không đáng tin (terminal-image.ts:70–146). Người dùng ghi đè được bằng PI_IMAGE_PROTOCOL, PI_TRUE_COLOR, PI_HYPERLINKS hoặc setting terminal.* (terminal-setup.md:207–219).

Giao thức Kitty truyền ảnh PNG đã mã hoá base64 trong các chuỗi APC \x1b_G…;…\x1b\\, chia khúc 4.096 ký tự với cờ m=1 cho khúc còn tiếp; tham số a=T,f=100,q=2 nghĩa là truyền và hiện ngay, định dạng PNG, không trả lời; c/r là số cột và hàng ô chữ ảnh chiếm, i là id ảnh, C=1 bảo terminal không dời con trỏ (terminal-image.ts:232–276). Ảnh JPEG, GIF, WebP cần một hàm chuyển sang PNG do ứng dụng đăng ký; thiếu nó thì hiện chữ thay thế (image.ts:24–31). iTerm2 dùng OSC 1337 với inline=1 và kích thước byte (terminal-image.ts:299–325). Điều quan trọng với TUI là ảnh phải khớp vào mô hình dòng:

Image.render(): một ảnh cao N hàng trả về đúng N dòng
pi/packages/tui/src/components/image.tsdòng 114–152TypeScript
if (caps.images && data) {	if (caps.images === "kitty" && this.imageId === undefined) {		this.imageId = allocateImageId();	}	const result = renderImage(data, dimensions, {		maxWidthCells: maxWidth,		maxHeightCells: maxHeight,		imageId: this.imageId,		moveCursor: false,	});	if (result) {		// Store the image ID for later cleanup		if (result.imageId) {			this.imageId = result.imageId;		}		if (caps.images === "kitty") {			// For Kitty: C=1 prevents cursor movement.			// Don't need the cursor movement.			lines = [result.sequence];			// Return `rows` lines so TUI accounts for image height.			for (let i = 0; i < result.rows - 1; i++) {				lines.push("");			}		} else {			// Return `rows` lines so TUI accounts for image height.			// First (rows-1) lines are empty and cleared before the image is drawn.			// Last line: move cursor back up, draw the image, then move back down			// so TUI cursor accounting stays inside the scroll area.			lines = [];			for (let i = 0; i < result.rows - 1; i++) {				lines.push("");			}			const rowOffset = result.rows - 1;			const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : "";			lines.push(moveUp + result.sequence);		}

Ảnh cao N hàng ô chữ trả về đúng N dòng, nên phép đếm dòng của cả hai renderer vẫn đúng. Với Kitty, chuỗi ảnh nằm ở dòng đầu và N − 1 dòng rỗng giữ chỗ. Với iTerm2, N − 1 dòng đầu rỗng, còn dòng cuối là lệnh lên N − 1 dòng rồi tới chuỗi ảnh; theo chú thích trong code, việc vẽ ảnh đưa con trỏ trở xuống, nên phép đếm dòng của TUI vẫn nằm trong vùng cuộn. Kích thước ô theo pixel, cần để tính N, được hỏi bằng CSI 16 t lúc khởi động; khi có câu trả lời, TUI gọi invalidate() cho cả cây để ảnh tính lại (tui.ts:1217–1235).

Dòng ảnh không bị nối reset và không bị ghép overlay. TuiMainScreen mở rộng vùng thay đổi để bao trọn các hàng một ảnh chiếm, xoá ảnh cũ theo id bằng a=d,d=I trước khi vẽ, và vẽ lại toàn bộ nếu việc chừa hàng cho ảnh sẽ làm màn hình cuộn (tui-main-screen.ts:210–245). TuiAltScreen còn làm thêm hai việc. Ảnh đã tải lên terminal được dùng lại bằng lệnh đặt chỗ a=p thay vì truyền lại cả dữ liệu, với giới hạn 16 ảnh, 32 MiB dữ liệu truyền và 64 MiB ước tính sau giải mã cho các ảnh đang khuất (tui-alt-screen.ts:445–493). Và ảnh bị cuộn khuất một phần được cắt bằng tham số y, h, r để chỉ hiện phần nằm trong viewport (terminal-image.ts:464–476).

Phần native#

Phần lớn những gì TUI cần đều đi qua terminal bằng chuỗi điều khiển. Ba thứ thì không: đọc clipboard của hệ điều hành (văn bản, ảnh, danh sách file, cho phím dán ảnh), biết phím bổ trợ nào đang được giữ ngay lúc này, và bật chế độ nhập VT của console Windows. packages/tui/native/ chứa ba addon nhỏ viết bằng C và Objective-C cho đúng ba việc đó, kèm bản dựng sẵn cho x64 và arm64:

Thư mụcHàm xuất raDựa trên
native/darwin/isModifierPressed, getText, setText, getImage, getFilePaths (darwin-platform.m:126–130)AppKit; trạng thái phím qua CGEventSourceFlagsState
native/win32/enableVirtualTerminalInput, isModifierPressed, getText, setText, getImage (win32-platform.c:235–239)kernel32, user32; GetAsyncKeyState, SetConsoleMode
native/linux/getText, getImage (linux-platform-x11.c:271–272)X11 qua libxcb.so.1; Wayland dùng wl-paste ở phía coding agent
NativeClipboard và cách chọn helper theo nền tảng
pi/packages/tui/src/native-platform.tsdòng 9–23, 55–65TypeScript
export interface NativeClipboard {	/** Undefined means unavailable, null means no text; transfer failures reject. */	getText(): Promise<string | null | undefined>;	/** Undefined means unavailable, null means no image; transfer failures reject. */	getImage(): Promise<Uint8Array | null | undefined>;	/** POSIX paths of file URLs on the clipboard. Undefined means unsupported, null means no files. */	getFilePaths?(): Promise<string[] | null | undefined>;	/** Linux uses command-line tools to retain clipboard ownership instead. */	setText?(text: string): Promise<void>;}type NativePlatformHelper = NativeClipboard & {	enableVirtualTerminalInput?: () => boolean;	isModifierPressed?: (name: ModifierKey) => boolean;};export function getNativePlatformHelper(): NativePlatformHelper | undefined {	if (process.platform !== "darwin" && process.platform !== "win32") return undefined;	return loadNativePlatformHelper(process.platform);}/** Load a clipboard helper without opening the display until a read is requested. */export function getNativeClipboard(): NativeClipboard | undefined {	if (process.platform !== "linux") return getNativePlatformHelper();	if (!process.env.DISPLAY) return undefined;	return loadNativePlatformHelper("linux", "-x11");}

Mỗi hàm clipboard trả về một Promise và phân biệt ba trường hợp: undefined nghĩa là không dùng được, null nghĩa là clipboard không có loại dữ liệu đó, còn lỗi truyền dữ liệu thì reject. Trên Linux, helper chỉ được nạp khi có DISPLAY, và theo chú thích ở dòng 25 chỉ việc nạp module được cache, không phải tình trạng của màn hình, vì một display bị ngắt có thể kết nối lại. Addon không cần header của Node: napi.h tự tìm một tập nhỏ hàm N-API lúc chạy, “from Node or Bun, without Node headers or a link-time dependency on a particular runtime” (napi.h:4–5). File .node được tìm ở vài chỗ: cạnh gói đã cài, cạnh module đang chạy, và cạnh file thực thi, để bản binary đóng gói một file cũng tìm thấy (native-module-path.ts:14–31). Không tìm thấy thì hàm trả về undefined và người gọi đi đường lui; coding agent khi đó đọc clipboard bằng các lệnh như wl-paste hay xclip (clipboard-image.ts:173).

Trạng thái phím bổ trợ giải quyết một vấn đề cụ thể của bàn phím: Terminal.app của macOS và console Windows có thể gửi Enter trần (\r) cho cả Shift+Enter. Khi nhận đúng một \r trên hai nền tảng đó, ProcessTerminal hỏi helper xem Shift có đang được giữ không, và nếu có thì đổi chuỗi thành Shift+Enter dạng CSI u:

forwardInputSequence(): \r cộng Shift đang giữ thành \x1b[13;2u
pi/packages/tui/src/terminal.tsdòng 396–406TypeScript
private forwardInputSequence(sequence: string): void {	if (!this.inputHandler) return;	const shouldDetectNativeShiftEnter =		sequence === "\r" && (isAppleTerminalSession() || process.platform === "win32");	const input = normalizeNativeShiftEnterInput(		sequence,		shouldDetectNativeShiftEnter,		shouldDetectNativeShiftEnter && isNativeModifierPressed("shift"),	);	this.inputHandler(input);}

Cách này chỉ đúng khi Pi chạy trên cùng máy với terminal; qua SSH, máy chạy Pi không nhìn thấy bàn phím của người dùng (terminal-setup.md:43–45). Việc thứ ba, enableVirtualTerminalInput, thêm cờ ENABLE_VIRTUAL_TERMINAL_INPUT cho stdin của console (win32-platform.c:33–48). Nếu thiếu cờ đó, theo chú thích trong terminal.ts, libuv đọc sự kiện console thô và bỏ mất phím bổ trợ, nên Shift+Tab tới như Tab thường; cờ phải được đặt sau setRawMode(true) vì raw mode đặt lại các cờ console (terminal.ts:221–225).

Interactive mode dựng giao diện trên pi-tui#

Phần này nhìn từ phía ứng dụng: coding agent chọn renderer, dựng cây component và nối sự kiện của session vào cây ra sao. Vòng đời của bản thân InteractiveMode (khởi động, lệnh slash, chuyển session) thuộc chương Các chế độ chạy.

Chọn renderer#

createInteractiveTui(): composition root cho cả hai chế độ
pi/packages/coding-agent/src/modes/interactive/tui-renderer.tsdòng 22–50TypeScript
export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen {	const terminal = options.terminal ?? new ProcessTerminal();	if (options.tuiMode === "fullscreen") {		const styleSearchMatch = (text: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text));		return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, {			searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),			searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),			searchNavigationButtonStyle: (text, hovered) => (hovered ? theme.underline(text) : text),			scrollToEndIndicator: () => {				const shortcut = keyDisplayText("tui.altScreen.bottom");				const label = ` ↓ Jump to latest message${shortcut ? ` · ${shortcut}` : ""} `;				return theme.bg("selectedBg", theme.fg("text", label));			},			openUrl: openBrowser,			onRightClickPaste: options.onRightClickPaste,			copyOnSelect: options.fullscreenCopyOnSelect,			wheelScrollLines: options.fullscreenWheelScrollLines ?? "auto",			copySelection: async (text) => {				try {					await copyToClipboard(text);					return true;				} catch (error) {					return error instanceof Error ? error.message : String(error);				}			},		});	}	return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);}

Đây là chỗ duy nhất biết đến cả hai lớp renderer. Ở fullscreen, nó nối các điểm mở của TuiAltScreen vào thế giới của coding agent: kiểu tô kết quả tìm kiếm lấy từ theme, nhãn “↓ Jump to latest message” ghi kèm phím tắt hiện hành, mở URL bằng trình duyệt, copy bằng clipboard thật thay cho OSC 52, và con lăn mặc định ở chế độ "auto" (tăng tốc khi quay nhanh trên các terminal không tự tăng tốc). Phần còn lại của interactive mode chỉ thấy interface TUI.

Vì người dùng có thể đổi chế độ trong /settings lúc đang chạy, renderer có thể bị thay. Component nào đã giữ tham chiếu tới renderer cũ (editor, loader, tool row) sẽ gọi vào một đối tượng đã dừng. createInteractiveTuiReference() giải quyết việc đó bằng một Proxy: mỗi lần đọc thuộc tính hay gọi phương thức, nó hỏi renderer hiện tại qua getTui() (tui-renderer.ts:52–81). InteractiveMode giữ renderer thật và đưa cho mọi người ui là proxy này (interactive-mode.ts:624–633).

Một cây, hai cách gắn#

init(): dựng layout fullscreen một lần và gắn cùng một cây vào renderer
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 968–992TypeScript
// Keep one component tree and remount it when changing renderers.this.renderWidgets(); // Initialize with default spacerconst viewport = createChatViewport({	document: this.documentContainer,	pendingMessages: this.pendingMessagesContainer,	status: this.statusContainer,	widgetsAbove: this.widgetContainerAbove,	editor: this.editorContainer,	widgetsBelow: this.widgetContainerBelow,	footer: this.footerContainer,	scrollbar: this.settingsManager.getFullscreenScrollbar(),	scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),	scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),});this.transcriptScrollView = viewport.transcript;this.fullscreenLayoutRoot = viewport.root;this.mountInteractiveTui(this.renderer, [	this.documentContainer,	this.pendingMessagesContainer,	this.statusContainer,	this.widgetContainerAbove,	this.editorContainer,	this.widgetContainerBelow,	this.footerContainer,]);
TUI.children (regular: nối dọc)              layoutRoot (fullscreen: createChatViewport)
├─ documentContainer                         VStack
│   ├─ headerContainer                       ├─ ScrollView(documentContainer)  grow 1, primary, follow end
│   ├─ loadedResourcesContainer              └─ VStack dock                    basis auto
│   └─ chatContainer                             ├─ pendingMessagesContainer
│       ├─ UserMessageComponent                  ├─ statusContainer
│       ├─ AssistantMessageComponent             ├─ widgetContainerAbove
│       ├─ ToolExecutionComponent ...            ├─ editorContainer ─ CustomEditor
├─ pendingMessagesContainer                      ├─ widgetContainerBelow
├─ statusContainer                               └─ footerContainer ─ FooterComponent
├─ widgetContainerAbove
├─ editorContainer ─ CustomEditor
├─ widgetContainerBelow
└─ footerContainer ─ FooterComponent
Cùng các đối tượng container, hai cách gắn. Ở regular, thứ tự của children là bố cục. Ở fullscreen, setLayoutRoot() bọc chúng trong layout có chiều cao ràng buộc, và render() của TUI đi qua layout root (tui-alt-screen.ts:334–336). children vẫn được gắn ở cả hai chế độ: đó là danh sách mà switchTuiMode() mang sang renderer mới.

Container là đơn vị thay thế: bộ chọn model, session hay settings không phải overlay mà được đặt thay editor trong editorContainer, nhận focus, và trả editor về chỗ khi xong (interactive-mode.ts:4853–4876). Widget của extension vào widgetContainerAbove/Below; khi không có widget nào, container phía trên chứa một Spacer(1) (:2498–2511).

Đổi chế độ khi đang chạy#

switchTuiMode(): dừng renderer cũ mà giữ màn hình, gắn lại cây vào renderer mới
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 892–910, 923–932TypeScript
private switchTuiMode(mode: TuiMode, restoreProgress = true, startRenderer = true): boolean {	const previousUi = this.renderer;	if (mode === previousUi.mode) return true;	if (previousUi.hasOverlayEntries) return false;	const components = [...previousUi.children];	const focus = previousUi.getFocusedComponent();	const terminal = previousUi.terminal;	const showHardwareCursor = previousUi.getShowHardwareCursor();	const clearOnShrink = previousUi.getClearOnShrink();	const onDebug = previousUi.onDebug;	if (previousUi instanceof TuiMainScreen) {		this.mainScreenRenderState = previousUi.captureRenderState();	}	previousUi.stop({ preserveScreen: true });	previousUi.setFocus(null);	previousUi.clear();	if (TuiLayouts.isViewportTUI(previousUi)) previousUi.setLayoutRoot(undefined);	if (nextUi instanceof TuiMainScreen && this.mainScreenRenderState) {		nextUi.restoreRenderState(this.mainScreenRenderState);	}	this.renderer = nextUi;	this.options.tuiMode = mode;	this.mountInteractiveTui(nextUi, components);	nextUi.invalidate();	nextUi.setFocus(focus);	if (!startRenderer) return true;	nextUi.start();

Không đổi được khi đang có overlay. Renderer cũ dừng với preserveScreen: true để không in lại hay đẩy con trỏ đi; cây được tháo ra và gắn y nguyên vào renderer mới, focus được trả về đúng component cũ. Nếu đi từ regular sang fullscreen, state vi sai của main screen được chụp lại, để khi quay về regular, TuiMainScreen mới tiếp tục từ chỗ cũ thay vì in lại cả lịch sử; đó là “render-state handoff” mà CHANGELOG 0.84.0 nhắc tới (CHANGELOG.md:241). Lúc thoát fullscreen, nếu fullscreenExitOutput là "transcript" (mặc định), interactive mode chuyển hẳn về regular rồi gọi renderNow() để in transcript vào main screen (:883–890); giá trị kia, "resume-hint", chỉ in gợi ý cách mở lại session (settings-selector.ts:728).

Từ sự kiện của session tới component#

InteractiveMode đăng ký một listener với AgentSession và xử lý từng sự kiện trong handleEvent() (interactive-mode.ts:3357–3369). Mỗi nhánh chỉ làm hai việc: đổi cây component, rồi ui.requestRender(); nhịp 16 ms lo phần còn lại.

message_start: tin nhắn user và custom vào chat ngay; assistant thì tạo component stream
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 3457–3479TypeScript
case "message_start":	if (event.message.role === "custom") {		this.addMessageToChat(event.message);		this.ui.requestRender();	} else if (event.message.role === "user") {		this.addMessageToChat(event.message);		this.updatePendingMessagesDisplay();		this.ui.requestRender();	} else if (event.message.role === "assistant") {		this.streamingComponent = new AssistantMessageComponent(			undefined,			this.hideThinkingBlock,			this.getMarkdownThemeWithSettings(),			this.hiddenThinkingLabel,			this.outputPad,			this.getMarkdownTransformers(),		);		this.streamingMessage = event.message;		this.chatContainer.addChild(this.streamingComponent);		this.streamingComponent.updateContent(this.streamingMessage, true);		this.ui.requestRender();	}	break;
Sự kiệnThay đổi trên cây
message_startUser, custom: thêm component vào chatContainer. Assistant: tạo AssistantMessageComponent đang stream và thêm vào cuối chat.
message_updateupdateContent(message, true); với mỗi tool call mới xuất hiện trong luồng, tạo một ToolExecutionComponent ngay cả khi đối số chưa stream xong, hoặc cập nhật đối số của hàng đã có (:3486–3512).
message_endCập nhật lần cuối với isStreaming = false; báo lỗi cho các tool đang chờ nếu bị huỷ hay lỗi, hoặc báo đối số đã đủ để tool sửa file tính diff.
tool_execution_start, _update, _endĐánh dấu bắt đầu, cập nhật kết quả từng phần, kết quả cuối kèm thời gian chạy. Lời gọi lồng (từ script codemode) được vẽ trong hàng của tool cha.
agent_endTắt chỉ báo working và thanh tiến trình của terminal; gỡ component stream còn sót.
entry_appended loại compactionXoá chatContainer và dựng lại từ các entry còn trong context (xem chương Compaction).

Mọi sự kiện đều bắt đầu bằng footer.invalidate(), vì footer hiện số token, chi phí, phần trăm context và có thể đổi sau bất kỳ sự kiện nào. Một ToolExecutionComponent là một Box nền toolPendingBg chuyển sang toolSuccessBg hay toolErrorBg khi có kết quả, và vẽ phần gọi và phần kết quả bằng renderer riêng của tool nếu tool có (tool-execution.ts:104–110; cách tool khai báo renderer thuộc chương Công cụ). Tin nhắn user là một Markdown tự tô nền userMessageBg và tự đệm, không bọc trong Box, vì Box sẽ giữ thêm một bản sao rộng kín màn hình của mỗi dòng (user-message.ts:38–58). Cả tin nhắn user lẫn assistant đều gắn dấu OSC 133, nhờ đó phím nhảy giữa các lời nhắc hoạt động ở cả hai chế độ (mục TuiAltScreen).

Theme và system theme#

Theme: token ngữ nghĩa thành mã ANSI#

Component của coding agent không viết màu cứng. Chúng gọi theme.fg("accent", text) hay theme.bg("toolSuccessBg", text) theo tên vai trò. Danh sách vai trò là THEME_TOKENS, 56 token, mỗi token thuộc ô chữ (foreground) hoặc ô nền (background), có mô tả và có thể có token dự phòng (theme-tokens.ts:24–101). Một file theme JSON gán giá trị cho các token, với sáu dạng giá trị: hex, oklch(...), okhsl(...), chỉ số 0–255, tên biến trong vars, và chuỗi rỗng nghĩa là màu mặc định của terminal (themes.md:83–92).

Ở tầng dưới, pi-tui có kiểu Color là chỉ số bảng màu, sRGB hoặc OKLCH, và mọi màu đều đổi được sang sRGB nên phép trộn màu luôn chạy (colors.ts:4–24). OKLab là không gian màu theo cảm nhận: L là độ sáng từ 0 tới 1, hai trục a, b là cặp màu đối nghịch; OKLCH là dạng toạ độ cực của nó (độ sáng, độ đậm chroma, góc màu hue); OKHSL đo saturation tương đối với mức tối đa mà gam sRGB cho phép ở hue và độ sáng đó, nên mọi giá trị đều hiển thị được và cùng saturation trông đậm như nhau ở mọi hue (README.md:107–109). Khi ghi ra, màu thành 38;2;r;g;b trên terminal truecolor hoặc chỉ số gần nhất của bảng 256 màu (colors.ts:299–307).

Theme: tính sẵn mã ANSI cho từng token; fg() chỉ còn tra bảng và nối chuỗi
pi/packages/coding-agent/src/modes/interactive/theme/theme.tsdòng 226–235, 297–306TypeScript
	const addToken = (token: ThemeToken, value: string | number, isBackground: boolean): string => {		if (value === "") {			(isBackground ? this.defaultBackgroundTokens : this.defaultForegroundTokens).push(token);			return isBackground ? "\x1b[49m" : "\x1b[39m";		}		const color = parseColor(value);		this.concreteColors[token] = color;		(isBackground ? concreteBackgrounds : concreteForegrounds).push(color);		return isBackground ? backgroundAnsi(color, mode) : foregroundAnsi(color, mode);	};fg(color: ThemeColor, text: string): string {	const ansi = this.tokenAnsi(this.fgAnsi, color);	if (this.dimTokens.has(color)) return `${ansi}\x1b[2m${text}\x1b[22;39m`;	return `${ansi}${text}\x1b[39m`;}bg(color: ThemeBg, text: string): string {	const ansi = this.tokenAnsi(this.bgAnsi, color);	return `${ansi}${text}\x1b[49m`;}

fg() nằm trên đường nóng của render, nên mọi chuyển đổi màu được làm một lần lúc tạo theme. Token rỗng thành \x1b[39m hoặc \x1b[49m, tức màu mặc định của terminal; token mờ được thêm SGR 2. Biến toàn cục theme là một Proxy đọc theme thật từ một khoá Symbol.for(...) trên globalThis, để mọi bản sao của module (Node và jiti khi chạy dev) thấy cùng một theme (theme.ts:647–664). Đổi theme thì controller gọi ui.invalidate() rồi requestRender(); mọi cache dòng bị xoá và component dựng lại chuỗi màu (theme-controller.ts:249–252).

System theme: màu lấy từ chính terminal#

Vấn đề. Một bảng màu cố định, dù là dark hay light, hiếm khi hợp với theme mà người dùng đã chọn cho terminal (Solarized, Catppuccin, Gruvbox...), và có thể khó đọc trên nền xám trung tính. Từ 0.99.0, ngay trước bản 1.0, theme mặc định là system (coding-agent CHANGELOG.md:445): Pi hỏi terminal màu chữ, màu nền và 16 màu ANSI, lấy hue của từng vai trò từ bảng màu đó, và tự giải độ sáng sao cho mỗi vai trò đạt một mức tương phản (themes.md:5–12). Bài “There are many themes, but this one is yours” của Earendil tóm tắt đúng các ý này: đọc màu của terminal, giải độ sáng từ quy tắc tương phản bằng đa thức đã khớp, làm việc trong OKLCH và OKHSL, và giới hạn chroma theo chroma của bảng màu (earendil.com/posts/system-theme).

Bước 1: hỏi terminal.

Một lần hỏi: OSC 10, OSC 11, OSC 4 cho 16 màu, và DA1 làm mốc kết thúc
pi/packages/tui/src/tui.tsdòng 160–172TypeScript
const TERMINAL_PALETTE_SIZE = 16;/** OSC 10 and 11 plus OSC 4 for every palette color. */const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;/** * Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request. * Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of * the color replies, including for terminals that ignore the color queries. */const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from(	{ length: TERMINAL_PALETTE_SIZE },	(_, index) => `\x1b]4;${index};?\x07`,).join("")}\x1b[c`;const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;

Mười tám câu hỏi màu và một DA1 đi trong một lần ghi. Câu trả lời tới qua stdin và bị TuiBase nuốt trước mọi xử lý khác (tui.ts:1159–1191). Truy vấn xong khi DA1 tới hoặc khi đủ mười tám câu trả lời; bảng màu chỉ được đặt khi có đủ 16 màu. Interactive mode chờ tối đa 100 ms (theme-controller.ts:20–40); câu trả lời tới muộn hơn, ví dụ qua SSH chậm, vẫn được áp dụng qua onLateReply. Trong lúc chờ, system theme render ở thang xám (saturation 0) thay vì đoán màu (theme.ts:536–549), và header lúc khởi động, vốn “nướng” sẵn màu vào chuỗi, chỉ được dựng sau khi màu đã về (interactive-mode.ts:1005–1008). Tuỳ những gì terminal trả lời, theme được sinh theo một trong ba bậc (system-theme.ts:16–19): có cả nền và bảng màu thì hue lấy từ bảng màu; chỉ có nền thì dùng hue riêng của từng họ màu; không có gì thì dùng chỉ số ANSI 0–15 và màu mặc định để terminal tự vẽ, chữ phụ thì làm mờ bằng SGR 2 thay vì dùng màu ANSI 8 (bright black), vốn gần như vô hình trong vài theme (system-theme.ts:637–655).

Bước 2: họ màu. Mỗi token thuộc một họ, và mỗi họ có hue, một khoảng saturation, và một ô trong bảng ANSI để lấy hue thật của terminal:

Họ màu: hue, khoảng saturation, ô ANSI nguồn
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 41–64TypeScript
/** A family's OKHSL hue and saturation: `max` at mid lightness, falling toward `min` at black and white. */interface Family {	hue: number;	saturation: { min: number; max: number };	/** ANSI palette slot the family takes its hue and saturation from. */	slot: number;}const FAMILIES = {	neutral: { hue: 231.49, saturation: { min: 0.02, max: 0.08 }, slot: 8 },	blue: { hue: 231.49, saturation: { min: 0.1, max: 0.68 }, slot: 4 },	green: { hue: 158.68, saturation: { min: 0.1, max: 0.76 }, slot: 2 },	red: { hue: 20, saturation: { min: 0.1, max: 0.92 }, slot: 1 },	yellow: { hue: 82.36, saturation: { min: 0.5, max: 1 }, slot: 3 },	orange: { hue: 52, saturation: { min: 0.12, max: 0.85 }, slot: 3 },	violet: { hue: 295, saturation: { min: 0.2, max: 0.6 }, slot: 5 },	calamine: { hue: 202.43, saturation: { min: 0.1, max: 0.74 }, slot: 6 },	thinkingSlate: { hue: 231.49, saturation: { min: 0.08, max: 0.2 }, slot: 4 },	thinkingBlue: { hue: 231.49, saturation: { min: 0.2, max: 0.45 }, slot: 4 },	thinkingPeriwinkle: { hue: 263.25, saturation: { min: 0.3, max: 0.6 }, slot: 6 },	thinkingViolet: { hue: 295, saturation: { min: 0.4, max: 0.75 }, slot: 5 },	thinkingMagenta: { hue: 337.5, saturation: { min: 0.5, max: 0.85 }, slot: 13 },	thinkingRed: { hue: 20, saturation: { min: 0.95, max: 1 }, slot: 1 },} satisfies Record<string, Family>;

Ví dụ error thuộc họ red (ô 1), mdLink thuộc blue (ô 4), accent thuộc violet (ô 5); vài token dùng ô khác để khỏi trùng màu với token tương tự, như syntaxString lấy ô 2 (system-theme.ts:68–131). Saturation của họ theo một đường cong chuông: lớn nhất ở độ sáng trung bình, giảm dần về min ở gần đen và gần trắng (:443–453).

Bước 3: mức tương phản là một đa thức.

Curve và levelTarget(): độ sáng cần có là đa thức bậc năm theo độ sáng của bề mặt
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 137–152, 455–460TypeScript
/** * Target-lightness curves: a polynomial in the surface's OKLab lightness giving the OKLab lightness a token * needs on it. `reachable` is the range of surface lightness where the level can be reached; beyond it the * level is relaxed. */interface Curve {	coefficients: number[];	reachable: [number, number];}const LEVELS = {	panel: {		dark: { coefficients: [0.29131, -0.39746, 2.33185, -0.85524, -1.2076, 0.86276], reachable: [0, 0.979] },		light: { coefficients: [-3.74073, 27.94549, -78.44258, 112.6798, -79.60015, 22.11277], reachable: [0.348, 1] },	},	track: {/** The target lightness for a level on a surface, or undefined where the level cannot be reached. */function levelTarget(level: Level, appearance: ThemeAppearance, surfaceL: number): number | undefined {	const curve: Curve = LEVELS[level][appearance];	if (surfaceL < curve.reachable[0] || surfaceL > curve.reachable[1]) return undefined;	return curve.coefficients.reduce((sum, coefficient, power) => sum + coefficient * surfaceL ** power, 0);}

Một “mức” (panel, subtle, readable, emphasis, text...) là hàm cho biết token cần độ sáng OKLab bao nhiêu khi nằm trên một bề mặt có độ sáng cho trước. Mỗi mức có hai đa thức, cho nền tối và nền sáng, với hệ số được khớp theo bản thiết kế theme tham chiếu, và một khoảng reachable ngoài đó mức bị nới (system-theme.ts:11–14). Thay số vào để thấy hình dạng của chúng:

Ví dụ minh hoạ: nền (OKLab L)panelsubtlereadabletext
Tối, 0,100,2740,5640,6690,893
Tối, 0,200,2970,5720,6750,897
Tối, 0,300,3510,6000,6990,915
Sáng, 1,000,6510,456
Sáng, 0,900,5050,209

Các giá trị được tính tay từ hệ số trong LEVELS. Trên nền tối, độ sáng đích gần như cố định (chữ thân bài khoảng 0,9 dù nền là 0,1 hay 0,2); trên nền sáng, khoảng cách cần thiết lớn dần khi nền tối đi (chữ cách nền 0,54 khi nền trắng, 0,69 khi nền 0,9). Đúng như chú thích đầu file mô tả.

Bước 4: quy tắc và thứ tự giải.

RULES: token nào phải đạt mức nào trên những bề mặt nào
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 289–300TypeScript
const each = (tokens: ThemeToken[], on: Surface[], level: Level): Rule[] =>	tokens.map((token) => ({ token, on, level }));const RULES: Rule[] = [	...each(PANELS, ["background"], "panel"),	{ token: "text", on: ["background"], level: "text" },	{ token: "text", on: ["selectedBg"], level: "textOnPanel" },	{ token: "userMessageText", on: ["userMessageBg"], level: "textOnPanel" },	{ token: "toolTitle", on: TOOL_PANELS, level: "textOnPanel" },	...each(["accent", "success", "error", "warning"], ["background", "selectedBg", ...TOOL_PANELS], "readable"),	{ token: "muted", on: ["background", "selectedBg", "customMessageBg", ...TOOL_PANELS], level: "readable" },	{ token: "dim", on: ["background", "selectedBg", "customMessageBg", ...TOOL_PANELS], level: "subtle" },

Mỗi quy tắc nói một token phải đạt một mức trên một danh sách bề mặt: các panel (nền tin nhắn user, nền tool...) phải đạt panel trên nền; text đạt text trên nền và textOnPanel trên selectedBg; accent, error đạt readable trên nền và trên mọi panel tool. Vì token có thể nằm trên một panel mà bản thân panel cũng là token cần giải, SOLVE_ORDER sắp xếp theo phụ thuộc để mọi bề mặt được giải trước token vẽ trên nó (system-theme.ts:352–365).

Bước 5: giải, và nới khi không giải được.

solve(t) và tìm nhị phân mức nới nhỏ nhất
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 522–553TypeScript
const solve = (t: number): Map<Surface | ThemeToken, RgbColor> | undefined => {	const colors = new Map<Surface | ThemeToken, RgbColor>([["background", background]]);	for (const token of SOLVE_ORDER) {		const targets: number[] = [];		for (const rule of RULES) {			if (rule.token !== token) continue;			for (const surface of rule.on) {				const value = target(rule.level, oklabLightness(colors.get(surface) ?? background), t);				if (value === undefined || value < 0 || value > 1) return undefined;				targets.push(value);			}		}		const l = lighter ? Math.max(...targets) : Math.min(...targets);		colors.set(token, PANELS.includes(token as ThemeBg) ? limitPanel(token, l) : paint(token, l));	}	return colors;};let relaxation = 0;let colors = solve(0);if (!colors) {	// Mid-gray backgrounds cannot fit every level: relax as little as possible. Full relaxation always fits.	let [low, high] = [0, 2];	colors = solve(high);	for (let index = 0; index < 20; index++) {		const middle = (low + high) / 2;		const attempt = solve(middle);		if (attempt) [high, colors] = [middle, attempt];		else low = middle;	}	relaxation = high;}

Với mỗi token, solve() tính độ sáng đích cho từng cặp (quy tắc, bề mặt), lấy giá trị khắt khe nhất (lớn nhất trên nền tối, nhỏ nhất trên nền sáng), rồi tô màu ở độ sáng đó với hue và saturation của họ. Panel còn bị giới hạn để chữ trắng (hoặc đen) vẫn đạt tỷ lệ tương phản WCAG 4,5:1 trên nó (system-theme.ts:504–520). Nền xám trung tính không chứa nổi mọi mức: đích có thể vượt khỏi 0–1, và solve(0) trả về undefined. Khi đó tham số nới t được tìm nhị phân trong 20 bước: từ 0 tới 1, các mức mạnh hơn mức sàn (readable trên nền tối, subtle trên nền sáng, system-theme.ts:341) bị nén dần về mức sàn; từ 1 tới 2, mọi mức bị kéo về phía chính bề mặt (system-theme.ts:490–502). t = 2 luôn giải được, nên luôn có kết quả.

Chữ thân bài được xử lý riêng sau cùng. Nếu màu chữ mặc định của terminal đủ đạt mức emphasis trên mọi bề mặt nó nằm, token được để rỗng, tức dùng đúng màu chữ của người dùng; nếu không, nó lấy hue của màu chữ đó ở độ sáng vừa đủ. Trong mọi trường hợp, withTextContrast() đẩy màu về phía trắng hoặc đen cho tới khi đạt 4,5:1 trên mọi bề mặt (system-theme.ts:566–585, :619–635).

Bước 6: giữ chroma của bảng màu.

anchored(): đổi độ sáng của một màu bảng mà không làm nó đậm hơn
pi/packages/coding-agent/src/modes/interactive/theme/system-theme.tsdòng 602–617TypeScript
/** * A source color's hue at another OKHSL lightness. Its saturation applies at its own lightness and falls off * toward black and white along the family's saturation curve, never rising above it. * * OKHSL saturation is relative to the most chroma sRGB allows at a lightness, so the same saturation can mean * more chroma elsewhere: Catppuccin Frappe's pink #f4b8e4 (chroma 0.089) would become #eb76d1 (0.180) at the * lightness the accent needs. Chroma is therefore also capped at the source's, with the same falloff. */function anchored(source: SourceColor, family: Family, lightness: number, saturation: number): RgbColor {	const anchor = saturationCurve(family, source.l);	const falloff = anchor > 0 ? Math.min(1, saturationCurve(family, lightness) / anchor) : 1;	const color = okhslColor(source.h, source.s * falloff * saturation, lightness);	const cap = source.chroma * falloff * saturation;	const { l, c } = colorToOklch(color);	return c <= cap ? color : colorToRgb(oklchColor(l, cap, source.h));}

Khi một màu của bảng được dời sang độ sáng mà token cần, giữ nguyên saturation OKHSL nghe có vẻ đúng, nhưng saturation đó là tương đối với gam màu, và ở độ sáng khác gam cho phép nhiều chroma hơn. Ví dụ trong chú thích: màu hồng #f4b8e4 của Catppuccin Frappe có chroma 0,089, sẽ thành #eb76d1 với chroma 0,180 ở độ sáng mà accent cần, tức gấp đôi độ đậm, và bảng màu pastel không còn pastel. Vì vậy chroma bị chặn trên bằng chroma gốc, giảm dần theo cùng đường cong của họ. Đây là sửa lỗi của 1.0.0 (coding-agent CHANGELOG.md:365).

Bước 7: theo terminal khi nó đổi sáng tối. Với system theme và các cặp theme dạng "light/dark", controller bật thông báo đổi bảng màu (chế độ 2031, \x1b[?2031h; tui.ts:986–994). Terminal hỗ trợ sẽ gửi \x1b[?997;1n khi chuyển sang tối và \x1b[?997;2n khi sang sáng (terminal-colors.ts:85–91); controller hỏi lại màu và sinh lại theme (theme-controller.ts:241–247). Câu hỏi “terminal đang sáng hay tối” được quyết định bằng màu nền và màu chữ terminal báo; nếu không có màu nền thì theo báo cáo 2031, rồi biến COLORFGBG, cuối cùng mặc định là tối (theme.ts:623–636).

Quyết định thiết kế: từ scrollback tới fullscreen#

Thiết kế gốc: ghi tiếp vào scrollback của terminal#

Bài blog tháng 11/2025 của Mario, viết khi pi còn là dự án cá nhân, mô tả pi-tui theo đúng những gì TuiMainScreen làm hôm nay: giữ nguyên scrollback của terminal, component theo kiểu retained mode với render(width), vẽ vi sai bắt đầu từ dòng đầu tiên thay đổi, và bọc mỗi lần cập nhật trong synchronized output CSI ?2026h/CSI ?2026l để không nhấp nháy (What I learned building an opinionated and minimal coding agent). Lựa chọn này cho transcript hành xử như output của mọi chương trình dòng lệnh: nó nằm trong lịch sử của terminal, nên cuộn, tìm kiếm, chọn chữ và copy đều dùng công cụ quen thuộc của chính terminal, và ứng dụng không phải tự cài chúng (tài liệu nói ngắn gọn: “the terminal owns scrollback”, docs/tui.md:62). README vẫn mô tả TuiMainScreen theo cách đó: “renders into the main terminal buffer and preserves terminal scrollback” (README.md:61). Thiết kế này sống tiếp nguyên vẹn thành chế độ regular: cùng thuật toán, chọn bằng tuiMode: "regular" hay --tui-mode regular, và vẫn là renderer của pi config.

Giá của scrollback#

Mục Trace cho thấy mặt trái. Thứ gì đã trôi vào scrollback thì đóng băng: muốn đổi nó, TUI phải xoá cả màn hình lẫn scrollback rồi in lại toàn bộ tài liệu. Với một coding agent, những thay đổi “ở quá khứ” không hiếm: mở rộng output của mọi tool, ẩn hiện khối thinking, một tool cũ trong batch song song xong muộn. Đổi độ rộng cửa sổ cũng buộc in lại tất cả vì mọi đoạn phải ngắt dòng lại. Ở session vài nghìn dòng, đó là vài nghìn dòng mỗi lần, và chỉ synchronized output che được cảnh lịch sử chạy lại trước mắt. Chiều cao còn là một chỗ giằng co: 0.51.1 bỏ vẽ lại khi đổi chiều cao, 0.62.0 bật lại, rồi thêm ngoại lệ Termux. Bố cục cũng bị ràng buộc: editor và footer chỉ là các dòng cuối của tài liệu, không có vùng cố định; overlay phải đệm khung đủ một màn hình. Và chuột thuộc về terminal, nên không có bấm để mở rộng hay kéo thanh cuộn.

Fullscreen: ứng dụng sở hữu viewport#

0.84.0 thêm hai renderer cùng interface, có cuộn do ứng dụng sở hữu, cùng VStack, HStack, ScrollView lồng nhau (CHANGELOG.md:244–245); 1.0.0 đổi mặc định sang fullscreen (coding-agent CHANGELOG.md:328), và bài “Pi 1.0” của Earendil liệt kê “fullscreen by default” trong các thay đổi lớn (Pi 1.0). Đổi lại được:

  • Lượng ghi ra mỗi khung có trần là height hàng, bất kể thay đổi nằm đâu trong tài liệu và bất kể tài liệu dài bao nhiêu. Resize chỉ vẽ lại một màn hình.
  • Dock cố định: editor và trạng thái đứng yên ở đáy cửa sổ trong khi transcript cuộn bên trên (usage.md:86).
  • Cuộn không bị giật về đáy: follow: "end" chỉ theo output khi đang ở đáy; ai cuộn lên đọc thì được để yên, kèm nhãn “nhảy tới tin mới nhất”.
  • Tương tác của ứng dụng: tìm trong transcript, nhảy giữa các lời nhắc, bấm để ẩn hiện thinking, link bấm được, thanh cuộn, thông báo nổi, ảnh Kitty cắt theo viewport.
  • Không mất transcript khi thoát, vì nó được in lại vào main screen.

Cái giá là ứng dụng phải tự làm những việc terminal vốn làm, và làm khác terminal một chút: chọn chữ, copy (OSC 52 không đáng tin nên phải dùng clipboard native), mở link, tìm kiếm, cuộn bằng con lăn. Tính năng riêng của terminal mất tác dụng khi chuột bị bắt: Ghostty không còn gạch chân và xem trước URL khi rê chuột (terminal-setup.md:65–67), cử chỉ trackpad nhanh trên iTerm2 chỉ cuộn khoảng một dòng mỗi lần (:29–39), ảnh iTerm2 thành chữ thay thế, multiplexer chậm đi vì lưu lượng chuột. Phần code cũng lớn: tui-alt-screen.ts gần 1.800 dòng, chưa kể bố cục và tìm kiếm, so với khoảng 650 dòng của tui-main-screen.ts. Vì cả hai lựa chọn đều có người cần, Pi giữ cả hai.

Một cây component, hai renderer#

Điều làm việc giữ cả hai rẻ là component không biết mình đang ở renderer nào. TuiBase chứa mọi thứ chung (focus, overlay, định tuyến input, lịch render, hỏi màu), mỗi lớp con chỉ cài doRender() và vài hook vòng đời. Interactive mode dựng đúng một cây và gắn nó theo hai cách, đổi renderer lúc đang chạy bằng cách tháo cây ra và gắn sang renderer mới, chuyển cả state vi sai của main screen để không in lại lịch sử. Proxy createInteractiveTuiReference() giữ cho các tham chiếu tới TUI trong component luôn trỏ vào renderer hiện hành. Những gì chỉ có nghĩa ở một chế độ được tách bằng kiểu: handleMouse chỉ được gọi ở fullscreen; setLayoutRoot() chỉ có trên ViewportTUI; các layout vẫn có render(width) trả về tài liệu không giới hạn, nên đặt chúng trong cây của main screen cũng chỉ cho ra các dòng xếp chồng bình thường (README.md:164).

Dòng chuỗi thay vì lưới ô#

Nhiều thư viện TUI, curses chẳng hạn, giữ một lưới ô (mỗi ô một ký tự và một style) và so lưới. pi-tui giữ mảng chuỗi có mã ANSI. Lợi ích: một component có thể chỉ dài mười dòng; test so chuỗi; cache là giữ lại một mảng; và phép so khung chỉ là so chuỗi từng dòng. Chi phí: kỷ luật độ rộng thuộc về component; ghép overlay, tô vùng chọn, vẽ thanh cuộn đều phải cắt chuỗi theo cột mà vẫn giữ đúng trạng thái ANSI (extractSegments, sliceByColumn, getGraphemeCellRange trong utils.ts); style phải reset ở cuối mỗi dòng; và ảnh, thứ không phải chữ, phải được nhận diện riêng ở mọi bước bằng isImageLine().

System theme thay vì một bảng màu riêng#

Với theme, Pi chọn mặc định là hoà vào terminal của người dùng thay vì mang theo một bộ màu của mình. Độ dễ đọc không dựa vào việc ai đó chỉnh tay từng bảng màu, mà được bảo đảm bằng quy tắc: mỗi vai trò có mức tương phản trên mỗi bề mặt nó có thể nằm, chữ thân bài luôn đạt 4,5:1 theo WCAG. Đổi lại, cả một bộ giải nhỏ (đa thức đã khớp, tìm nhị phân mức nới, giới hạn chroma) phải chạy mỗi khi terminal báo màu, và ba bậc dự phòng cho terminal không trả lời. Theme dark và light cố định vẫn có cho ai muốn.

Cạm bẫy và trường hợp biên#

Kiểm tra độ rộng trong nhánh vi sai: log, dừng TUI, ném lỗi
pi/packages/tui/src/tui-main-screen.tsdòng 518–545TypeScript
if (!isImage && visibleWidth(line) > width) {	// Log all lines to crash file for debugging	const crashLogPath = path.join(this.logDirectory ?? os.tmpdir(), "pi-tui-crash.log");	const crashData = [		`Crash at ${new Date().toISOString()}`,		`Terminal width: ${width}`,		`Line ${i} visible width: ${visibleWidth(line)}`,		"",		"=== All rendered lines ===",		...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),		"",	].join("\n");	fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });	fs.writeFileSync(crashLogPath, crashData);	// Clean up terminal state before throwing	this.stop();	const errorMsg = [		`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,		"",		"This is likely caused by a custom TUI component not truncating its output.",		"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",		"",		`Debug log written to: ${crashLogPath}`,	].join("\n");	throw new Error(errorMsg);}
  • Kiểm tra chỉ có ở nhánh vi sai. fullRender() không kiểm tra, nên một dòng quá rộng có thể lọt qua khung đầu tiên và chỉ gây lỗi ở khung sau, khi nó nằm trong vùng thay đổi. Ở fullscreen, dòng quá rộng bị cắt im lặng (tui-alt-screen.ts:1709–1712), nên một component sai có thể chạy êm ở fullscreen mà sập ở regular. Kiểm thử ở cả hai chế độ, như tài liệu khuyên (docs/tui.md:182).
  • Ghi thẳng ra stdout khi TUI đang chạy. TuiMainScreen di chuyển con trỏ tương đối từ hardwareCursorRow. Một console.log chen vào giữa làm con trỏ thật lệch khỏi con số đó, và khung sau ghi đè sai chỗ. Interactive mode chỉ dùng console.log trước ui.start() (interactive-mode.ts:965, so với :1000).
  • Chuỗi màu nằm trong state. Theme đổi thì cache dòng bị xoá, nhưng chuỗi đã có mã màu cũ mà ứng dụng tự giữ thì không. Component phải dựng lại chúng trong invalidate(); AssistantMessageComponent làm vậy bằng cách gọi lại updateContent() (assistant-message.ts:51–56).
  • Sửa quá khứ ở regular. Mọi thay đổi trên viewport là một lần in lại cả tài liệu và xoá scrollback bằng \x1b[3J. Ở session dài, mở rộng tất cả tool output hay bật hiện thinking là những thao tác đắt ở regular, rẻ ở fullscreen.
  • Hàng trống còn sót. clearOnShrink tắt mặc định để đỡ vẽ lại; bật bằng setting terminal.clearOnShrink nếu thấy hàng trống còn lại sau khi nội dung co lại (settings.md:108).
  • Esc chậm qua SSH. Một ESC đơn lẻ phải chờ 100 ms qua SSH trước khi được coi là phím Esc. Hạ xuống bằng PI_TUI_ESC_TIMEOUT nếu đường truyền nhanh; đặt quá thấp thì Alt+phím kiểu cũ có thể bị tách thành Esc rồi một chữ.
  • Rò chuỗi nhả phím khi thoát. Với giao thức Kitty, terminal gửi cả sự kiện nhả phím. Nếu tiến trình thoát ngay sau phím Ctrl+D, sự kiện nhả có thể tới sau khi Pi đã trả terminal cho shell và hiện lên như rác, nhất là qua SSH chậm. Interactive mode vì vậy tắt giao thức Kitty rồi “xả” stdin tối đa một giây trước khi dừng (interactive-mode.ts:4282–4288, terminal.ts:435–471).
  • tmux. Phím có phím bổ trợ cần extended-keys; ảnh bị tắt; hyperlink OSC 8 chỉ bật nếu tmux display-message -p '#{client_termfeatures}' có hyperlinks (terminal-image.ts:49–68); chuột ở fullscreen chỉ báo khi giữ nút.
  • Phím nhả và component. Sự kiện nhả phím bị lọc trừ khi component đặt wantsKeyRelease. Component chỉ cần phím nhấn thì không phải làm gì; component nào cần biết lúc phím được thả ra thì phải bật cờ, và chỉ nhận được khi terminal dùng giao thức Kitty.
  • Gỡ lỗi render. PI_TUI_WRITE_LOG ghi lại luồng ANSI thô ra file hoặc thư mục (terminal.ts:176–189); PI_TUI_DEBUG_REDRAW=1 ghi lý do của mỗi lần vẽ lại toàn bộ vào pi-tui-debug.log trong thư mục log, ở coding agent là thư mục agent (tui-main-screen.ts:322–329); PI_TUI_DEBUG=1 chép mỗi khung vẽ theo đường vi sai, kèm các biến trạng thái, vào /tmp/tui (:570–597); lệnh /debug ghi các dòng đã render vào pi-debug.log (usage.md:92).

Tài liệu và code lệch nhau ở v1.4.0#

  • README liệt kê interface Terminal thiếu drainInput, kittyProtocolActive, setTitle, setProgress, setProgramStatus và restoreOnExit so với terminal.ts:78–132.
  • README nói TuiMainScreen có “three rendering strategies” (README.md:743–749); code còn vẽ lại toàn bộ khi đổi chiều cao, khi clearOnShrink kích hoạt, và khi xoá dòng làm viewport lùi lên.
  • README chỉ nhắc ngưỡng dán hơn 10 dòng; code còn tạo marker cho lần dán hơn 1.000 ký tự.
  • AGENTS.md dặn thêm phím mặc định vào DEFAULT_EDITOR_KEYBINDINGS hay DEFAULT_APP_KEYBINDINGS (AGENTS.md:27); ở v1.4.0 hai bảng đó tên là TUI_KEYBINDINGS (pi-tui) và APP_KEYBINDINGS (coding agent). Quy tắc “không viết cứng phím” cũng có vài ngoại lệ trong chính pi-tui: shift+backspace, shift+delete, shift+space trong editor (editor.ts:841) và Shift+Ctrl+D gỡ lỗi trong TUI (tui.ts:1112).

Đọc code ở đâu#

Thứ tựFileĐọc để hiểu
1tui/README.mdHợp đồng component, overlay, chuột, hai renderer, bố cục fullscreen. Đọc trước để có bức tranh.
2tui.ts:117Component, Container, interface TUI, rồi TuiBase: lịch render (1019–1079), định tuyến input (1081–1157), overlay (752–865, 1376–1448), hỏi màu (1520–1542).
3tui-main-screen.ts:247Thuật toán vi sai của chế độ regular, đọc liền một mạch tới hết hàm.
4test/tui-render.test.tsĐặc tả chạy được: spinner giữa màn hình, nội dung co lại, resize, Termux, ảnh Kitty, ghi theo khúc.
5tui-alt-screen.ts:1690Khung fullscreen; sau đó handleViewportInput() (691) và handleMouseEvent() (912) cho phím và chuột.
6layout.ts:379, scroll-view.ts, stack.tsBố cục có chiều cao ràng buộc, theo đáy, chia chỗ theo trọng số.
7terminal.ts, stdin-buffer.tsRaw mode, thương lượng giao thức bàn phím, cắt luồng stdin, bracketed paste, khôi phục khi thoát.
8keys.ts:859, keybindings.tsKhớp một tên phím với mọi cách mã hoá; sổ đăng ký hành động.
9editor.ts:696, autocomplete.tsThứ tự ưu tiên phím, dán, undo, autocomplete bất đồng bộ.
10markdown.ts:303, terminal-image.ts, image.tsHai lớp cache của Markdown; giao thức ảnh và cách ảnh khớp vào mô hình dòng.
11native-platform.ts, native/*/README.mdAddon nào làm gì, nạp ở đâu, đi đường lui thế nào.
12tui-renderer.ts, chat-viewport.tsComposition root, proxy tham chiếu, bố cục fullscreen của coding agent.
13interactive-mode.ts:945init(), switchTuiMode() (892), handleEvent() (3363). File dài 7.000 dòng; dùng grep theo tên sự kiện.
14assistant-message.ts, custom-editor.ts, tool-execution.tsComponent tiêu biểu của ứng dụng: stream, phím của app, hàng tool.
15system-theme.ts, theme-controller.ts, theme.tsBộ giải màu, việc hỏi terminal, lớp Theme; đọc cùng docs/themes.md.
16terminal-setup.md, keybindings.md, tmux.md, tui.mdHành vi theo từng terminal, bảng phím đầy đủ, cấu hình tmux, hướng dẫn viết component cho extension.
17tui CHANGELOG.md:235, coding-agent CHANGELOG.md:324Lúc hai renderer ra đời và lúc fullscreen thành mặc định.

Tóm tắt#

  • pi-tui (khoảng 19.600 dòng, ba dependency) là một thư viện retained mode: component trả về các dòng chuỗi cho một độ rộng, TUI so khung mới với khung trước và chỉ ghi phần khác, bọc trong synchronized output \x1b[?2026h … \x1b[?2026l.
  • requestRender() gộp mọi lời xin thành tối đa một khung mỗi 16 ms; phím bấm được vẽ ngay ở nextTick; requestRender(true) buộc vẽ lại toàn bộ.
  • TuiMainScreen (regular) giữ cả tài liệu làm khung trước, di chuyển con trỏ tương đối tới dòng đầu tiên đổi và ghi đè tới dòng cuối đổi; dòng mới ở đáy đẩy dòng cũ vào scrollback gốc. Thay đổi trên viewport, đổi độ rộng hay chiều cao thì xoá scrollback và in lại toàn bộ.
  • TuiAltScreen (fullscreen, mặc định từ 1.0.0) vẽ đúng một màn hình trong alternate screen, so từng hàng và đặt con trỏ tuyệt đối. Ứng dụng sở hữu viewport: VStack, HStack, ScrollView theo đáy, chuột, chọn chữ, copy, tìm kiếm, nhảy giữa lời nhắc OSC 133; transcript được in lại vào main screen khi thoát.
  • Input đi qua StdinBuffer (cắt chuỗi, gom nội dung dán), thương lượng giao thức bàn phím Kitty với DA1 làm mốc và modifyOtherKeys làm dự phòng, rồi tới listener, focus và component. Phím được gọi theo tên hành động, người dùng cấu hình được, mở rộng bằng declaration merging.
  • Editor có trần chiều cao, marker cho lần dán lớn, undo gộp theo từ, kill ring và autocomplete bất đồng bộ có kiểm tra kết quả cũ. Markdown cache dòng theo (văn bản, độ rộng) và cache token qua WeakRef; ảnh Kitty và iTerm2 chiếm đúng số dòng bằng số hàng ảnh.
  • Interactive mode dựng một cây component, gắn theo hai cách cho hai renderer, đổi renderer lúc đang chạy kèm chuyển state vi sai; mỗi sự kiện của session chỉ đổi cây rồi gọi requestRender().
  • System theme hỏi terminal màu chữ, màu nền và 16 màu ANSI, gán mỗi token vào một họ màu, giải độ sáng từ các mức tương phản là đa thức đã khớp, nới mức khi nền xám không chứa nổi, giữ chữ thân bài ở 4,5:1 và không cho màu bảng đậm hơn chroma gốc.

Mục lục

Giao diện