Skip to content

Commit 754ec24

Browse files
committed
fix(inquirerer): simplify viewport rendering with cursor positioning
- Remove ansi-diff usage in favor of simple cursor positioning - Fix viewport to reserve space on first render - Move cursor back to viewport start before each redraw - Clear and redraw each line for reliable updates - Remove commitMessage calls that were causing duplicate output The viewport now uses a simple approach: 1. On first render, reserve space by printing empty lines 2. On subsequent renders, move cursor up and redraw in-place 3. Each line is cleared before writing new content
1 parent 6afd415 commit 754ec24

File tree

2 files changed

+42
-92
lines changed

2 files changed

+42
-92
lines changed

packages/inquirerer/src/ui/aicode.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export class AICodeUI {
166166
* Add a message to the chat
167167
*/
168168
addMessage(role: MessageRole, content: string): this {
169-
// If there's a streaming message, commit it first
169+
// If there's a streaming message, end it first
170170
if (this.isStreaming) {
171171
this.endStream();
172172
}
@@ -177,8 +177,8 @@ export class AICodeUI {
177177
timestamp: new Date(),
178178
});
179179

180-
// Commit the message to scrollback
181-
this.commitMessage(this.messages[this.messages.length - 1]);
180+
// Scroll to show the latest message
181+
this.scrollOffset = 0;
182182

183183
this.render();
184184
return this;
@@ -229,12 +229,10 @@ export class AICodeUI {
229229
if (lastMessage && lastMessage.isStreaming) {
230230
lastMessage.isStreaming = false;
231231
lastMessage.content = this.streamingContent;
232-
233-
// Commit the completed message to scrollback
234-
this.commitMessage(lastMessage);
235232
}
236233

237234
this.streamingContent = '';
235+
this.scrollOffset = 0;
238236
this.render();
239237
return this;
240238
}
@@ -264,14 +262,6 @@ export class AICodeUI {
264262
return this.lineEditor.text;
265263
}
266264

267-
/**
268-
* Commit a message to scrollback (above the viewport)
269-
*/
270-
private commitMessage(message: ChatMessage): void {
271-
const formatted = this.formatMessage(message);
272-
this.viewport.commit(formatted + '\n');
273-
}
274-
275265
/**
276266
* Format a message for display
277267
*/

packages/inquirerer/src/ui/viewport.ts

Lines changed: 38 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
2-
* ViewportRenderer - Diff-based terminal rendering with scrollback preservation
2+
* ViewportRenderer - Terminal rendering with scrollback preservation
33
*
44
* This module implements a terminal UI renderer that:
55
* - Renders to a fixed "viewport" region at the bottom of the terminal
6-
* - Uses diff-based updates to minimize flicker and escape sequences
6+
* - Uses cursor positioning to update content in-place
77
* - Preserves native terminal scrollback for committed output
88
* - Supports terminal resize handling
99
*
@@ -12,10 +12,6 @@
1212

1313
import { Writable } from 'stream';
1414

15-
// ansi-diff for minimal diff-based rendering
16-
// eslint-disable-next-line @typescript-eslint/no-require-imports
17-
const ansiDiff = require('ansi-diff');
18-
1915
/**
2016
* ANSI escape codes for terminal control
2117
*/
@@ -28,9 +24,10 @@ const ANSI = {
2824
saveCursor: '\x1B7',
2925
restoreCursor: '\x1B8',
3026
cursorTo: (row: number, col: number) => `\x1B[${row};${col}H`,
31-
cursorUp: (n: number) => `\x1B[${n}A`,
32-
cursorDown: (n: number) => `\x1B[${n}B`,
27+
cursorUp: (n: number) => n > 0 ? `\x1B[${n}A` : '',
28+
cursorDown: (n: number) => n > 0 ? `\x1B[${n}B` : '',
3329
cursorToColumn: (col: number) => `\x1B[${col}G`,
30+
cursorToStart: '\r',
3431

3532
// Line clearing (does NOT affect scrollback)
3633
clearLine: '\x1B[2K',
@@ -66,17 +63,17 @@ export interface ViewportState {
6663
* Key concepts:
6764
* - "Committed output" is written normally and becomes part of scrollback
6865
* - "Live viewport" is a reserved region that gets updated in-place
69-
* - Uses ansi-diff for minimal escape sequence output
66+
* - Uses cursor positioning to redraw content efficiently
7067
*/
7168
export class ViewportRenderer {
7269
private output: Writable;
7370
private viewportHeight: number;
7471
private hideCursorEnabled: boolean;
75-
private diff: ReturnType<typeof ansiDiff>;
7672
private isRunning: boolean = false;
7773
private terminalWidth: number;
7874
private terminalHeight: number;
7975
private resizeHandler: (() => void) | null = null;
76+
private hasRenderedOnce: boolean = false;
8077

8178
constructor(options: ViewportRendererOptions = {}) {
8279
this.output = options.output ?? process.stdout;
@@ -87,12 +84,6 @@ export class ViewportRenderer {
8784
const tty = this.output as NodeJS.WriteStream;
8885
this.terminalWidth = tty.columns ?? 80;
8986
this.terminalHeight = tty.rows ?? 24;
90-
91-
// Initialize ansi-diff with terminal dimensions
92-
this.diff = ansiDiff({
93-
width: this.terminalWidth,
94-
height: this.viewportHeight,
95-
});
9687
}
9788

9889
/**
@@ -117,13 +108,11 @@ export class ViewportRenderer {
117108
*/
118109
setViewportHeight(height: number): void {
119110
this.viewportHeight = height;
120-
this.diff.resize({ height });
121111
}
122112

123113
/**
124114
* Start the viewport renderer
125115
* - Sets up resize handling
126-
* - Reserves space for the viewport
127116
* - Hides cursor if configured
128117
*/
129118
start(): this {
@@ -135,10 +124,6 @@ export class ViewportRenderer {
135124
const tty = this.output as NodeJS.WriteStream;
136125
this.terminalWidth = tty.columns ?? 80;
137126
this.terminalHeight = tty.rows ?? 24;
138-
this.diff.resize({
139-
width: this.terminalWidth,
140-
height: this.viewportHeight,
141-
});
142127
};
143128

144129
if (typeof (this.output as NodeJS.WriteStream).on === 'function') {
@@ -150,63 +135,37 @@ export class ViewportRenderer {
150135
this.write(ANSI.hideCursor);
151136
}
152137

153-
// Reserve space for viewport by printing empty lines
154-
// This ensures we have room to render without scrolling
155-
this.reserveViewportSpace();
156-
157138
return this;
158139
}
159140

160-
/**
161-
* Reserve space for the viewport at the bottom of the terminal
162-
*/
163-
private reserveViewportSpace(): void {
164-
// Print empty lines to ensure viewport space exists
165-
for (let i = 0; i < this.viewportHeight; i++) {
166-
this.write('\n');
167-
}
168-
// Move cursor back up to the start of viewport
169-
this.write(ANSI.cursorUp(this.viewportHeight));
170-
}
171-
172141
/**
173142
* Write committed output that becomes part of scrollback
174143
* This should be used for completed messages, not live updates
175144
*/
176145
commit(text: string): this {
177-
if (!this.isRunning) {
178-
this.write(text);
179-
return this;
146+
// First, clear the current viewport by moving up and clearing lines
147+
if (this.hasRenderedOnce) {
148+
this.write(ANSI.cursorUp(this.viewportHeight));
149+
for (let i = 0; i < this.viewportHeight; i++) {
150+
this.write(ANSI.clearLine + '\n');
151+
}
152+
this.write(ANSI.cursorUp(this.viewportHeight));
180153
}
181154

182-
// Save cursor position
183-
this.write(ANSI.saveCursor);
184-
185-
// Move to the line above the viewport
186-
// We need to scroll the viewport content up first
187-
this.write(ANSI.cursorUp(this.viewportHeight));
188-
189-
// Write the committed text (this will scroll naturally)
155+
// Write the committed text (this becomes scrollback)
190156
this.write(text);
191157
if (!text.endsWith('\n')) {
192158
this.write('\n');
193159
}
194160

195-
// Re-reserve viewport space
196-
this.reserveViewportSpace();
197-
198-
// Force a full redraw of the viewport
199-
this.diff = ansiDiff({
200-
width: this.terminalWidth,
201-
height: this.viewportHeight,
202-
});
161+
this.hasRenderedOnce = false;
203162

204163
return this;
205164
}
206165

207166
/**
208167
* Render the viewport with the given state
209-
* Uses diff-based rendering to minimize escape sequences
168+
* Uses cursor positioning to redraw content in-place
210169
*/
211170
render(state: ViewportState): this {
212171
if (!this.isRunning) {
@@ -221,25 +180,31 @@ export class ViewportRenderer {
221180
lines.push('');
222181
}
223182

224-
// Join lines and compute diff
225-
const content = lines.join('\n');
226-
const diffOutput = this.diff.update(content);
183+
// On first render, reserve space by printing empty lines
184+
// This establishes the viewport region
185+
if (!this.hasRenderedOnce) {
186+
for (let i = 0; i < this.viewportHeight; i++) {
187+
this.write('\n');
188+
}
189+
this.hasRenderedOnce = true;
190+
}
227191

228-
// Save cursor, move to viewport start, apply diff, restore cursor
229-
this.write(ANSI.saveCursor);
230-
this.write(diffOutput);
192+
// Move cursor back to start of viewport
193+
this.write(ANSI.cursorUp(this.viewportHeight));
194+
this.write(ANSI.cursorToStart);
231195

232-
// Position cursor if specified
233-
if (state.cursorRow !== undefined && state.cursorCol !== undefined) {
234-
// Calculate absolute position within viewport
235-
const row = Math.min(state.cursorRow, this.viewportHeight - 1);
236-
const col = state.cursorCol;
237-
this.write(ANSI.cursorUp(this.viewportHeight - 1 - row));
238-
this.write(ANSI.cursorToColumn(col + 1)); // ANSI columns are 1-indexed
239-
} else {
240-
this.write(ANSI.restoreCursor);
196+
// Clear and redraw each line
197+
for (let i = 0; i < lines.length; i++) {
198+
this.write(ANSI.clearLine);
199+
this.write(lines[i]);
200+
if (i < lines.length - 1) {
201+
this.write('\n');
202+
}
241203
}
242204

205+
// Move cursor to end of viewport (for next render)
206+
this.write('\n');
207+
243208
return this;
244209
}
245210

@@ -255,7 +220,6 @@ export class ViewportRenderer {
255220
* Stop the viewport renderer
256221
* - Removes resize handler
257222
* - Shows cursor
258-
* - Clears viewport
259223
*/
260224
stop(): this {
261225
if (!this.isRunning) return this;
@@ -267,10 +231,6 @@ export class ViewportRenderer {
267231
this.resizeHandler = null;
268232
}
269233

270-
// Clear viewport and move cursor to end
271-
this.clear();
272-
this.write(ANSI.cursorDown(this.viewportHeight));
273-
274234
// Show cursor
275235
if (this.hideCursorEnabled) {
276236
this.write(ANSI.showCursor);

0 commit comments

Comments
 (0)