From 1acc5f7f2e1c94ce6e89aaaf258ed9de7644dd28 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 20 Jul 2026 20:47:20 +0800 Subject: [PATCH 1/2] fix(acp): deduplicate startup and record lifecycle notifications --- .../interfaces/acp/src/client/manager.rs | 147 ++++++++++++++---- .../sections/workspaces/WorkspaceItem.tsx | 9 +- src/web-ui/src/app/layout/AppLayout.scss | 53 ------- src/web-ui/src/app/layout/AppLayout.tsx | 64 ++------ .../flow_chat/components/ModelSelector.tsx | 10 +- .../src/flow_chat/services/FlowChatManager.ts | 4 +- src/web-ui/src/locales/en-US/common.json | 5 +- src/web-ui/src/locales/zh-CN/common.json | 5 +- src/web-ui/src/locales/zh-TW/common.json | 5 +- .../components/NotificationContainer.test.tsx | 84 ++++++++++ .../performance/startup-session-perf.spec.ts | 2 - 11 files changed, 244 insertions(+), 144 deletions(-) create mode 100644 src/web-ui/src/shared/notification-system/components/NotificationContainer.test.tsx diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 169e2258b6..2c8bc5cbb8 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -25,6 +25,7 @@ use bitfun_core::infrastructure::PathManager; use bitfun_core::service::config::ConfigService; use bitfun_core::service::remote_ssh::workspace_state::get_remote_workspace_manager; use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use dashmap::mapref::entry::Entry; use dashmap::DashMap; use futures::io::{AsyncRead as FuturesAsyncRead, AsyncWrite as FuturesAsyncWrite}; use log::{debug, info, warn}; @@ -513,33 +514,59 @@ impl AcpClientService { workspace_path: Option<&str>, remote_connection_id: Option<&str>, ) -> BitFunResult<()> { - if let Some(existing) = self.clients.get(connection_id).map(|entry| entry.clone()) { - let status = *existing.status.read().await; - if matches!(status, AcpClientStatus::Running) { - return Ok(()); - } - if matches!(status, AcpClientStatus::Starting) { - return wait_for_client_connection(existing, connection_id).await; + let (connection, remote_connection_id) = loop { + if let Some(existing) = self.clients.get(connection_id).map(|entry| entry.clone()) { + let status = *existing.status.read().await; + match status { + AcpClientStatus::Running => return Ok(()), + AcpClientStatus::Starting => { + return wait_for_client_connection(existing, connection_id).await; + } + AcpClientStatus::Configured + | AcpClientStatus::Stopped + | AcpClientStatus::Failed => { + self.clients + .remove_if(connection_id, |_, current| Arc::ptr_eq(current, &existing)); + } + } } - } - let StartClientConfig { - remote_connection_id, - config, - } = self - .resolve_start_client_config(client_id, workspace_path, remote_connection_id) - .await?; + let StartClientConfig { + remote_connection_id, + config, + } = self + .resolve_start_client_config(client_id, workspace_path, remote_connection_id) + .await?; + let candidate = Arc::new(AcpClientConnection::new( + connection_id.to_string(), + client_id.to_string(), + config, + )); - let connection = Arc::new(AcpClientConnection::new( - connection_id.to_string(), - client_id.to_string(), - config, - )); - self.clients - .insert(connection_id.to_string(), connection.clone()); - *connection.status.write().await = AcpClientStatus::Starting; + match claim_client_start(&self.clients, connection_id, candidate) { + ClientStartClaim::Owned(connection) => { + break (connection, remote_connection_id); + } + ClientStartClaim::Existing(existing) => { + let status = *existing.status.read().await; + match status { + AcpClientStatus::Running => return Ok(()), + AcpClientStatus::Starting => { + return wait_for_client_connection(existing, connection_id).await; + } + AcpClientStatus::Configured + | AcpClientStatus::Stopped + | AcpClientStatus::Failed => { + self.clients.remove_if(connection_id, |_, current| { + Arc::ptr_eq(current, &existing) + }); + } + } + } + } + }; - let (transport, child) = match remote_connection_id { + let transport_result = match remote_connection_id { Some(ref remote_connection_id) => { self.open_transport_for_connection( client_id, @@ -560,10 +587,17 @@ impl AcpClientService { ) .await } - } - .inspect_err(|_| { - self.clients.remove(connection_id); - })?; + }; + let (transport, child) = match transport_result { + Ok(result) => result, + Err(error) => { + *connection.status.write().await = AcpClientStatus::Failed; + self.clients.remove_if(connection_id, |_, current| { + Arc::ptr_eq(current, &connection) + }); + return Err(error); + } + }; *connection.child.lock().await = child; let service = self.clone(); let connection_for_task = connection.clone(); @@ -1801,7 +1835,7 @@ impl AcpClientConnection { id, client_id, config, - status: RwLock::new(AcpClientStatus::Configured), + status: RwLock::new(AcpClientStatus::Starting), connection: RwLock::new(None), agent_capabilities: RwLock::new(None), sessions: DashMap::new(), @@ -1818,6 +1852,25 @@ impl AcpClientConnection { } } +enum ClientStartClaim { + Owned(Arc), + Existing(Arc), +} + +fn claim_client_start( + clients: &DashMap>, + connection_id: &str, + candidate: Arc, +) -> ClientStartClaim { + match clients.entry(connection_id.to_string()) { + Entry::Vacant(entry) => { + entry.insert(candidate.clone()); + ClientStartClaim::Owned(candidate) + } + Entry::Occupied(entry) => ClientStartClaim::Existing(entry.get().clone()), + } +} + async fn wait_for_client_connection( client: Arc, connection_id: &str, @@ -2431,6 +2484,44 @@ fn select_permission_option_id(options: &[PermissionOption], approve: bool) -> S mod tests { use super::*; + fn test_client_connection(id: &str) -> Arc { + Arc::new(AcpClientConnection::new( + id.to_string(), + "opencode".to_string(), + AcpClientConfig { + name: Some("OpenCode".to_string()), + command: "opencode".to_string(), + args: Vec::new(), + env: HashMap::new(), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + }, + )) + } + + #[test] + fn claims_only_one_client_start_for_a_connection() { + let clients = DashMap::new(); + let first = test_client_connection("opencode::session::s1"); + let second = test_client_connection("opencode::session::s1"); + + let ClientStartClaim::Owned(owned) = + claim_client_start(&clients, "opencode::session::s1", first.clone()) + else { + panic!("first claimant should own startup"); + }; + let ClientStartClaim::Existing(existing) = + claim_client_start(&clients, "opencode::session::s1", second) + else { + panic!("second claimant should reuse startup"); + }; + + assert!(Arc::ptr_eq(&owned, &first)); + assert!(Arc::ptr_eq(&existing, &first)); + assert_eq!(clients.len(), 1); + } + #[test] fn selects_actual_permission_option_id_for_approval() { let options = vec![ diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 32aaf802cb..06037ade73 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -634,13 +634,10 @@ const WorkspaceItem: React.FC = ({ workspaceId: workspace.id, activateWorkspace: setActiveWorkspace, }); - } catch (error) { - notificationService.error( - error instanceof Error ? error.message : t('nav.workspaces.createSessionFailed'), - { duration: 4000 } - ); + } catch { + // createAcpChatSession records the failure through the ACP notification lifecycle. } - }, [setActiveWorkspace, t, workspace]); + }, [setActiveWorkspace, workspace]); const handleCreateInitSession = useCallback(async () => { setMenuOpen(false); diff --git a/src/web-ui/src/app/layout/AppLayout.scss b/src/web-ui/src/app/layout/AppLayout.scss index 956a08e560..74563d4ab5 100644 --- a/src/web-ui/src/app/layout/AppLayout.scss +++ b/src/web-ui/src/app/layout/AppLayout.scss @@ -122,42 +122,6 @@ html, body { background: var(--color-bg-scene); } -.bitfun-app-acp-session-loading { - position: absolute; - left: 50%; - bottom: $size-gap-5; - z-index: 10020; - display: inline-flex; - align-items: center; - gap: $size-gap-2; - max-width: min(360px, calc(100vw - 32px)); - min-height: 36px; - padding: 0 $size-gap-3; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - background: color-mix(in srgb, var(--color-bg-elevated) 92%, transparent); - box-shadow: 0 10px 30px rgba(var(--color-static-black-rgb), 0.28); - color: var(--color-text-primary); - font-size: var(--font-size-sm); - line-height: 1.35; - transform: translateX(-50%); - pointer-events: none; - animation: bitfun-acp-session-loading-in $motion-fast $easing-decelerate forwards; - - span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &__spinner { - flex-shrink: 0; - color: var(--color-accent-500); - animation: bitfun-acp-session-spinner 0.9s linear infinite; - } -} - .bitfun-window-mode-hint { position: fixed; top: calc(env(safe-area-inset-top, 0px) + #{$size-gap-3}); @@ -199,17 +163,6 @@ html, body { } } -@keyframes bitfun-acp-session-loading-in { - from { - opacity: 0; - transform: translateX(-50%) translateY(6px); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(0); - } -} - @keyframes bitfun-window-mode-hint-in { from { opacity: 0; @@ -221,12 +174,6 @@ html, body { } } -@keyframes bitfun-acp-session-spinner { - to { - transform: rotate(360deg); - } -} - // ==================== Scrollbar styles ==================== // Moved to global scrollbar.css for shared management. diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 3c5bcc445b..a59d061ab1 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -9,7 +9,6 @@ */ import React, { useState, useCallback, useEffect, useMemo, useRef, useContext, lazy, Suspense } from 'react'; -import { LoaderCircle } from 'lucide-react'; import { useWorkspaceContext } from '../../infrastructure/contexts/WorkspaceContext'; import { useWindowControls } from '../hooks/useWindowControls'; import { isWindowFullscreenShortcut } from '../hooks/windowFullscreenShortcut'; @@ -35,12 +34,12 @@ import { shortcutManager, parseStoredKeybindings } from '@/infrastructure/servic import { useSessionModeStore } from '../stores/sessionModeStore'; import { isMacOSDesktopRuntime } from '@/infrastructure/runtime'; import { flowChatSessionConfigForWorkspace } from '../utils/projectSessionWorkspace'; +import { notificationService } from '@/shared/notification-system'; import './AppLayout.scss'; type TransitionDirection = 'entering' | 'returning' | null; const log = createLogger('AppLayout'); -const ACP_SESSION_PENDING_TIMEOUT_MS = 75_000; const NewProjectDialog = lazy(() => import('../components/NewProjectDialog').then(module => ({ default: module.NewProjectDialog })) ); @@ -66,6 +65,7 @@ interface AcpSessionCreationEventDetail { clientId?: string; action?: 'create' | 'restore'; requestId?: string; + succeeded?: boolean; } interface WindowModeHint { @@ -216,12 +216,6 @@ const AppLayout: React.FC = ({ className = '' }) => { const [showNewProjectDialog, setShowNewProjectDialog] = useState(false); const [showAboutDialog, setShowAboutDialog] = useState(false); const [showWorkspaceStatus, setShowWorkspaceStatus] = useState(false); - const [pendingAcpSessionClients, setPendingAcpSessionClients] = useState>([]); const handleOpenProject = useCallback(async () => { try { const { pickWorkspaceDirectory } = await import( @@ -647,37 +641,27 @@ const AppLayout: React.FC = ({ className = '' }) => { const action = detail?.action === 'restore' ? 'restore' : 'create'; const id = detail?.requestId?.trim() || `${action}:${clientId}`; if (detail?.phase === 'start') { - setPendingAcpSessionClients(prev => [ - ...prev.filter(item => item.id !== id), - { id, clientId, action, startedAt: Date.now() }, - ]); + notificationService.silent({ + title: clientId, + message: tCommon('nav.workspaces.startingAcpSession'), + type: 'info', + metadata: { source: 'acp-session', clientId, action, requestId: id, phase: 'start' }, + }); } else if (detail?.phase === 'finish') { - setPendingAcpSessionClients(prev => { - const index = prev.findIndex(item => - item.id === id || - (!detail?.requestId && item.clientId === clientId && item.action === action) - ); - if (index === -1) return prev; - return prev.filter((_, currentIndex) => currentIndex !== index); + const succeeded = detail.succeeded !== false; + notificationService.silent({ + title: clientId, + message: succeeded + ? tCommon('nav.workspaces.acpSessionStarted') + : tCommon('nav.workspaces.acpSessionStartFailed'), + type: succeeded ? 'success' : 'error', + metadata: { source: 'acp-session', clientId, action, requestId: id, phase: 'finish', succeeded }, }); } }; window.addEventListener('bitfun:acp-session-creation', handler); return () => window.removeEventListener('bitfun:acp-session-creation', handler); - }, []); - - React.useEffect(() => { - if (pendingAcpSessionClients.length === 0) return undefined; - - const intervalId = window.setInterval(() => { - const expiresBefore = Date.now() - ACP_SESSION_PENDING_TIMEOUT_MS; - setPendingAcpSessionClients(prev => - prev.filter(item => item.startedAt >= expiresBefore) - ); - }, 5_000); - - return () => window.clearInterval(intervalId); - }, [pendingAcpSessionClients.length]); + }, [tCommon]); // Global drag-and-drop React.useEffect(() => { @@ -757,20 +741,6 @@ const AppLayout: React.FC = ({ className = '' }) => { )} - {pendingAcpSessionClients.length > 0 && ( -
- - - {pendingAcpSessionClients[pendingAcpSessionClients.length - 1].action === 'restore' - ? tCommon('nav.workspaces.restoringAcpSession', { - agentName: pendingAcpSessionClients[pendingAcpSessionClients.length - 1].clientId, - }) - : tCommon('nav.workspaces.creatingAcpSession', { - agentName: pendingAcpSessionClients[pendingAcpSessionClients.length - 1].clientId, - })} - -
- )} {/* Dialogs (previously owned by TitleBar) */} diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index ea689fb9d3..eba490e3ec 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -261,6 +261,7 @@ export const ModelSelector: React.FC = ({ })); } + let succeeded = false; try { const options = await withTimeout( ACPClientAPI.getSessionOptions({ @@ -275,13 +276,20 @@ export const ModelSelector: React.FC = ({ ); setAcpOptions(options); syncAcpContextUsageToStore(sessionId, options); + succeeded = true; } catch (error) { log.warn('Failed to load ACP session model options', { sessionId, acpClientId, error }); setAcpOptions(null); } finally { if (shouldShowRestoreToast) { window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', { - detail: { phase: 'finish', clientId: acpClientId, action: 'restore', requestId: restoreRequestId }, + detail: { + phase: 'finish', + clientId: acpClientId, + action: 'restore', + requestId: restoreRequestId, + succeeded, + }, })); } } diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 1427ccf2da..1bc71ed1c8 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -445,6 +445,7 @@ export class FlowChatManager { detail: { phase: 'start', clientId, action: 'create' }, })); + let succeeded = false; try { const response = await ACPClientAPI.createFlowSession({ clientId, @@ -470,10 +471,11 @@ export class FlowChatManager { config.remoteSshHost, ); + succeeded = true; return response.sessionId; } finally { window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', { - detail: { phase: 'finish', clientId, action: 'create' }, + detail: { phase: 'finish', clientId, action: 'create', succeeded }, })); } } diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 553d0c276f..cdca867b32 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -351,8 +351,9 @@ "revealFailed": "Failed to reveal workspace folder", "copyPathFailed": "Failed to copy path", "createSessionFailed": "Failed to create session", - "creatingAcpSession": "Starting {{agentName}} session...", - "restoringAcpSession": "Restoring {{agentName}} session...", + "startingAcpSession": "Session starting", + "acpSessionStarted": "Session started", + "acpSessionStartFailed": "Session failed to start", "initSessionFailed": "Failed to start AGENTS.md initialization session", "worktreeCreated": "Worktree created", "worktreeCreatedAndOpened": "Worktree created and opened", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index abf169c722..cf228226de 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -351,8 +351,9 @@ "revealFailed": "打开工作区目录失败", "copyPathFailed": "复制路径失败", "createSessionFailed": "新建会话失败", - "creatingAcpSession": "正在启动 {{agentName}} 会话...", - "restoringAcpSession": "正在恢复 {{agentName}} 会话...", + "startingAcpSession": "会话启动中", + "acpSessionStarted": "会话启动完成", + "acpSessionStartFailed": "会话启动失败", "initSessionFailed": "初始化 AGENTS.md 会话失败", "worktreeCreated": "已创建 worktree", "worktreeCreatedAndOpened": "已创建并打开 worktree", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 2792fc7ba5..1dac130f89 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -351,8 +351,9 @@ "revealFailed": "開啟工作區目錄失敗", "copyPathFailed": "複製路徑失敗", "createSessionFailed": "新增會話失敗", - "creatingAcpSession": "正在啟動 {{agentName}} 會話...", - "restoringAcpSession": "正在恢復 {{agentName}} 會話...", + "startingAcpSession": "會話啟動中", + "acpSessionStarted": "會話啟動完成", + "acpSessionStartFailed": "會話啟動失敗", "initSessionFailed": "初始化 AGENTS.md 會話失敗", "worktreeCreated": "已建立 worktree", "worktreeCreatedAndOpened": "已建立並開啟 worktree", diff --git a/src/web-ui/src/shared/notification-system/components/NotificationContainer.test.tsx b/src/web-ui/src/shared/notification-system/components/NotificationContainer.test.tsx new file mode 100644 index 0000000000..418c1f2249 --- /dev/null +++ b/src/web-ui/src/shared/notification-system/components/NotificationContainer.test.tsx @@ -0,0 +1,84 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Notification } from '../types'; +import { useActiveNotifications } from '../hooks/useNotificationState'; +import { NotificationContainer } from './NotificationContainer'; + +vi.mock('../hooks/useNotificationState', () => ({ + useActiveNotifications: vi.fn(), +})); + +vi.mock('./NotificationItem', () => ({ + NotificationItem: ({ notification }: { notification: Notification }) => ( +
{notification.message}
+ ), +})); + +vi.mock('./ProgressNotification', () => ({ + ProgressNotification: ({ notification }: { notification: Notification }) => ( +
{notification.message}
+ ), +})); + +vi.mock('./LoadingNotification', () => ({ + LoadingNotification: ({ notification }: { notification: Notification }) => ( +
{notification.message}
+ ), +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const notification = (variant: Notification['variant'], message: string): Notification => ({ + id: `${variant}-${message}`, + type: 'info', + variant, + title: 'Test', + message, + timestamp: 1, + status: 'active', +}); + +describe('NotificationContainer', () => { + let dom: JSDOM; + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + dom = new JSDOM('
'); + globalThis.window = dom.window as unknown as Window & typeof globalThis; + globalThis.document = dom.window.document; + container = document.getElementById('root') as HTMLDivElement; + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + vi.clearAllMocks(); + dom.window.close(); + }); + + it('keeps task notifications in the notification center instead of the toast stack', () => { + vi.mocked(useActiveNotifications).mockReturnValue([ + notification('toast', 'Saved'), + notification('progress', 'Indexing'), + notification('loading', 'Connecting'), + ]); + + act(() => root.render()); + + expect(container.querySelector('[data-variant="toast"]')?.textContent).toBe('Saved'); + expect(container.querySelector('[data-variant="progress"]')).toBeNull(); + expect(container.querySelector('[data-variant="loading"]')).toBeNull(); + }); + + it('keeps silent notifications out of the toast stack', () => { + vi.mocked(useActiveNotifications).mockReturnValue([notification('silent', 'Background')]); + + act(() => root.render()); + + expect(container.querySelector('.notification-container')).toBeNull(); + }); +}); diff --git a/tests/e2e/specs/performance/startup-session-perf.spec.ts b/tests/e2e/specs/performance/startup-session-perf.spec.ts index c3ad446081..673504ba41 100644 --- a/tests/e2e/specs/performance/startup-session-perf.spec.ts +++ b/tests/e2e/specs/performance/startup-session-perf.spec.ts @@ -1428,7 +1428,6 @@ async function startLongSessionViewportTimelineRecorder( '.history-session-placeholder', '.bitfun-scene-viewport__lazy-fallback', '.bitfun-assistant-scene__loading', - '.bitfun-app-acp-session-loading', '[role="status"][aria-busy="true"]', ]; const OBSERVED_MUTATION_SELECTOR = [ @@ -1559,7 +1558,6 @@ async function startLongSessionViewportTimelineRecorder( element.closest('.history-session-placeholder') || element.closest('.bitfun-scene-viewport__lazy-fallback') || element.closest('.bitfun-assistant-scene__loading') || - element.closest('.bitfun-app-acp-session-loading') || element.closest('[role="status"][aria-busy="true"]'), ); From 2b97e4a794134140708d6c193bac7c456cecd74c Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 21 Jul 2026 09:29:21 +0800 Subject: [PATCH 2/2] test(cli): use stable multiline PTY sentinel --- src/apps/cli/tests/terminal_process_contracts.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/apps/cli/tests/terminal_process_contracts.rs b/src/apps/cli/tests/terminal_process_contracts.rs index df12fcb34a..c681ce6e05 100644 --- a/src/apps/cli/tests/terminal_process_contracts.rs +++ b/src/apps/cli/tests/terminal_process_contracts.rs @@ -27,6 +27,7 @@ const EXEC_STREAM_SIZE: PtySize = PtySize { }; const STARTUP_INPUT: &[u8] = b"exercise active turn resize Q7Z9"; const STARTUP_INPUT_SENTINEL: &str = "Q7Z9"; +const MULTILINE_INPUT_SENTINEL: &str = "M7Q4"; const RECOVERY_INPUT: &[u8] = b"READY_AFTER_CANCEL K4W8"; const RECOVERY_INPUT_SENTINEL: &str = "K4W8"; @@ -48,18 +49,20 @@ fn interactive_startup_survives_resize_multiline_input_and_emits_cleanup() { process.resize(RESIZED_SIZE); + // Ratatui emits only changed cells, so the sentinel must not share characters + // with the startup placeholder at the same screen positions. #[cfg(unix)] - process.write(b"\x1b[200~alpha\r\nbeta\x1b[201~"); + process.write(b"\x1b[200~M7Q4\r\nbeta\x1b[201~"); #[cfg(windows)] { - let mut rapid_input = b"alpha".to_vec(); + let mut rapid_input = MULTILINE_INPUT_SENTINEL.as_bytes().to_vec(); rapid_input.extend(std::iter::repeat_n(b'a', 251)); rapid_input.extend_from_slice(b"\rbeta"); process.write(&rapid_input); } process.expect_output( - "alpha", + MULTILINE_INPUT_SENTINEL, Duration::from_secs(15), "interactive startup did not render multiline input", ); @@ -80,7 +83,7 @@ fn interactive_startup_survives_resize_multiline_input_and_emits_cleanup() { "unexpected process status {status}:\n{output}" ); assert!( - output.contains("alpha"), + output.contains(MULTILINE_INPUT_SENTINEL), "paste text was not rendered:\n{output}" ); assert!(