diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d9e54fbe..06fbf739c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index 752984d19..3b98ffb33 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -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})\"" diff --git a/packages/squad-cli/scripts/patch-ink-rendering.mjs b/packages/squad-cli/scripts/patch-ink-rendering.mjs new file mode 100644 index 000000000..a545a03e6 --- /dev/null +++ b/packages/squad-cli/scripts/patch-ink-rendering.mjs @@ -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(); diff --git a/packages/squad-cli/src/cli/shell/components/AgentPanel.tsx b/packages/squad-cli/src/cli/shell/components/AgentPanel.tsx index 61a710541..8681b9019 100644 --- a/packages/squad-cli/src/cli/shell/components/AgentPanel.tsx +++ b/packages/squad-cli/src/cli/shell/components/AgentPanel.tsx @@ -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'; @@ -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]); @@ -49,12 +49,27 @@ export const AgentPanel: React.FC = ({ 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()); + 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]); diff --git a/packages/squad-cli/src/cli/shell/components/App.tsx b/packages/squad-cli/src/cli/shell/components/App.tsx index 69bb2c42e..ab252a507 100644 --- a/packages/squad-cli/src/cli/shell/components/App.tsx +++ b/packages/squad-cli/src/cli/shell/components/App.tsx @@ -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'; @@ -282,12 +282,16 @@ export const App: React.FC = ({ 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 => @@ -299,8 +303,7 @@ export const App: React.FC = ({ 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'); @@ -311,13 +314,12 @@ export const App: React.FC = ({ 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. @@ -381,13 +383,28 @@ export const App: React.FC = ({ 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 ( - + {/* Static block: header first (stays at top of scroll buffer), then messages */} {(item) => { @@ -443,16 +460,20 @@ export const App: React.FC = ({ registry, renderer, teamRoot, version, }} - {/* Live region: bounded height only while processing so InputPrompt stays in viewport; - auto-sized when idle to avoid blank space below the agent panel. */} - + {/* 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. */} + - + {/* Fixed input box at bottom — Copilot/Claude style */} a.name)} messageCount={messages.length} /> + {/* version is shown in the Static header — no footer duplicate needed */} ); }; diff --git a/packages/squad-cli/src/cli/shell/components/InputPrompt.tsx b/packages/squad-cli/src/cli/shell/components/InputPrompt.tsx index 7b0e9b1af..7c926ba22 100644 --- a/packages/squad-cli/src/cli/shell/components/InputPrompt.tsx +++ b/packages/squad-cli/src/cli/shell/components/InputPrompt.tsx @@ -82,7 +82,7 @@ export const InputPrompt: React.FC = ({ if (!disabled || noColor) return; const timer = setInterval(() => { setSpinFrame(f => (f + 1) % SPINNER_FRAMES.length); - }, 80); + }, 150); return () => clearInterval(timer); }, [disabled, noColor]); diff --git a/packages/squad-cli/src/cli/shell/components/MessageStream.tsx b/packages/squad-cli/src/cli/shell/components/MessageStream.tsx index 94aae89bc..0ee0731f7 100644 --- a/packages/squad-cli/src/cli/shell/components/MessageStream.tsx +++ b/packages/squad-cli/src/cli/shell/components/MessageStream.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Box, Text } from 'ink'; import { getRoleEmoji } from '../lifecycle.js'; import { isNoColor, useTerminalWidth, useLayoutTier, type LayoutTier } from '../terminal.js'; @@ -201,39 +201,41 @@ export const MessageStream: React.FC = ({ maxVisible = 50, }) => { const visible = messages.slice(-maxVisible); - const roleMap = new Map((agents ?? []).map(a => [a.name, a.role])); + const visibleOffset = Math.max(0, messages.length - maxVisible); + const roleMap = useMemo(() => new Map((agents ?? []).map(a => [a.name, a.role])), [agents]); // Message fade-in: new messages start dim for 200ms const fadingCount = useMessageFade(messages.length); - // Elapsed time tracking for the ThinkingIndicator + // Elapsed time tracking for the ThinkingIndicator. + // Only update state when the rounded seconds value changes to avoid + // unnecessary re-renders that cause terminal scroll flicker. const [elapsedMs, setElapsedMs] = useState(0); const processingStartRef = useRef(Date.now()); + const lastElapsedSecRef = useRef(0); useEffect(() => { if (processing) { processingStartRef.current = Date.now(); + lastElapsedSecRef.current = 0; setElapsedMs(0); - // Update once per second — reduces re-renders that cause flicker (#206) const timer = setInterval(() => { - setElapsedMs(Date.now() - processingStartRef.current); + const now = Date.now() - processingStartRef.current; + const sec = Math.floor(now / 1000); + if (sec !== lastElapsedSecRef.current) { + lastElapsedSecRef.current = sec; + setElapsedMs(now); + } }, 1000); return () => clearInterval(timer); } else { setElapsedMs(0); + lastElapsedSecRef.current = 0; } }, [processing]); - // Build activity hint: prefer explicit hint, then infer from agent @mention - const resolvedHint = (() => { - if (activityHint) return activityHint; - const lastUser = [...messages].reverse().find(m => m.role === 'user'); - if (lastUser) { - const atMatch = lastUser.content.match(/^@(\w+)/); - if (atMatch?.[1]) return `${atMatch[1]} is thinking...`; - } - return undefined; - })(); + // Activity hint comes from the parent (App.tsx derives @mention hints + // via `mentionHint` and passes them through `activityHint`). // Compute response duration: time from previous user message to this agent message const getResponseDuration = (index: number): string | null => { @@ -263,26 +265,28 @@ export const MessageStream: React.FC = ({ const isFading = fadingCount > 0 && i >= visible.length - fadingCount; return ( - + {isNewTurn && } - - {msg.role === 'user' ? ( - <> - - {msg.content} - - ) : msg.role === 'system' ? ( - <> - {msg.content} - - ) : ( - <> - {emoji ? `${emoji} ` : ''}{(msg.agentName === 'coordinator' ? 'Squad' : msg.agentName) ?? 'agent'}: - {renderMarkdownInline(wrapTableContent(msg.content, contentWidth, tier))} - {duration && ({duration})} - - )} - + {msg.role === 'system' ? ( + + {msg.content} + + ) : ( + + {msg.role === 'user' ? ( + <> + + {msg.content} + + ) : ( + <> + {emoji ? `${emoji} ` : ''}{(msg.agentName === 'coordinator' ? 'Squad' : msg.agentName) ?? 'agent'}: + {renderMarkdownInline(wrapTableContent(msg.content, contentWidth, tier))} + {duration && ({duration})} + + )} + + )} ); })} @@ -321,7 +325,7 @@ export const MessageStream: React.FC = ({ )} diff --git a/packages/squad-cli/src/cli/shell/index.ts b/packages/squad-cli/src/cli/shell/index.ts index d17f9e397..b9960c417 100644 --- a/packages/squad-cli/src/cli/shell/index.ts +++ b/packages/squad-cli/src/cli/shell/index.ts @@ -1139,6 +1139,7 @@ export async function runShell(): Promise { // Clear terminal and scrollback — prevents old scaffold output from // bleeding through above the header box in extended sessions. + // Also ensures we start from a clean viewport before Ink renders. process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); const { waitUntilExit } = render( @@ -1205,6 +1206,11 @@ export async function runShell(): Promise { onRestoreSession, }), ), + // NOTE: Both incrementalRendering AND Ink's trailing-newline have been + // patched via scripts/patch-ink-rendering.mjs (runs on postinstall). + // This means: (a) logUpdate uses standard erase-and-rewrite, (b) no + // trailing '\n' is appended to output, (c) no clearTerminal scroll-to-top. + // patchConsole: false ensures console.log doesn't corrupt Ink's rendering. { exitOnCtrlC: false, patchConsole: false }, ); diff --git a/packages/squad-cli/src/cli/shell/terminal.ts b/packages/squad-cli/src/cli/shell/terminal.ts index 6bed88479..0d40042ab 100644 --- a/packages/squad-cli/src/cli/shell/terminal.ts +++ b/packages/squad-cli/src/cli/shell/terminal.ts @@ -18,49 +18,50 @@ export function getTerminalWidth(): number { return Math.max(process.stdout.columns || 80, 40); } -/** React hook — returns live terminal width, updates on resize. */ -export function useTerminalWidth(): number { - const [width, setWidth] = useState(getTerminalWidth()); - - useEffect(() => { - const onResize = () => setWidth(getTerminalWidth()); - // Avoid MaxListenersExceededWarning in test environments with many renders - const prev = process.stdout.getMaxListeners?.() ?? 10; - if (prev <= 20) process.stdout.setMaxListeners?.(prev + 10); - process.stdout.on('resize', onResize); - return () => { - process.stdout.off('resize', onResize); - }; - }, []); - - return width; -} +/** + * Default row count used when `process.stdout.rows` is undefined + * (e.g. piped output, test harnesses). 50 rows ensures the live + * viewport has enough room for content like /help. + */ +const DEFAULT_TERMINAL_ROWS = 50; -/** Current terminal height, clamped to a minimum of 10. */ +/** Current terminal height, clamped to a minimum of 10. + * Fallback of DEFAULT_TERMINAL_ROWS when rows is undefined (test/pipe environments) + * ensures the live viewport has enough room for content like /help. */ export function getTerminalHeight(): number { - return Math.max(process.stdout.rows || 24, 10); + return Math.max(process.stdout.rows || DEFAULT_TERMINAL_ROWS, 10); } -/** React hook — returns live terminal height, updates on resize. */ -export function useTerminalHeight(): number { - const [height, setHeight] = useState(getTerminalHeight()); - +/** + * Shared hook that subscribes to `process.stdout` resize events and + * returns the current value of `getter()`, debounced at 150 ms. + * Extracted from the formerly-duplicated useTerminalWidth / useTerminalHeight hooks. + */ +function useTerminalDimension(getter: () => number): number { + const [value, setValue] = useState(getter()); useEffect(() => { - const onResize = () => setHeight(getTerminalHeight()); + let timer: ReturnType | null = null; + const onResize = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => setValue(getter()), 150); + }; const prev = process.stdout.getMaxListeners?.() ?? 10; if (prev <= 20) process.stdout.setMaxListeners?.(prev + 10); process.stdout.on('resize', onResize); return () => { process.stdout.off('resize', onResize); + if (timer) clearTimeout(timer); }; }, []); - - return height; + return value; } -/** - * Detect terminal capabilities for cross-platform compatibility. - */ +/** React hook — returns live terminal width, updates on resize. */ +export function useTerminalWidth(): number { return useTerminalDimension(getTerminalWidth); } + +/** React hook — returns live terminal height, updates on resize. */ +export function useTerminalHeight(): number { return useTerminalDimension(getTerminalHeight); } + /** * Returns true when the environment requests no color output. * Respects the NO_COLOR standard (https://no-color.org/) and TERM=dumb. @@ -72,6 +73,7 @@ export function isNoColor(): boolean { ); } +/** Detect terminal capabilities for cross-platform compatibility. */ export function detectTerminal(): TerminalCapabilities { const plat = platform(); const isTTY = Boolean(process.stdout.isTTY); @@ -81,6 +83,10 @@ export function detectTerminal(): TerminalCapabilities { supportsColor: !noColor && isTTY && (process.env['FORCE_COLOR'] !== '0'), supportsUnicode: plat !== 'win32' || Boolean(process.env['WT_SESSION']), columns: process.stdout.columns || 80, + // detectTerminal uses 24 (standard VT100 default) rather than + // DEFAULT_TERMINAL_ROWS because this is a capability snapshot — not + // a live viewport sizing decision — and 24 is the safer assumption + // when advertising rows to callers that need a conservative baseline. rows: process.stdout.rows || 24, platform: plat, isWindows: plat === 'win32', diff --git a/scripts/bump-build.mjs b/scripts/bump-build.mjs index 63a1cfb4c..ac0a975ec 100644 --- a/scripts/bump-build.mjs +++ b/scripts/bump-build.mjs @@ -6,7 +6,7 @@ * e.g. 0.8.6-preview.1 → 0.8.6-preview.2 * * If no build number exists (e.g. 0.8.6-preview), starts at 1. - * Non-prerelease versions use: major.minor.patch.build + * Non-prerelease versions use: major.minor.patch-build.N (valid semver) * Updates all 3 package.json files (root + both workspaces) in lockstep. * * Skip this script by setting SKIP_BUILD_BUMP=1 (used in CI/CD publish). @@ -32,6 +32,7 @@ const PACKAGE_PATHS = [ ]; // Parse version: "major.minor.patch-prerelease.build" or "major.minor.patch.build" +// Non-prerelease bumps now produce "major.minor.patch-build.N" (valid semver) function parseVersion(version) { // Try prerelease format: "1.2.3-tag" or "1.2.3-tag.N" let match = version.match(/^(\d+\.\d+\.\d+)(-[a-zA-Z][a-zA-Z0-9-]*)(?:\.(\d+))?$/); @@ -58,7 +59,8 @@ function formatVersion({ base, build, prerelease }) { if (prerelease) { return `${base}${prerelease}.${build}`; } - return `${base}.${build}`; + // Use prerelease tag for valid semver (npm rejects 4-part versions like 0.8.25.4) + return `${base}-build.${build}`; } // Read the canonical version from root package.json diff --git a/test/bump-build.test.ts b/test/bump-build.test.ts index f1b282adb..70ca0698a 100644 --- a/test/bump-build.test.ts +++ b/test/bump-build.test.ts @@ -69,7 +69,17 @@ describe('bump-build.mjs', () => { workspace = makeTempWorkspace('1.0.0.3'); execSync(`node ${join(workspace.dir, 'scripts', 'bump-build.mjs')}`, execOpts); for (const p of workspace.paths) { - expect(readVersion(p)).toBe('1.0.0.4'); + // Old 4-part format (1.0.0.3) is parsed as base=1.0.0, build=3 + // New format uses valid semver prerelease tag + expect(readVersion(p)).toBe('1.0.0-build.4'); + } + }); + + it('bumps clean release version to semver prerelease format', () => { + workspace = makeTempWorkspace('1.0.0'); + execSync(`node ${join(workspace.dir, 'scripts', 'bump-build.mjs')}`, execOpts); + for (const p of workspace.paths) { + expect(readVersion(p)).toBe('1.0.0-build.1'); } }); diff --git a/test/cli-packaging-smoke.test.ts b/test/cli-packaging-smoke.test.ts index 2ffaa6059..3b7ba78c4 100644 --- a/test/cli-packaging-smoke.test.ts +++ b/test/cli-packaging-smoke.test.ts @@ -31,14 +31,18 @@ describe('CLI packaging smoke test', { timeout: 120_000 }, () => { const sdkDist = join(sdkDir, 'dist'); const cliDist = join(cliDir, 'dist'); + // SKIP_BUILD_BUMP prevents bump-build.mjs from mutating versions to + // invalid 4-part semver (e.g. 0.8.25.4) which npm install rejects. + const buildEnv = { ...process.env, SKIP_BUILD_BUMP: '1' }; + if (!existsSync(sdkDist)) { console.log('Building squad-sdk...'); - execSync('npm run build', { cwd: sdkDir, stdio: 'inherit' }); + execSync('npm run build', { cwd: sdkDir, stdio: 'inherit', env: buildEnv }); } if (!existsSync(cliDist)) { console.log('Building squad-cli...'); - execSync('npm run build', { cwd: cliDir, stdio: 'inherit' }); + execSync('npm run build', { cwd: cliDir, stdio: 'inherit', env: buildEnv }); } // Pack both packages diff --git a/test/marketplace.test.ts b/test/marketplace.test.ts index be48897da..d940b61d2 100644 --- a/test/marketplace.test.ts +++ b/test/marketplace.test.ts @@ -327,8 +327,10 @@ describe('packageForMarketplace', () => { }); it('should throw if project directory does not exist', () => { + // Use a UUID-based path to guarantee it doesn't exist on any OS + const fakePath = path.join(tmpDir, `nonexistent-${randomUUID()}`); expect(() => - packageForMarketplace('/nonexistent', makeManifest()), + packageForMarketplace(fakePath, makeManifest()), ).toThrow('not found'); }); diff --git a/test/repl-ux.test.ts b/test/repl-ux.test.ts index 80ffc74a8..c5f6e3893 100644 --- a/test/repl-ux.test.ts +++ b/test/repl-ux.test.ts @@ -62,12 +62,15 @@ describe('ThinkingIndicator visibility', () => { expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); }); - it('spinner text includes agent name from @mention', () => { + it('spinner text shows agent name from explicit activityHint', () => { + // After removing the redundant @mention fallback from MessageStream, + // the hint must come from the parent via activityHint (as App.tsx does). const { lastFrame } = render( h(MessageStream, { messages: [makeMessage({ role: 'user', content: '@Kovash fix the bug' })], processing: true, streamingContent: new Map(), + activityHint: 'Kovash is thinking...', }) ); const frame = lastFrame()!; @@ -742,12 +745,15 @@ describe('ThinkingIndicator integration with MessageStream', () => { expect(frame).toContain('Routing to agent'); }); - it('shows agent-specific hint when @mention present', () => { + it('shows agent-specific hint when activityHint provided', () => { + // After removing the redundant @mention fallback from MessageStream, + // the hint must come from the parent via activityHint (as App.tsx does). const { lastFrame } = render( h(MessageStream, { messages: [makeMessage({ role: 'user', content: '@Kovash fix the bug' })], processing: true, streamingContent: new Map(), + activityHint: 'Kovash is thinking...', }) ); const frame = lastFrame()!;