Skip to content

Commit 7c0bb39

Browse files
authored
feat: handle claude code output better in bottom bar terminal (#2611)
* fix: improve xterm terminal redrawing for Claude Code - Add custom handling for Claude Code's cursor positioning escape sequences - Add resize functionality
1 parent b90dacd commit 7c0bb39

File tree

4 files changed

+92
-3
lines changed

4 files changed

+92
-3
lines changed

apps/web/client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@trpc/server": "^11.0.0",
5757
"@uiw/codemirror-extensions-basic-setup": "^4.23.10",
5858
"@uiw/react-codemirror": "^4.23.10",
59+
"@xterm/addon-fit": "^0.10.0",
5960
"@xterm/xterm": "^5.5.0",
6061
"ai": "^4.3.10",
6162
"blob-util": "^2.0.2",

apps/web/client/src/app/project/[id]/_components/bottom-bar/terminal.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import '@xterm/xterm/css/xterm.css';
44

55
import { useEditorEngine } from '@/components/store/editor';
66
import { cn } from '@onlook/ui/utils';
7+
import { FitAddon } from '@xterm/addon-fit';
78
import { type ITheme } from '@xterm/xterm';
89
import { observer } from 'mobx-react-lite';
910
import { useTheme } from 'next-themes';
@@ -53,6 +54,12 @@ export const Terminal = memo(observer(({ hidden = false, terminalSessionId }: Te
5354
// Only open if not already attached
5455
if (!terminalSession.xterm.element || terminalSession.xterm.element.parentElement !== containerRef.current) {
5556
terminalSession.xterm.open(containerRef.current);
57+
// Ensure proper sizing after opening
58+
setTimeout(() => {
59+
if (terminalSession?.fitAddon && containerRef.current && !hidden) {
60+
terminalSession.fitAddon.fit();
61+
}
62+
}, 100);
5663
}
5764
return () => {
5865
// Detach xterm from DOM on unmount (but do not dispose)
@@ -76,10 +83,31 @@ export const Terminal = memo(observer(({ hidden = false, terminalSessionId }: Te
7683
if (!hidden && terminalSession?.xterm) {
7784
setTimeout(() => {
7885
terminalSession.xterm?.focus();
86+
// Fit terminal when it becomes visible
87+
if (terminalSession.fitAddon) {
88+
terminalSession.fitAddon.fit();
89+
}
7990
}, 100);
8091
}
8192
}, [hidden, terminalSession]);
8293

94+
// Handle container resize
95+
useEffect(() => {
96+
if (!containerRef.current || !terminalSession?.fitAddon || hidden) return;
97+
98+
const resizeObserver = new ResizeObserver(() => {
99+
if (!hidden) {
100+
terminalSession.fitAddon.fit();
101+
}
102+
});
103+
104+
resizeObserver.observe(containerRef.current);
105+
106+
return () => {
107+
resizeObserver.disconnect();
108+
};
109+
}, [terminalSession, hidden]);
110+
83111
return (
84112
<div
85113
ref={containerRef}

apps/web/client/src/components/store/editor/sandbox/terminal.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Task, Terminal, WebSocketSession } from '@codesandbox/sdk';
2+
import { FitAddon } from '@xterm/addon-fit';
23
import { Terminal as XTerm } from '@xterm/xterm';
34
import { v4 as uuidv4 } from 'uuid';
45
import type { ErrorManager } from '../error';
@@ -16,6 +17,7 @@ export interface CLISession {
1617
// Task is readonly
1718
task: Task | null;
1819
xterm: XTerm;
20+
fitAddon: FitAddon;
1921
}
2022

2123
export interface TaskSession extends CLISession {
@@ -33,6 +35,7 @@ export class CLISessionImpl implements CLISession {
3335
terminal: Terminal | null;
3436
task: Task | null;
3537
xterm: XTerm;
38+
fitAddon: FitAddon;
3639

3740
constructor(
3841
public readonly name: string,
@@ -41,7 +44,9 @@ export class CLISessionImpl implements CLISession {
4144
private readonly errorManager: ErrorManager,
4245
) {
4346
this.id = uuidv4();
47+
this.fitAddon = new FitAddon();
4448
this.xterm = this.createXTerm();
49+
this.xterm.loadAddon(this.fitAddon);
4550
this.terminal = null;
4651
this.task = null;
4752

@@ -67,7 +72,22 @@ export class CLISessionImpl implements CLISession {
6772
this.xterm.onData((data: string) => {
6873
terminal.write(data);
6974
});
75+
76+
// Handle terminal resize
77+
this.xterm.onResize(({ cols, rows }) => {
78+
// Check if terminal has resize method
79+
if ('resize' in terminal && typeof terminal.resize === 'function') {
80+
terminal.resize(cols, rows);
81+
}
82+
});
83+
7084
await terminal.open();
85+
86+
// Set initial terminal size and environment
87+
if (this.xterm.cols && this.xterm.rows && 'resize' in terminal && typeof terminal.resize === 'function') {
88+
terminal.resize(this.xterm.cols, this.xterm.rows);
89+
}
90+
7191
} catch (error) {
7292
console.error('Failed to initialize terminal:', error);
7393
this.terminal = null;
@@ -91,16 +111,51 @@ export class CLISessionImpl implements CLISession {
91111
}
92112

93113
createXTerm() {
94-
return new XTerm({
114+
const terminal = new XTerm({
95115
cursorBlink: true,
96116
fontSize: 12,
97117
fontFamily: 'monospace',
98-
convertEol: true,
118+
convertEol: false,
99119
allowTransparency: true,
100120
disableStdin: false,
101121
allowProposedApi: true,
102122
macOptionIsMeta: true,
123+
altClickMovesCursor: false,
124+
windowsMode: false,
125+
scrollback: 1000,
126+
screenReaderMode: false,
127+
fastScrollModifier: 'alt',
128+
fastScrollSensitivity: 5,
103129
});
130+
131+
// Override write method to handle Claude Code's redrawing patterns
132+
const originalWrite = terminal.write.bind(terminal);
133+
terminal.write = (data: string | Uint8Array, callback?: () => void) => {
134+
if (typeof data === 'string') {
135+
// Detect Claude Code's redraw pattern: multiple line clears with cursor movement
136+
const lineUpPattern = /(\x1b\[2K\x1b\[1A)+\x1b\[2K\x1b\[G/;
137+
if (lineUpPattern.test(data)) {
138+
// Count how many lines are being cleared
139+
const matches = data.match(/\x1b\[1A/g);
140+
const lineCount = matches ? matches.length : 0;
141+
142+
// Clear the number of lines being redrawn plus some buffer
143+
for (let i = 0; i <= lineCount + 2; i++) {
144+
terminal.write('\x1b[2K\x1b[1A\x1b[2K');
145+
}
146+
terminal.write('\x1b[G'); // Go to beginning of line
147+
148+
// Extract just the content after the clearing commands
149+
const contentMatch = data.match(/\x1b\[G(.+)$/s);
150+
if (contentMatch && contentMatch[1]) {
151+
return originalWrite(contentMatch[1], callback);
152+
}
153+
}
154+
}
155+
return originalWrite(data, callback);
156+
};
157+
158+
return terminal;
104159
}
105160

106161
async createDevTaskTerminal() {

bun.lock

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"@trpc/server": "^11.0.0",
6161
"@uiw/codemirror-extensions-basic-setup": "^4.23.10",
6262
"@uiw/react-codemirror": "^4.23.10",
63+
"@xterm/addon-fit": "^0.10.0",
6364
"@xterm/xterm": "^5.5.0",
6465
"ai": "^4.3.10",
6566
"blob-util": "^2.0.2",
@@ -278,6 +279,9 @@
278279
"packages/fonts": {
279280
"name": "@onlook/fonts",
280281
"version": "0.0.0",
282+
"dependencies": {
283+
"@onlook/parser": "*",
284+
},
281285
"devDependencies": {
282286
"@onlook/typescript": "*",
283287
"typescript": "^5.5.4",
@@ -298,7 +302,6 @@
298302
"name": "@onlook/growth",
299303
"version": "0.0.0",
300304
"dependencies": {
301-
"@babel/types": "^7.27.0",
302305
"@onlook/parser": "*",
303306
"@onlook/utility": "*",
304307
},
@@ -2059,6 +2062,8 @@
20592062

20602063
"@xmldom/xmldom": ["@xmldom/[email protected]", "", {}, "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw=="],
20612064

2065+
"@xterm/addon-fit": ["@xterm/[email protected]", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
2066+
20622067
"@xterm/xterm": ["@xterm/[email protected]", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
20632068

20642069
"abort-controller": ["[email protected]", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],

0 commit comments

Comments
 (0)