Skip to content

Commit 6c76746

Browse files
committed
feat: add PTY split-view terminal renderer and protocol event types
1 parent 80ed240 commit 6c76746

4 files changed

Lines changed: 178 additions & 0 deletions

File tree

‎apps/cli/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { main } from './main.js';
55
export { createProgram } from './main.js';
66
export { WebSocketClient } from './client/ws-client.js';
77
export { ReconnectHandler } from './client/reconnect.js';
8+
export { SplitTerminalRenderer } from './terminal/split-pane-renderer.js';
9+
810

911
if (process.env['NODE_ENV'] !== 'test') {
1012
main();
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import chalk from 'chalk';
2+
import { colors } from './colors.js';
3+
4+
export interface SplitPaneOptions {
5+
splitRatio?: number; // default 0.45 (45% left, 55% right)
6+
chatTitle?: string;
7+
ptyTitle?: string;
8+
}
9+
10+
export type PaneFocus = 'chat' | 'pty';
11+
12+
export class SplitTerminalRenderer {
13+
private splitRatio: number;
14+
private chatTitle: string;
15+
private ptyTitle: string;
16+
private focus: PaneFocus = 'chat';
17+
18+
private chatBuffer: string[] = [];
19+
private ptyBuffer: string[] = [];
20+
private currentInput = '';
21+
22+
private maxChatHistory = 100;
23+
private maxPtyHistory = 200;
24+
25+
constructor(options: SplitPaneOptions = {}) {
26+
this.splitRatio = options.splitRatio ?? 0.45;
27+
this.chatTitle = options.chatTitle ?? '💬 CHAT & COLLABORATION';
28+
this.ptyTitle = options.ptyTitle ?? '🤖 NATIVE AI VIEW (PTY)';
29+
}
30+
31+
public get currentFocus(): PaneFocus {
32+
return this.focus;
33+
}
34+
35+
public setFocus(focus: PaneFocus): void {
36+
this.focus = focus;
37+
this.render();
38+
}
39+
40+
public toggleFocus(): PaneFocus {
41+
this.focus = this.focus === 'chat' ? 'pty' : 'chat';
42+
this.render();
43+
return this.focus;
44+
}
45+
46+
public appendChat(line: string): void {
47+
const lines = line.split('\n');
48+
for (const l of lines) {
49+
this.chatBuffer.push(l);
50+
}
51+
if (this.chatBuffer.length > this.maxChatHistory) {
52+
this.chatBuffer = this.chatBuffer.slice(this.chatBuffer.length - this.maxChatHistory);
53+
}
54+
this.render();
55+
}
56+
57+
public appendPtyData(data: string): void {
58+
const cleaned = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
59+
const lines = cleaned.split('\n');
60+
for (const l of lines) {
61+
this.ptyBuffer.push(l);
62+
}
63+
if (this.ptyBuffer.length > this.maxPtyHistory) {
64+
this.ptyBuffer = this.ptyBuffer.slice(this.ptyBuffer.length - this.maxPtyHistory);
65+
}
66+
this.render();
67+
}
68+
69+
public setInput(input: string): void {
70+
this.currentInput = input;
71+
this.render();
72+
}
73+
74+
public clearPty(): void {
75+
this.ptyBuffer = [];
76+
this.render();
77+
}
78+
79+
public render(): void {
80+
if (!process.stdout.isTTY) return;
81+
82+
const totalCols = process.stdout.columns || 120;
83+
const totalRows = process.stdout.rows || 30;
84+
85+
const leftCols = Math.max(20, Math.floor(totalCols * this.splitRatio));
86+
const rightCols = Math.max(20, totalCols - leftCols - 1);
87+
const contentRows = Math.max(5, totalRows - 4); // 1 header, 1 subheader, contentRows, 1 divider, 1 footer input
88+
89+
// Header bar
90+
const leftHeaderStr = ` ${this.chatTitle} `.padEnd(leftCols).slice(0, leftCols);
91+
const rightHeaderStr = ` ${this.ptyTitle} `.padEnd(rightCols).slice(0, rightCols);
92+
93+
const leftHeaderFormatted =
94+
this.focus === 'chat'
95+
? chalk.bgCyan.black.bold(leftHeaderStr)
96+
: chalk.bgBlue.white(leftHeaderStr);
97+
98+
const rightHeaderFormatted =
99+
this.focus === 'pty'
100+
? chalk.bgCyan.black.bold(rightHeaderStr)
101+
: chalk.bgBlue.white(rightHeaderStr);
102+
103+
const headerLine = `${leftHeaderFormatted}│${rightHeaderFormatted}`;
104+
105+
// Subheader / Status Line
106+
const leftFocusTag = this.focus === 'chat' ? colors.green('[ACTIVE FOCUS]') : colors.dim('[Press TAB to focus]');
107+
const rightFocusTag = this.focus === 'pty' ? colors.green('[ACTIVE FOCUS]') : colors.dim('[Press TAB to focus]');
108+
109+
const subLeft = ` ${leftFocusTag}`.padEnd(leftCols + 10).slice(0, leftCols + 10);
110+
const subRight = ` ${rightFocusTag}`.padEnd(rightCols + 10).slice(0, rightCols + 10);
111+
const subheaderLine = `${subLeft}│${subRight}`;
112+
113+
// Prepare viewport slice
114+
const visibleChat = this.chatBuffer.slice(-contentRows);
115+
const visiblePty = this.ptyBuffer.slice(-contentRows);
116+
117+
const bodyLines: string[] = [];
118+
119+
for (let r = 0; r < contentRows; r++) {
120+
const chatLine = visibleChat[r] ?? '';
121+
const ptyLine = visiblePty[r] ?? '';
122+
123+
const truncatedChat = this.stripAnsiAndPad(chatLine, leftCols);
124+
const truncatedPty = this.stripAnsiAndPad(ptyLine, rightCols);
125+
126+
bodyLines.push(`${truncatedChat}│${truncatedPty}`);
127+
}
128+
129+
// Horizontal divider above input
130+
const lineDivider = '─'.repeat(totalCols);
131+
132+
// Footer input bar
133+
const focusHelp =
134+
this.focus === 'chat'
135+
? colors.cyan('💬 Focus: Chat Input') + colors.dim(' (Press TAB for PTY)')
136+
: colors.magenta('🤖 Focus: Native PTY Keypresses') + colors.dim(' (Press TAB for Chat)');
137+
138+
const promptText = `> ${this.currentInput}`;
139+
const footerLine = `${promptText.padEnd(totalCols - 35).slice(0, totalCols - 35)} │ ${focusHelp}`;
140+
141+
// Frame output assembly using ANSI screen home \x1b[H
142+
const frame = ['\x1b[H', headerLine, subheaderLine, ...bodyLines, lineDivider, footerLine].join('\n');
143+
process.stdout.write(frame);
144+
}
145+
146+
private stripAnsiAndPad(text: string, width: number): string {
147+
const plain = text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
148+
if (plain.length >= width) {
149+
return text.slice(0, width);
150+
}
151+
return text + ' '.repeat(width - plain.length);
152+
}
153+
}

‎packages/protocol/src/constants.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export const EVENT_TYPES = {
3939
AI_STREAM_FAILED: 'ai.stream.failed',
4040
AI_STREAM_TIMEOUT: 'ai.stream.timeout',
4141
AI_STREAM_ERROR: 'ai.stream.error',
42+
43+
// PTY Event Types
44+
PTY_OUTPUT: 'pty.output',
45+
PTY_RESIZE: 'pty.resize',
46+
PTY_INPUT: 'pty.input',
4247
} as const;
4348

4449
export type EventType = (typeof EVENT_TYPES)[keyof typeof EVENT_TYPES] | (string & {});

‎packages/protocol/src/types.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,21 @@ export interface AIStreamErrorPayload {
136136
error: string;
137137
code?: string;
138138
}
139+
140+
export interface PTYOutputPayload {
141+
streamId: string;
142+
adapterName: string;
143+
data: string;
144+
}
145+
146+
export interface PTYResizePayload {
147+
streamId: string;
148+
cols: number;
149+
rows: number;
150+
}
151+
152+
export interface PTYInputPayload {
153+
streamId: string;
154+
data: string;
155+
}
156+

0 commit comments

Comments
 (0)