Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

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

## [Unreleased]

### Fixed — CLI Terminal Rendering
- Eliminated scroll-to-top flicker caused by Ink's fullscreen `clearTerminal` path firing on every render cycle
- Reduced re-render churn via memoized elapsed-time display (one-second granularity gate) and consolidated animation intervals
- Stabilized component keys (timestamp-based instead of shifting array indices) to prevent Ink remounts
- Pinned live viewport height to keep input prompt above fold on all terminal sizes

## [0.8.24] - 2026-03-08

### Added — Azure DevOps Platform Adapter
Expand Down
2 changes: 1 addition & 1 deletion packages/squad-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
"README.md"
],
"scripts": {
"postinstall": "node scripts/patch-esm-imports.mjs",
"postinstall": "node scripts/patch-esm-imports.mjs && node scripts/patch-ink-rendering.mjs",
"prepublishOnly": "npm run build",
"build": "tsc -p tsconfig.json && npm run postbuild",
"postbuild": "node -e \"require('fs').cpSync('src/remote-ui', 'dist/remote-ui', {recursive: true})\""
Expand Down
115 changes: 115 additions & 0 deletions packages/squad-cli/scripts/patch-ink-rendering.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env node

/**
* Ink Rendering Patcher for Squad CLI
*
* Patches ink/build/ink.js to fix scroll flicker on Windows Terminal.
* Three patches are applied:
*
* 1. Remove trailing newline — the extra '\n' appended to output causes
* logUpdate's previousLineCount to be off by one, pushing the bottom of
* the UI below the viewport.
*
* 2. Disable clearTerminal fullscreen path — when output fills the terminal,
* Ink clears the entire screen, causing violent scroll-to-top flicker.
* We force the condition to `false` so logUpdate's incremental
* erase-and-rewrite is always used instead.
*
* 3. Verify incrementalRendering passthrough — confirms that Ink forwards
* the incrementalRendering option to logUpdate.create(). No code change
* needed if already wired up.
*
* All patches are idempotent (safe to run multiple times).
*/

import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

function patchInkRendering() {
// Try multiple possible locations (npm workspaces can hoist dependencies)
const possiblePaths = [
// squad-cli package node_modules
join(__dirname, '..', 'node_modules', 'ink', 'build', 'ink.js'),
// Workspace root node_modules (common with npm workspaces)
join(__dirname, '..', '..', '..', 'node_modules', 'ink', 'build', 'ink.js'),
// Global install location (node_modules at parent of package)
join(__dirname, '..', '..', 'ink', 'build', 'ink.js'),
];

const inkJsPath = possiblePaths.find(p => existsSync(p)) ?? null;

if (!inkJsPath) {
// ink not installed yet — exit silently
return false;
}

try {
let content = readFileSync(inkJsPath, 'utf8');
let patchCount = 0;

// --- Patch 1: Remove trailing newline ---
// Original: const outputToRender = output + '\n';
// Patched: const outputToRender = output;
const trailingNewlineSearch = "const outputToRender = output + '\\n';";
const trailingNewlineReplace = 'const outputToRender = output;';
if (content.includes(trailingNewlineSearch)) {
content = content.replace(trailingNewlineSearch, trailingNewlineReplace);
console.log(' ✅ Patch 1/3: Removed trailing newline from outputToRender');
patchCount++;
} else if (content.includes(trailingNewlineReplace)) {
console.log(' ⏭️ Patch 1/3: Trailing newline already removed');
} else {
console.warn(' ⚠️ Patch 1/3: Could not find outputToRender pattern — Ink version may have changed');
}

// --- Patch 2: Disable clearTerminal fullscreen path ---
// Original: if (isFullscreen) {
// const sync = shouldSynchronize(this.options.stdout);
// ...
// this.options.stdout.write(ansiEscapes.clearTerminal + ...
// Patched: if (false) {
//
// We match `if (isFullscreen) {` only when followed by the clearTerminal
// usage to avoid replacing unrelated isFullscreen references.
const fullscreenSearch = /if \(isFullscreen\) \{\s*\n\s*const sync = shouldSynchronize/;
const fullscreenAlreadyPatched = /if \(false\) \{\s*\n\s*const sync = shouldSynchronize/;
if (fullscreenSearch.test(content)) {
content = content.replace(
/if \(isFullscreen\) (\{\s*\n\s*const sync = shouldSynchronize)/,
'if (false) $1'
);
console.log(' ✅ Patch 2/3: Disabled clearTerminal fullscreen path');
patchCount++;
} else if (fullscreenAlreadyPatched.test(content)) {
console.log(' ⏭️ Patch 2/3: clearTerminal path already disabled');
} else {
console.warn(' ⚠️ Patch 2/3: Could not find isFullscreen pattern — Ink version may have changed');
}

// --- Patch 3: Verify incrementalRendering passthrough ---
const incrementalPattern = 'incremental: options.incrementalRendering';
if (content.includes(incrementalPattern)) {
console.log(' ✅ Patch 3/3: incrementalRendering passthrough verified (no change needed)');
} else {
console.warn(' ⚠️ Patch 3/3: incrementalRendering passthrough not found — Ink version may have changed');
}

if (patchCount > 0) {
writeFileSync(inkJsPath, content, 'utf8');
console.log(`✅ Patched ink.js with ${patchCount} rendering fix(es) for scroll flicker`);
return true;
}

return false;
} catch (err) {
console.warn('⚠️ Failed to patch ink.js rendering:', err.message);
console.warn(' Scroll flicker may occur on Windows Terminal.');
return false;
}
}

// Run patch
patchInkRendering();
27 changes: 21 additions & 6 deletions packages/squad-cli/src/cli/shell/components/AgentPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Box, Text } from 'ink';
import { getRoleEmoji } from '../lifecycle.js';
import { isNoColor, useLayoutTier } from '../terminal.js';
Expand All @@ -21,10 +21,10 @@ const PulsingDot: React.FC = () => {

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

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

// Tick every second to update elapsed times
const [, setTick] = useState(0);
// Re-render gate: store elapsed strings in a ref so the timer only triggers
// a React re-render (via the tick counter) when a visible value changes.
const elapsedRef = useRef(new Map<string, string>());
const [, setElapsedTick] = useState(0);

useEffect(() => {
const hasActive = agents.some(a => a.status === 'working' || a.status === 'streaming');
if (!hasActive) return;
const timer = setInterval(() => setTick(t => t + 1), 1000);
const timer = setInterval(() => {
let changed = false;
for (const a of agents) {
if (a.status === 'working' || a.status === 'streaming') {
const display = formatElapsed(agentElapsedSec(a));
if (elapsedRef.current.get(a.name) !== display) {
elapsedRef.current.set(a.name, display);
changed = true;
}
}
}
if (changed) setElapsedTick(t => t + 1);
}, 1000);
return () => clearInterval(timer);
}, [agents]);
Comment thread
jongio marked this conversation as resolved.

Expand Down
65 changes: 43 additions & 22 deletions packages/squad-cli/src/cli/shell/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { Box, Text, Static, useApp, useInput } from 'ink';
import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
import { AgentPanel } from './AgentPanel.js';
import { MessageStream, renderMarkdownInline, formatDuration } from './MessageStream.js';
import { InputPrompt } from './InputPrompt.js';
Expand Down Expand Up @@ -282,12 +282,16 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
const width = useTerminalWidth();
const tier = useLayoutTier();
const terminalHeight = useTerminalHeight();
const contentWidth = tier === 'wide' ? Math.min(width, 120) : tier === 'normal' ? Math.min(width, 80) : width;

// Budget live region height so InputPrompt is never pushed off-screen.
// Reserve 3 rows for InputPrompt (prompt line + hint + padding).
const INPUT_RESERVED_ROWS = 3;
const liveContentHeight = Math.max(terminalHeight - INPUT_RESERVED_ROWS, 4);
// Cap contentWidth at Ink's stdout columns to prevent text overflow/clipping.
// In tests, Ink renders at 100 columns while process.stdout.columns may differ.
const { stdout: inkStdout } = useStdout();
const renderWidth = inkStdout && 'columns' in inkStdout
? (inkStdout as { columns?: number }).columns ?? width
: width;
const contentWidth = Math.min(
tier === 'wide' ? Math.min(width, 120) : tier === 'normal' ? Math.min(width, 80) : width,
renderWidth,
);

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

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

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

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

// Fill the entire viewport. Ink's fullscreen clearTerminal path and
// trailing-newline behavior have been patched out of ink.js, so we can
// safely use the full terminal height without triggering scroll-to-top.
// logUpdate tracks exactly rootHeight lines and erases/rewrites them
// on each render cycle without cursor drift.
const rootHeight = Math.max(terminalHeight, 8);

// Derive maxVisible from terminal height so taller terminals show more
// conversation context. Reserve ~8 rows for header/input/agent-panel chrome.
const maxVisible = Math.max(Math.floor((terminalHeight - 8) / 3), 3);

return (
<Box flexDirection="column">
<Box flexDirection="column" height={rootHeight}>
{/* Static block: header first (stays at top of scroll buffer), then messages */}
<Static items={allStaticItems}>
{(item) => {
Expand Down Expand Up @@ -443,16 +460,20 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
}}
</Static>

{/* Live region: bounded height only while processing so InputPrompt stays in viewport;
auto-sized when idle to avoid blank space below the agent panel. */}
<Box flexDirection="column" {...(processing ? { height: liveContentHeight, overflow: 'hidden' as const } : {})}>
{/* Live region: always height-constrained to prevent layout shift flicker
when processing state toggles. InputPrompt stays pinned at bottom.
Messages are kept here (not in Static) so the user can always see the
recent conversation without scrolling. maxVisible caps the message
count to prevent overflow into the InputPrompt area. */}
<Box flexDirection="column" flexGrow={1}>
<AgentPanel agents={agents} streamingContent={streamingContent} />
<MessageStream messages={[]} agents={agents} streamingContent={streamingContent} processing={processing} activityHint={activityHint || mentionHint} agentActivities={agentActivities} thinkingPhase={thinkingPhase} />
<MessageStream messages={messages} agents={agents} streamingContent={streamingContent} processing={processing} activityHint={activityHint || mentionHint} agentActivities={agentActivities} thinkingPhase={thinkingPhase} maxVisible={maxVisible} />
</Box>
{/* Fixed input box at bottom — Copilot/Claude style */}
<Box marginTop={1} borderStyle={noColor ? undefined : 'round'} borderColor={noColor ? undefined : 'cyan'} paddingX={1}>
<InputPrompt onSubmit={handleSubmit} disabled={processing} agentNames={agents.map(a => a.name)} messageCount={messages.length} />
</Box>
{/* version is shown in the Static header — no footer duplicate needed */}
</Box>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (!disabled || noColor) return;
const timer = setInterval(() => {
setSpinFrame(f => (f + 1) % SPINNER_FRAMES.length);
}, 80);
}, 150);
return () => clearInterval(timer);
}, [disabled, noColor]);

Expand Down
Loading