Skip to content

Commit 839ecfd

Browse files
committed
Introducing new headless local search, terminal resize fix and fixing input lagging
1 parent 445a67e commit 839ecfd

8 files changed

Lines changed: 123 additions & 102 deletions

eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser';
33

44
export default [
55
{
6-
ignores: ['dist/**', 'node_modules/**', 'api/**', 'binaries/**']
6+
ignores: ['dist/**', 'node_modules/**', 'api/**', 'binaries/**', '.claude/worktrees/**', '.worktrees/**']
77
},
88
{
99
files: ['src/**/*.ts', 'tests/**/*.ts'],

src/ui/inputPrompt.ts

Lines changed: 48 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -989,35 +989,30 @@ export function leavePromptSurface(
989989
function handlePasteComplete(
990990
pasteState: PasteState,
991991
rl: readline.Interface,
992-
output: NodeJS.WriteStream
992+
output: NodeJS.WriteStream,
993+
renderActivePrompt: () => void
993994
): void {
994995
const display = getContentDisplay(pasteState.buffer);
995-
const rlAny = rl as readline.Interface & { line: string; cursor: number; _refreshLine?: () => void };
996+
const rlAny = rl as readline.Interface & { line: string; cursor: number };
996997

997998
// Get any prefix content that was typed before the paste
998999
const prefix = pasteState.prefixContent || '';
9991000

1000-
// Count newlines to know how many extra prompt lines were printed
1001-
const newlineCount = (pasteState.buffer.match(/\n/g) || []).length;
1002-
1003-
// If readline echoed pasted rows, clear that transient output.
1004-
// When output is suppressed, skip this so we don't move into chat logs.
1001+
// If readline echoed pasted rows (timeout fallback path where output was
1002+
// not suppressed), clear those transient lines. On the normal bracketed-
1003+
// paste path outputSuppressed is true so the box was never disturbed.
10051004
if (!pasteState.outputSuppressed) {
1006-
// Clear all the extra lines that readline printed during paste
1007-
// Move cursor up for each newline, clearing as we go
1005+
const newlineCount = (pasteState.buffer.match(/\n/g) || []).length;
10081006
for (let i = 0; i < newlineCount; i++) {
1009-
readline.moveCursor(output, 0, -1); // Move up one line
1010-
readline.clearLine(output, 0); // Clear that line
1007+
readline.moveCursor(output, 0, -1);
1008+
readline.clearLine(output, 0);
10111009
}
1012-
1013-
// Now we're back at the original prompt line - clear it too
10141010
readline.cursorTo(output, 0);
10151011
readline.clearLine(output, 0);
10161012
}
10171013

10181014
if (display.isPasted) {
10191015
// Large paste: show indicator, store actual content
1020-
// Prepend prefix to hidden content so it's included in submission
10211016
pasteState.hiddenContent = prefix + display.actual;
10221017
rlAny.line = prefix + display.visual;
10231018
rlAny.cursor = rlAny.line.length;
@@ -1027,8 +1022,9 @@ function handlePasteComplete(
10271022
rlAny.cursor = rlAny.line.length;
10281023
}
10291024

1030-
// Refresh the display with clean prompt
1031-
rl.prompt(true);
1025+
// Use the boxed renderer — NOT rl.prompt(true) — so the composer stays
1026+
// anchored at the bottom and the 3-row layout is preserved.
1027+
renderActivePrompt();
10321028

10331029
// Clear the buffer and prefix
10341030
pasteState.buffer = '';
@@ -1045,7 +1041,6 @@ async function promptOnce(options: PromptOnceOptions): Promise<PromptResult> {
10451041
// Initialize paste state for bracketed paste detection
10461042
const pasteState = createPasteState();
10471043
let contextualHelpVisible = false;
1048-
let renderedContextualHelpLines = 0;
10491044

10501045
const applyPlanModePrefix = (line: string): string => {
10511046
const planPrefix = getPlanModeManager().isEnabled() ? 'plan:on' : 'plan:off';
@@ -1066,26 +1061,8 @@ async function promptOnce(options: PromptOnceOptions): Promise<PromptResult> {
10661061
return applyPlanModePrefix(statusLine ?? '');
10671062
};
10681063

1069-
const getActiveContextualHelpLines = (): string[] => {
1070-
if (!contextualHelpVisible) {
1071-
return [];
1072-
}
1073-
const rlAny = rl as readline.Interface & { line?: string };
1074-
const currentLine = rlAny.line ?? '';
1075-
const width = getPromptBlockWidth(stdOutput.columns);
1076-
return buildContextualHelpPanelLines(currentLine, width, files, slashCommands);
1077-
};
1078-
10791064
const renderPromptSurface = (isResize = false, hasExistingPromptBlock = true): void => {
1080-
const helpLines = getActiveContextualHelpLines();
1081-
renderPromptLine(
1082-
rl,
1083-
getActiveStatusLine(),
1084-
stdOutput,
1085-
isResize,
1086-
hasExistingPromptBlock,
1087-
suggestionText
1088-
);
1065+
renderPromptLine(rl, getActiveStatusLine(), stdOutput, isResize, hasExistingPromptBlock, suggestionText);
10891066
};
10901067

10911068
const resizeWatcher = new TerminalResizeWatcher(stdOutput, () => {
@@ -1104,23 +1081,6 @@ async function promptOnce(options: PromptOnceOptions): Promise<PromptResult> {
11041081
renderPromptSurface(false, true);
11051082
}
11061083

1107-
// Override readline's _refreshLine to use our renderPromptLine instead.
1108-
// readline's default _refreshLine miscalculates cursor position when the
1109-
// prompt contains ANSI escape codes (chalk styling), causing the cursor
1110-
// to appear on top of typed text rather than after it.
1111-
const rlInternal = rl as readline.Interface & { _refreshLine?: () => void };
1112-
const originalRefreshLine = typeof rlInternal._refreshLine === 'function'
1113-
? rlInternal._refreshLine.bind(rlInternal)
1114-
: undefined;
1115-
1116-
if (typeof rlInternal._refreshLine === 'function') {
1117-
rlInternal._refreshLine = () => {
1118-
if (!pasteState.isInPaste) {
1119-
renderPromptLine(rl, statusLine, stdOutput);
1120-
}
1121-
};
1122-
}
1123-
11241084
return new Promise<PromptResult>((resolve) => {
11251085
let ctrlCCount = 0;
11261086
let closed = false;
@@ -1327,7 +1287,7 @@ async function promptOnce(options: PromptOnceOptions): Promise<PromptResult> {
13271287
pasteState.buffer, onImageDetected, { announce: false, output: stdOutput }
13281288
);
13291289
}
1330-
handlePasteComplete(pasteState, rl, stdOutput);
1290+
handlePasteComplete(pasteState, rl, stdOutput, renderActivePrompt);
13311291
// Schedule a deferred fallback scan in case the synchronous
13321292
// replacement missed (e.g. file not yet materialized).
13331293
scheduleInlineImageScan(10);
@@ -1358,7 +1318,7 @@ async function promptOnce(options: PromptOnceOptions): Promise<PromptResult> {
13581318
pasteState.buffer, onImageDetected, { announce: false, output: stdOutput }
13591319
);
13601320
}
1361-
handlePasteComplete(pasteState, rl, stdOutput);
1321+
handlePasteComplete(pasteState, rl, stdOutput, renderActivePrompt);
13621322
scheduleInlineImageScan(10);
13631323
}
13641324
}, 50);
@@ -1620,6 +1580,10 @@ function getComposerBorderStyle(line: string): InputBorderStyle {
16201580
return 'default';
16211581
}
16221582

1583+
// Track the width used by the last renderPromptLine call so we can detect
1584+
// width changes (resize) and compute reflow line counts for clearing.
1585+
let lastRenderedPromptWidth = 0;
1586+
16231587
function renderPromptLine(
16241588
rl: readline.Interface,
16251589
statusLine: string | { left: string; right: string } | undefined,
@@ -1638,25 +1602,41 @@ function renderPromptLine(
16381602
const bottomBorder = drawInputBottomBorder(width, borderStyle);
16391603
const statusRow = formatPromptStatusRow(statusLine, width);
16401604

1605+
// Detect width change even when called from _refreshLine (which passes
1606+
// isResize=false). Readline triggers _refreshLine on resize before our
1607+
// debounced handler fires, so we must use the reflow-aware clearing path
1608+
// whenever the width has actually changed.
1609+
const widthChanged = lastRenderedPromptWidth > 0 && width !== lastRenderedPromptWidth;
1610+
const effectiveResize = isResize || widthChanged;
1611+
16411612
// Keep readline's prompt in sync for line editing internals.
16421613
rl.setPrompt(PROMPT_PREFIX);
16431614

16441615
// Hide cursor during rendering to prevent flicker/slow blinking.
1645-
// The cursor visibly jumps as lines are cleared and rewritten;
1646-
// hiding it produces a clean, natural blink at the final position.
16471616
output.write('\x1b[?25l');
16481617

1649-
if (isResize) {
1650-
// Cursor sits on the input row. Move up to include the top border
1651-
// before clearing, otherwise old borders at previous width remain.
1652-
for (let i = 0; i < PROMPT_LINES_ABOVE_INPUT; i++) {
1653-
readline.moveCursor(output, 0, -1);
1654-
}
1618+
if (effectiveResize && hasExistingPromptBlock) {
1619+
// When the terminal resizes, it reflows all previously written content.
1620+
// A line of N chars wraps to ceil(N / newCols) physical rows at the new
1621+
// terminal width. We must move up enough to reach above ALL reflowed
1622+
// remnants of the old prompt block before clearing.
1623+
const termCols = output.columns ?? 80;
1624+
const oldWidth = lastRenderedPromptWidth || width;
1625+
const logicalLines = PROMPT_BLOCK_LINE_COUNT + STATUS_LINE_COUNT;
1626+
// Use actual terminal columns (not prompt width) since that's what
1627+
// the terminal uses for reflow calculations.
1628+
const rowsPerOldLine = Math.max(1, Math.ceil(oldWidth / Math.max(1, termCols)));
1629+
const totalReflowedRows = logicalLines * rowsPerOldLine;
1630+
// Move up generously to reach above all reflowed prompt content.
1631+
// Add extra margin because the cursor's physical position within the
1632+
// reflowed input row is uncertain.
1633+
const moveUp = totalReflowedRows + rowsPerOldLine;
1634+
readline.moveCursor(output, 0, -moveUp);
16551635
readline.cursorTo(output, 0);
16561636
readline.clearScreenDown(output);
16571637
} else if (hasExistingPromptBlock) {
1658-
// Cursor normally sits on the input row.
1659-
// Clear the full prompt block in place before re-drawing.
1638+
// Same-width redraw: cursor sits on the input row.
1639+
// Clear the fixed 4-line prompt block in place.
16601640
readline.cursorTo(output, 0);
16611641
for (let i = 0; i < PROMPT_LINES_ABOVE_INPUT; i++) {
16621642
readline.moveCursor(output, 0, -1);
@@ -1673,9 +1653,7 @@ function renderPromptLine(
16731653
}
16741654
readline.cursorTo(output, 0);
16751655
} else {
1676-
// Initial render: the cursor has been placed on a fresh row by createReadline,
1677-
// but defensively clear the current line before drawing the top border to
1678-
// eliminate any residual characters that could cause a one-frame flash.
1656+
// Initial render: defensively clear current line before drawing.
16791657
readline.cursorTo(output, 0);
16801658
readline.clearLine(output, 0);
16811659
}
@@ -1687,11 +1665,11 @@ function renderPromptLine(
16871665
output.write(statusRow);
16881666

16891667
// Move cursor back to input row, inside the box.
1690-
// drawInputBox uses chalk background only (no │ side borders),
1691-
// so cursorColumn from buildPromptRenderState is already correct.
16921668
readline.moveCursor(output, 0, -(PROMPT_LINES_BELOW_INPUT + STATUS_LINE_COUNT));
16931669
readline.cursorTo(output, prompt.cursorColumn);
16941670

16951671
// Show cursor at its final, correct position.
16961672
output.write('\x1b[?25h');
1673+
1674+
lastRenderedPromptWidth = width;
16971675
}

src/ui/terminalResize.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,30 @@ type ResizeCallback = () => void;
1111

1212
/**
1313
* Watches a TTY stream for resize events and runs the supplied callback.
14+
* Debounces rapid events (e.g. while dragging a window edge) to avoid
15+
* redundant redraws that leave ghost prompt artifacts.
1416
*/
1517
export class TerminalResizeWatcher {
1618
private readonly stream: ResizableStream;
1719
private readonly handler: ResizeCallback;
1820
private disposed = false;
21+
private debounceTimer: NodeJS.Timeout | undefined;
1922

20-
constructor(stream: ResizableStream, callback: ResizeCallback) {
23+
constructor(stream: ResizableStream, callback: ResizeCallback, debounceMs = 80) {
2124
this.stream = stream;
2225
this.handler = () => {
2326
if (this.disposed) {
2427
return;
2528
}
26-
callback();
29+
if (this.debounceTimer) {
30+
clearTimeout(this.debounceTimer);
31+
}
32+
this.debounceTimer = setTimeout(() => {
33+
this.debounceTimer = undefined;
34+
if (!this.disposed) {
35+
callback();
36+
}
37+
}, debounceMs);
2738
};
2839
if (this.stream && typeof this.stream.on === 'function') {
2940
this.stream.on('resize', this.handler);
@@ -35,6 +46,10 @@ export class TerminalResizeWatcher {
3546
return;
3647
}
3748
this.disposed = true;
49+
if (this.debounceTimer) {
50+
clearTimeout(this.debounceTimer);
51+
this.debounceTimer = undefined;
52+
}
3853
if (this.stream && typeof this.stream.off === 'function') {
3954
this.stream.off('resize', this.handler);
4055
}

tests/core/agent.startup-ui.spec.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,14 +1120,10 @@ describe('agent startup and active input UI', () => {
11201120
collectWorkspaceFiles: vi.fn(async () => {}),
11211121
};
11221122

1123-
// Mock readInstruction to resolve immediately
1124-
const readInstructionMock = vi.fn(async () => 'test input');
1125-
11261123
// Replace the private method's dependency on readInstruction
11271124
// by checking the timing: promptForInstruction must NOT wait
11281125
// more than 200ms before invoking readInstruction.
1129-
const start = Date.now();
1130-
const promptPromise = (agent as any).promptForInstruction([], []).catch(() => {});
1126+
const _promptPromise = (agent as any).promptForInstruction([], []).catch(() => {});
11311127

11321128
// Give it a short window to proceed
11331129
await new Promise((r) => setTimeout(r, 200));

tests/googleHeadlessSearch.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* and HTML result parsing from rendered DOM.
99
*/
1010

11-
import { describe, it, expect, vi, afterEach } from 'vitest';
11+
import { describe, it, expect } from 'vitest';
1212

1313
// We'll test the exported helpers from web.ts
1414
import { findChromePath, parseGoogleResultsFromDOM } from '../src/actions/web.js';

tests/terminalResize.spec.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,78 @@ class FakeStream extends EventEmitter {
1818
}
1919

2020
describe('TerminalResizeWatcher', () => {
21-
it('invokes the provided callback when resize events occur', () => {
21+
it('invokes the provided callback after debounce period', async () => {
22+
vi.useFakeTimers();
2223
const stream = new FakeStream();
2324
const handler = vi.fn();
2425

2526
const watcher = new TerminalResizeWatcher(stream as any, handler);
2627
stream.emit('resize');
28+
29+
// Not called immediately — debounced
30+
expect(handler).not.toHaveBeenCalled();
31+
32+
vi.advanceTimersByTime(100);
33+
expect(handler).toHaveBeenCalledTimes(1);
34+
35+
watcher.dispose();
36+
vi.useRealTimers();
37+
});
38+
39+
it('coalesces rapid resize events into a single callback', async () => {
40+
vi.useFakeTimers();
41+
const stream = new FakeStream();
42+
const handler = vi.fn();
43+
44+
const watcher = new TerminalResizeWatcher(stream as any, handler);
45+
46+
// Rapid-fire events (e.g. dragging window edge)
47+
stream.emit('resize');
48+
vi.advanceTimersByTime(20);
49+
stream.emit('resize');
50+
vi.advanceTimersByTime(20);
51+
stream.emit('resize');
52+
vi.advanceTimersByTime(20);
2753
stream.emit('resize');
2854

29-
expect(handler).toHaveBeenCalledTimes(2);
55+
// Still within debounce window — not called yet
56+
expect(handler).not.toHaveBeenCalled();
57+
58+
// After debounce settles
59+
vi.advanceTimersByTime(100);
60+
expect(handler).toHaveBeenCalledTimes(1);
61+
3062
watcher.dispose();
63+
vi.useRealTimers();
3164
});
3265

3366
it('stops reacting once disposed', () => {
67+
vi.useFakeTimers();
3468
const stream = new FakeStream();
3569
const handler = vi.fn();
3670

3771
const watcher = new TerminalResizeWatcher(stream as any, handler);
3872
watcher.dispose();
3973
stream.emit('resize');
74+
vi.advanceTimersByTime(200);
75+
76+
expect(handler).not.toHaveBeenCalled();
77+
vi.useRealTimers();
78+
});
79+
80+
it('cancels pending debounce timer on dispose', () => {
81+
vi.useFakeTimers();
82+
const stream = new FakeStream();
83+
const handler = vi.fn();
84+
85+
const watcher = new TerminalResizeWatcher(stream as any, handler);
86+
stream.emit('resize');
87+
88+
// Dispose before debounce fires
89+
watcher.dispose();
90+
vi.advanceTimersByTime(200);
4091

4192
expect(handler).not.toHaveBeenCalled();
93+
vi.useRealTimers();
4294
});
4395
});

0 commit comments

Comments
 (0)