Skip to content

Commit 21ad721

Browse files
authored
Merge branch 'main' into squad/new-customer-docs-5
2 parents 01093e0 + 153eabc commit 21ad721

14 files changed

Lines changed: 301 additions & 102 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### Fixed — CLI Terminal Rendering
8+
- Eliminated scroll-to-top flicker caused by Ink's fullscreen `clearTerminal` path firing on every render cycle
9+
- Reduced re-render churn via memoized elapsed-time display (one-second granularity gate) and consolidated animation intervals
10+
- Stabilized component keys (timestamp-based instead of shifting array indices) to prevent Ink remounts
11+
- Pinned live viewport height to keep input prompt above fold on all terminal sizes
12+
513
## [0.8.24] - 2026-03-08
614

715
### Added — Azure DevOps Platform Adapter

‎packages/squad-cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@
148148
"README.md"
149149
],
150150
"scripts": {
151-
"postinstall": "node scripts/patch-esm-imports.mjs",
151+
"postinstall": "node scripts/patch-esm-imports.mjs && node scripts/patch-ink-rendering.mjs",
152152
"prepublishOnly": "npm run build",
153153
"build": "tsc -p tsconfig.json && npm run postbuild",
154154
"postbuild": "node -e \"require('fs').cpSync('src/remote-ui', 'dist/remote-ui', {recursive: true})\""
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Ink Rendering Patcher for Squad CLI
5+
*
6+
* Patches ink/build/ink.js to fix scroll flicker on Windows Terminal.
7+
* Three patches are applied:
8+
*
9+
* 1. Remove trailing newline — the extra '\n' appended to output causes
10+
* logUpdate's previousLineCount to be off by one, pushing the bottom of
11+
* the UI below the viewport.
12+
*
13+
* 2. Disable clearTerminal fullscreen path — when output fills the terminal,
14+
* Ink clears the entire screen, causing violent scroll-to-top flicker.
15+
* We force the condition to `false` so logUpdate's incremental
16+
* erase-and-rewrite is always used instead.
17+
*
18+
* 3. Verify incrementalRendering passthrough — confirms that Ink forwards
19+
* the incrementalRendering option to logUpdate.create(). No code change
20+
* needed if already wired up.
21+
*
22+
* All patches are idempotent (safe to run multiple times).
23+
*/
24+
25+
import { readFileSync, writeFileSync, existsSync } from 'fs';
26+
import { join, dirname } from 'path';
27+
import { fileURLToPath } from 'url';
28+
29+
const __dirname = dirname(fileURLToPath(import.meta.url));
30+
31+
function patchInkRendering() {
32+
// Try multiple possible locations (npm workspaces can hoist dependencies)
33+
const possiblePaths = [
34+
// squad-cli package node_modules
35+
join(__dirname, '..', 'node_modules', 'ink', 'build', 'ink.js'),
36+
// Workspace root node_modules (common with npm workspaces)
37+
join(__dirname, '..', '..', '..', 'node_modules', 'ink', 'build', 'ink.js'),
38+
// Global install location (node_modules at parent of package)
39+
join(__dirname, '..', '..', 'ink', 'build', 'ink.js'),
40+
];
41+
42+
const inkJsPath = possiblePaths.find(p => existsSync(p)) ?? null;
43+
44+
if (!inkJsPath) {
45+
// ink not installed yet — exit silently
46+
return false;
47+
}
48+
49+
try {
50+
let content = readFileSync(inkJsPath, 'utf8');
51+
let patchCount = 0;
52+
53+
// --- Patch 1: Remove trailing newline ---
54+
// Original: const outputToRender = output + '\n';
55+
// Patched: const outputToRender = output;
56+
const trailingNewlineSearch = "const outputToRender = output + '\\n';";
57+
const trailingNewlineReplace = 'const outputToRender = output;';
58+
if (content.includes(trailingNewlineSearch)) {
59+
content = content.replace(trailingNewlineSearch, trailingNewlineReplace);
60+
console.log(' ✅ Patch 1/3: Removed trailing newline from outputToRender');
61+
patchCount++;
62+
} else if (content.includes(trailingNewlineReplace)) {
63+
console.log(' ⏭️ Patch 1/3: Trailing newline already removed');
64+
} else {
65+
console.warn(' ⚠️ Patch 1/3: Could not find outputToRender pattern — Ink version may have changed');
66+
}
67+
68+
// --- Patch 2: Disable clearTerminal fullscreen path ---
69+
// Original: if (isFullscreen) {
70+
// const sync = shouldSynchronize(this.options.stdout);
71+
// ...
72+
// this.options.stdout.write(ansiEscapes.clearTerminal + ...
73+
// Patched: if (false) {
74+
//
75+
// We match `if (isFullscreen) {` only when followed by the clearTerminal
76+
// usage to avoid replacing unrelated isFullscreen references.
77+
const fullscreenSearch = /if \(isFullscreen\) \{\s*\n\s*const sync = shouldSynchronize/;
78+
const fullscreenAlreadyPatched = /if \(false\) \{\s*\n\s*const sync = shouldSynchronize/;
79+
if (fullscreenSearch.test(content)) {
80+
content = content.replace(
81+
/if \(isFullscreen\) (\{\s*\n\s*const sync = shouldSynchronize)/,
82+
'if (false) $1'
83+
);
84+
console.log(' ✅ Patch 2/3: Disabled clearTerminal fullscreen path');
85+
patchCount++;
86+
} else if (fullscreenAlreadyPatched.test(content)) {
87+
console.log(' ⏭️ Patch 2/3: clearTerminal path already disabled');
88+
} else {
89+
console.warn(' ⚠️ Patch 2/3: Could not find isFullscreen pattern — Ink version may have changed');
90+
}
91+
92+
// --- Patch 3: Verify incrementalRendering passthrough ---
93+
const incrementalPattern = 'incremental: options.incrementalRendering';
94+
if (content.includes(incrementalPattern)) {
95+
console.log(' ✅ Patch 3/3: incrementalRendering passthrough verified (no change needed)');
96+
} else {
97+
console.warn(' ⚠️ Patch 3/3: incrementalRendering passthrough not found — Ink version may have changed');
98+
}
99+
100+
if (patchCount > 0) {
101+
writeFileSync(inkJsPath, content, 'utf8');
102+
console.log(`✅ Patched ink.js with ${patchCount} rendering fix(es) for scroll flicker`);
103+
return true;
104+
}
105+
106+
return false;
107+
} catch (err) {
108+
console.warn('⚠️ Failed to patch ink.js rendering:', err.message);
109+
console.warn(' Scroll flicker may occur on Windows Terminal.');
110+
return false;
111+
}
112+
}
113+
114+
// Run patch
115+
patchInkRendering();

‎packages/squad-cli/src/cli/shell/components/AgentPanel.tsx‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from 'react';
1+
import React, { useState, useEffect, useRef } from 'react';
22
import { Box, Text } from 'ink';
33
import { getRoleEmoji } from '../lifecycle.js';
44
import { isNoColor, useLayoutTier } from '../terminal.js';
@@ -21,10 +21,10 @@ const PulsingDot: React.FC = () => {
2121

2222
useEffect(() => {
2323
if (noColor) return;
24-
// 500ms interval reduces re-renders vs 300ms (#206)
24+
// 800ms interval reduces re-renders vs 500ms (fix-cli-scroll-rerender-storm)
2525
const timer = setInterval(() => {
2626
setFrame(f => (f + 1) % PULSE_FRAMES.length);
27-
}, 500);
27+
}, 800);
2828
return () => clearInterval(timer);
2929
}, [noColor]);
3030

@@ -49,12 +49,27 @@ export const AgentPanel: React.FC<AgentPanelProps> = ({ agents, streamingContent
4949
const noColor = isNoColor();
5050
const tier = useLayoutTier();
5151

52-
// Tick every second to update elapsed times
53-
const [, setTick] = useState(0);
52+
// Re-render gate: store elapsed strings in a ref so the timer only triggers
53+
// a React re-render (via the tick counter) when a visible value changes.
54+
const elapsedRef = useRef(new Map<string, string>());
55+
const [, setElapsedTick] = useState(0);
56+
5457
useEffect(() => {
5558
const hasActive = agents.some(a => a.status === 'working' || a.status === 'streaming');
5659
if (!hasActive) return;
57-
const timer = setInterval(() => setTick(t => t + 1), 1000);
60+
const timer = setInterval(() => {
61+
let changed = false;
62+
for (const a of agents) {
63+
if (a.status === 'working' || a.status === 'streaming') {
64+
const display = formatElapsed(agentElapsedSec(a));
65+
if (elapsedRef.current.get(a.name) !== display) {
66+
elapsedRef.current.set(a.name, display);
67+
changed = true;
68+
}
69+
}
70+
}
71+
if (changed) setElapsedTick(t => t + 1);
72+
}, 1000);
5873
return () => clearInterval(timer);
5974
}, [agents]);
6075

‎packages/squad-cli/src/cli/shell/components/App.tsx‎

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
2-
import { Box, Text, Static, useApp, useInput } from 'ink';
2+
import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
33
import { AgentPanel } from './AgentPanel.js';
44
import { MessageStream, renderMarkdownInline, formatDuration } from './MessageStream.js';
55
import { InputPrompt } from './InputPrompt.js';
@@ -282,12 +282,16 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
282282
const width = useTerminalWidth();
283283
const tier = useLayoutTier();
284284
const terminalHeight = useTerminalHeight();
285-
const contentWidth = tier === 'wide' ? Math.min(width, 120) : tier === 'normal' ? Math.min(width, 80) : width;
286-
287-
// Budget live region height so InputPrompt is never pushed off-screen.
288-
// Reserve 3 rows for InputPrompt (prompt line + hint + padding).
289-
const INPUT_RESERVED_ROWS = 3;
290-
const liveContentHeight = Math.max(terminalHeight - INPUT_RESERVED_ROWS, 4);
285+
// Cap contentWidth at Ink's stdout columns to prevent text overflow/clipping.
286+
// In tests, Ink renders at 100 columns while process.stdout.columns may differ.
287+
const { stdout: inkStdout } = useStdout();
288+
const renderWidth = inkStdout && 'columns' in inkStdout
289+
? (inkStdout as { columns?: number }).columns ?? width
290+
: width;
291+
const contentWidth = Math.min(
292+
tier === 'wide' ? Math.min(width, 120) : tier === 'normal' ? Math.min(width, 80) : width,
293+
renderWidth,
294+
);
291295

292296
// Prefer lead/coordinator for first-run hint, fall back to first agent
293297
const leadAgent = welcome?.agents.find(a =>
@@ -299,8 +303,7 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
299303
// Determine ThinkingIndicator phase based on SDK connection state
300304
const thinkingPhase: ThinkingPhase = !onDispatch ? 'connecting' : 'routing';
301305

302-
// Derive @mention hint from last user message (needed because MessageStream
303-
// receives messages=[] after the Static scrollback refactor).
306+
// Derive @mention hint from last user message.
304307
const mentionHint = useMemo(() => {
305308
if (!processing) return undefined;
306309
const lastUser = [...messages].reverse().find(m => m.role === 'user');
@@ -311,13 +314,12 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
311314
return undefined;
312315
}, [messages, processing]);
313316

314-
// Combine archived + current messages for Static rendering.
315-
// This array only grows — archival moves items between the two source arrays
316-
// but the combined list stays stable, which is required by Ink's Static tracking.
317-
const staticMessages = useMemo(
318-
() => [...archivedMessages, ...messages],
319-
[archivedMessages, messages],
320-
);
317+
// Only archived (overflow) messages go to Static scrollback.
318+
// Current messages stay in the live region so the user can always see
319+
// the recent conversation without scrolling. This prevents the
320+
// "conversation vanishes" problem where every re-render forced the
321+
// viewport to the bottom, hiding Static scrollback content.
322+
const staticMessages = archivedMessages;
321323
const roleMap = useMemo(() => new Map((agents ?? []).map(a => [a.name, a.role])), [agents]);
322324

323325
// Memoize the header box — rendered once into Static scroll buffer at the top.
@@ -381,13 +383,28 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
381383
const allStaticItems = useMemo((): StaticItem[] => {
382384
const items: StaticItem[] = [{ kind: 'header', key: 'welcome-header' }];
383385
for (let i = 0; i < staticMessages.length; i++) {
384-
items.push({ kind: 'msg', key: `${sessionId}-${i}`, msg: staticMessages[i]!, idx: i });
386+
// Use timestamp + index-at-creation for stable keys that don't shift
387+
// when new messages are added (array only grows via append)
388+
const msg = staticMessages[i]!;
389+
const stableKey = `${sessionId}-${msg.timestamp.getTime()}-${i}`;
390+
items.push({ kind: 'msg', key: stableKey, msg, idx: i });
385391
}
386392
return items;
387393
}, [staticMessages, sessionId]);
388394

395+
// Fill the entire viewport. Ink's fullscreen clearTerminal path and
396+
// trailing-newline behavior have been patched out of ink.js, so we can
397+
// safely use the full terminal height without triggering scroll-to-top.
398+
// logUpdate tracks exactly rootHeight lines and erases/rewrites them
399+
// on each render cycle without cursor drift.
400+
const rootHeight = Math.max(terminalHeight, 8);
401+
402+
// Derive maxVisible from terminal height so taller terminals show more
403+
// conversation context. Reserve ~8 rows for header/input/agent-panel chrome.
404+
const maxVisible = Math.max(Math.floor((terminalHeight - 8) / 3), 3);
405+
389406
return (
390-
<Box flexDirection="column">
407+
<Box flexDirection="column" height={rootHeight}>
391408
{/* Static block: header first (stays at top of scroll buffer), then messages */}
392409
<Static items={allStaticItems}>
393410
{(item) => {
@@ -443,16 +460,20 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
443460
}}
444461
</Static>
445462

446-
{/* Live region: bounded height only while processing so InputPrompt stays in viewport;
447-
auto-sized when idle to avoid blank space below the agent panel. */}
448-
<Box flexDirection="column" {...(processing ? { height: liveContentHeight, overflow: 'hidden' as const } : {})}>
463+
{/* Live region: always height-constrained to prevent layout shift flicker
464+
when processing state toggles. InputPrompt stays pinned at bottom.
465+
Messages are kept here (not in Static) so the user can always see the
466+
recent conversation without scrolling. maxVisible caps the message
467+
count to prevent overflow into the InputPrompt area. */}
468+
<Box flexDirection="column" flexGrow={1}>
449469
<AgentPanel agents={agents} streamingContent={streamingContent} />
450-
<MessageStream messages={[]} agents={agents} streamingContent={streamingContent} processing={processing} activityHint={activityHint || mentionHint} agentActivities={agentActivities} thinkingPhase={thinkingPhase} />
470+
<MessageStream messages={messages} agents={agents} streamingContent={streamingContent} processing={processing} activityHint={activityHint || mentionHint} agentActivities={agentActivities} thinkingPhase={thinkingPhase} maxVisible={maxVisible} />
451471
</Box>
452472
{/* Fixed input box at bottom — Copilot/Claude style */}
453473
<Box marginTop={1} borderStyle={noColor ? undefined : 'round'} borderColor={noColor ? undefined : 'cyan'} paddingX={1}>
454474
<InputPrompt onSubmit={handleSubmit} disabled={processing} agentNames={agents.map(a => a.name)} messageCount={messages.length} />
455475
</Box>
476+
{/* version is shown in the Static header — no footer duplicate needed */}
456477
</Box>
457478
);
458479
};

‎packages/squad-cli/src/cli/shell/components/InputPrompt.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
8282
if (!disabled || noColor) return;
8383
const timer = setInterval(() => {
8484
setSpinFrame(f => (f + 1) % SPINNER_FRAMES.length);
85-
}, 80);
85+
}, 150);
8686
return () => clearInterval(timer);
8787
}, [disabled, noColor]);
8888

0 commit comments

Comments
 (0)