Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 02d77d26af fix(terminal): keep active-only compose sends off the toolbar fan-out
The focused workspace executor omitted broadcast, so executeSnippetCommand
fell back to the toolbar switch and still wrote every peer. Pass
broadcast: false on that path.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 15:04:02 +00:00
Cursor Agentand陈大猫 b036b023bb feat(terminal): split compose-bar send target from toolbar broadcast
Toolbar broadcast still fans out keystrokes, paste, and terminal-path
snippets. The compose-bar indicator is now a toggle for whether Enter
sends to broadcast peers or only the active session. Turning the toolbar
switch on or off resets that target to follow it; later flips stay
independent.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 14:58:28 +00:00
28 changed files with 615 additions and 29 deletions

No files matched your search

+5 -1
View File
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
@@ -784,8 +784,12 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
isComposeSendToAll={isComposeSendToAll}
onToggleComposeSendTarget={onToggleComposeSendTarget}
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
canUseGlobalBroadcast={canUseGlobalBroadcast}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
+4
View File
@@ -205,7 +205,11 @@ export function TerminalHost() {
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
isComposeSendToAll: sessionActions?.isComposeSendToAll,
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
@@ -70,8 +70,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
@@ -150,8 +154,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
@@ -181,7 +189,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
isComposeSendToAll,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
moveFocusInWorkspace,
openLogView,
canUseGlobalBroadcast,
@@ -201,7 +211,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleComposeSendTarget,
toggleGlobalBroadcast,
toggleGlobalComposeSendTarget,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
+2
View File
@@ -77,6 +77,8 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.sendToAll': 'Send to all sessions',
'terminal.composeBar.sendToActive': 'Send to the active session only',
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
+2
View File
@@ -77,6 +77,8 @@ export const esTerminalMessages: Messages = {
'terminal.composeBar.send': 'Enviar',
'terminal.composeBar.close': 'Cerrar barra de redacción',
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
'terminal.composeBar.sendToAll': 'Enviar a todas las sesiones',
'terminal.composeBar.sendToActive': 'Enviar solo a la sesión activa',
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
+2
View File
@@ -98,6 +98,8 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.sendToAll': 'Отправить во все сессии',
'terminal.composeBar.sendToActive': 'Отправить только в активную сессию',
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
+2
View File
@@ -524,6 +524,8 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.sendToAll': '发送到所有会话',
'terminal.composeBar.sendToActive': '仅发送到当前会话',
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
+2
View File
@@ -524,6 +524,8 @@ export const zhTWVaultMessages: Messages = {
'terminal.composeBar.send': '傳送',
'terminal.composeBar.close': '關閉撰寫欄',
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
'terminal.composeBar.sendToAll': '傳送到所有工作階段',
'terminal.composeBar.sendToActive': '僅傳送到目前工作階段',
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
@@ -102,6 +102,10 @@ test('session actions register and unregister', () => {
reorderTabs: noop,
toggleBroadcast: noop,
isBroadcastEnabled: noop,
toggleComposeSendTarget: noop,
isComposeSendToAll: noop,
toggleGlobalComposeSendTarget: noop,
isGlobalComposeSendToAll: false,
copySession: noop,
copyWorkspace: noop,
createSessionFromCloneSource: noop,
@@ -68,8 +68,12 @@ export type SessionSnapshotActions = Pick<
| 'reorderTabs'
| 'toggleBroadcast'
| 'isBroadcastEnabled'
| 'toggleComposeSendTarget'
| 'isComposeSendToAll'
| 'toggleGlobalBroadcast'
| 'isGlobalBroadcastEnabled'
| 'toggleGlobalComposeSendTarget'
| 'isGlobalComposeSendToAll'
| 'canUseGlobalBroadcast'
| 'copySession'
| 'copyWorkspace'
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("compose send target follows toolbar broadcast and then flips independently", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
const [first, second] = current().orphanSessions;
await act(async () => {
current().createWorkspaceFromSessions(first.id, second.id, "right");
});
const workspaceId = current().workspaces[0]?.id;
assert.ok(workspaceId);
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleBroadcast(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), true);
assert.equal(current().isComposeSendToAll(workspaceId), true);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), true);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isComposeSendToAll(workspaceId), true);
assert.equal(current().isBroadcastEnabled(workspaceId), true);
await act(async () => { current().toggleBroadcast(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), true);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
assert.equal(current().canUseGlobalBroadcast, false);
} finally {
await act(async () => { renderer?.unmount(); });
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("global compose send target follows the toolbar switch for orphan sessions", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalComposeSendToAll, false);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, true);
assert.equal(current().isGlobalComposeSendToAll, true);
await act(async () => { current().toggleGlobalComposeSendTarget(); });
assert.equal(current().isGlobalBroadcastEnabled, true);
assert.equal(current().isGlobalComposeSendToAll, false);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
const closedId = current().orphanSessions[1].id;
await act(async () => { current().toggleGlobalComposeSendTarget(); });
assert.equal(current().isGlobalComposeSendToAll, true);
await act(async () => { current().closeSession(closedId); });
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
} finally {
await act(async () => { renderer?.unmount(); });
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
+55 -11
View File
@@ -25,6 +25,10 @@ SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import {
composeSendTargetAfterToolbarToggle,
flipComposeSendTarget,
} from '../../domain/terminalBroadcast';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
@@ -235,8 +239,12 @@ export const useSessionState = ({
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
// Broadcast mode: stores workspace IDs that have broadcast enabled
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
// Compose-bar send target. Toolbar toggles reset it; the compose bar can
// then flip it without changing keystroke fan-out.
const [composeSendAllWorkspaceIds, setComposeSendAllWorkspaceIds] = useState<Set<string>>(new Set());
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
@@ -1137,8 +1145,10 @@ export const useSessionState = ({
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
setGlobalBroadcastEnabled(false);
// Compose-bar send target follows the toolbar switch back to active-only.
if (!canUseGlobalBroadcast && (globalBroadcastEnabled || globalComposeSendToAll)) {
if (globalBroadcastEnabled) setGlobalBroadcastEnabled(false);
if (globalComposeSendToAll) setGlobalComposeSendToAll(false);
}
const openLogView = useCallback((log: ConnectionLog) => {
@@ -1273,31 +1283,61 @@ export const useSessionState = ({
return newSessionId;
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
const setWorkspaceComposeSendTarget = useCallback((workspaceId: string, target: 'all' | 'active') => {
setComposeSendAllWorkspaceIds((prev) => {
const hasAll = prev.has(workspaceId);
if ((target === 'all') === hasAll) return prev;
const next = new Set(prev);
if (next.has(workspaceId)) {
next.delete(workspaceId);
} else {
next.add(workspaceId);
}
if (target === 'all') next.add(workspaceId);
else next.delete(workspaceId);
return next;
});
}, []);
// Toggle broadcast mode for a workspace. Compose-bar send target follows.
const toggleBroadcast = useCallback((workspaceId: string) => {
const enabling = !broadcastWorkspaceIds.has(workspaceId);
setBroadcastWorkspaceIds(prev => {
const next = new Set(prev);
if (prev.has(workspaceId)) next.delete(workspaceId);
else next.add(workspaceId);
return next;
});
setWorkspaceComposeSendTarget(workspaceId, composeSendTargetAfterToolbarToggle(enabling));
}, [broadcastWorkspaceIds, setWorkspaceComposeSendTarget]);
// Check if a workspace has broadcast enabled
const isBroadcastEnabled = useCallback((workspaceId: string) => {
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
const toggleComposeSendTarget = useCallback((workspaceId: string) => {
setWorkspaceComposeSendTarget(
workspaceId,
flipComposeSendTarget(composeSendAllWorkspaceIds.has(workspaceId) ? 'all' : 'active'),
);
}, [composeSendAllWorkspaceIds, setWorkspaceComposeSendTarget]);
const isComposeSendToAll = useCallback((workspaceId: string) => {
return composeSendAllWorkspaceIds.has(workspaceId);
}, [composeSendAllWorkspaceIds]);
// Toggle global broadcast mode for orphan sessions
const toggleGlobalBroadcast = useCallback(() => {
setGlobalBroadcastEnabled(prev => !prev);
setGlobalBroadcastEnabled(prev => {
const next = !prev;
setGlobalComposeSendToAll(composeSendTargetAfterToolbarToggle(next) === 'all');
return next;
});
}, []);
const toggleGlobalComposeSendTarget = useCallback(() => {
setGlobalComposeSendToAll((prev) => flipComposeSendTarget(prev ? 'all' : 'active') === 'all');
}, []);
// Global broadcast enabled state - direct boolean value
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
const isGlobalComposeSendToAll = globalComposeSendToAll;
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
@@ -1426,8 +1466,12 @@ export const useSessionState = ({
// Broadcast mode
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
+12 -3
View File
@@ -364,6 +364,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOpenTheme,
onOpenSystem,
isBroadcastEnabled,
composeSendToAll,
onToggleComposeSendTarget,
onToggleBroadcast,
onToggleComposeBar,
isWorkspaceComposeBarOpen,
@@ -3435,13 +3437,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// A bypassed password fan-out (#3488) forces the sensitive marker even
// when this target has not itself classified its prompt as sensitive.
const sensitive = passwordPromptActiveRef.current || options?.sensitive === true;
const broadcastThisSend = options?.broadcast !== undefined
? options.broadcast === true
: isBroadcastEnabledRef.current;
if (
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
})
&& options?.broadcast !== false
&& isBroadcastEnabledRef.current
&& broadcastThisSend
&& onBroadcastInputRef.current
) {
const broadcastDeliveredSessionIds = onBroadcastInputRef.current(data, sessionId, {
@@ -3451,6 +3455,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// peer writes keep skipping input interceptors.
...(sensitive ? { sourceSensitive: true } : {}),
...(lineDelayMs ? { lineDelayMs } : {}),
// Compose-bar send-to-all while the toolbar switch is off. Keystrokes
// and snippets leave this unset and stay on the toolbar gate.
...(options?.broadcast === true && !isBroadcastEnabledRef.current
? { composePeerFanOut: true }
: {}),
});
// #3491: report the sessions that received the fan-out so callers (the
// solo compose bar) can detect a payload delivered into a sensitive
@@ -4712,7 +4721,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
+20 -4
View File
@@ -260,8 +260,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateLocalTerminal,
isBroadcastEnabled,
onToggleBroadcast,
isComposeSendToAll,
onToggleComposeSendTarget,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
onToggleGlobalBroadcast,
onToggleGlobalComposeSendTarget,
canUseGlobalBroadcast,
updateHosts,
updateSnippets,
@@ -1277,6 +1281,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sourceSessionId,
globalBroadcastEnabled: isGlobalBroadcastEnabled,
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
includeGlobalPeersWithoutToolbar: options?.composePeerFanOut === true,
}).filter(id => !paced?.targets || paced.targets.some(target => target.sessionId === id));
if (options?.preparePacedBroadcast && paced) {
paced.targets = targetSessionIds.filter(id => passwordBypass || !isTerminalSensitiveInputActive(id)).map(id => {
@@ -2463,14 +2468,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
let anyRecipientSensitive = false;
const pendingSends: Promise<boolean>[] = [];
const payload = text + '\r';
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
// Compose Enter follows the compose-bar target. Toolbar broadcast stays
// the keystroke switch and is only the fallback when no compose target
// has been published (older callers).
const composeSendToAll = isComposeSendToAll
? isComposeSendToAll(activeWorkspace.id) === true
: Boolean(isBroadcastEnabled?.(activeWorkspace.id));
const focusedSessionId = activeWorkspace.focusedSessionId;
const broadcastPasswordBypass = broadcastPasswordBypassRef.current;
const focusedSensitive = focusedSessionId
? isTerminalSensitiveInputActive(focusedSessionId)
: false;
if (broadcastEnabled && (broadcastPasswordBypass || !focusedSensitive)) {
if (composeSendToAll && (broadcastPasswordBypass || !focusedSensitive)) {
const allSessionIds = sessionsRef.current
.filter((session) => session.workspaceId === activeWorkspace.id)
.map((session) => session.id);
@@ -2518,7 +2528,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const executor = snippetExecutorsRef.current.get(targetId);
if (executor) {
const sensitive = isTerminalSensitiveInputActive(targetId);
pendingSends.push(Promise.resolve(executor(text, false)).then(
// Explicitly local: an omitted broadcast option falls back to the
// toolbar switch, which can still be on while compose is active-only.
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
));
} else {
@@ -2539,7 +2551,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// eligibility for it.
if (focusedSensitive || anyRecipientSensitive) return false;
return delivered;
}, [isBroadcastEnabled, terminalBackend]);
}, [isBroadcastEnabled, isComposeSendToAll, terminalBackend]);
const sessionLogConfig = useMemo(
() => ({
@@ -2664,7 +2676,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
restoreTerminalCwd,
identities,
isBroadcastEnabled,
isComposeSendToAll,
onToggleComposeSendTarget,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
onToggleGlobalComposeSendTarget,
canUseGlobalBroadcast,
isComposeBarOpen,
keyBindings,
@@ -3,6 +3,7 @@ import test from 'node:test';
import React from 'react';
import { JSDOM } from 'jsdom';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../application/i18n/I18nProvider';
import { TerminalComposeBar } from './TerminalComposeBar';
import { TooltipProvider } from '../ui/tooltip';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
@@ -195,3 +196,65 @@ test('empty compose draft Enter reconnects; a typed draft still sends', async (t
await act(async () => { root.unmount(); });
});
test('compose send target toggle shows all vs active without looking like broadcast when active', async (t) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
for (const name of ['window', 'document', 'localStorage']) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
}
t.after(() => {
dom.window.close();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const toggles: string[] = [];
const render = (composeSendToAll: boolean) => (
<I18nProvider locale="en">
<TooltipProvider>
<TerminalComposeBar
sessionId="compose-target"
onSend={() => true}
onClose={() => {}}
isBroadcastEnabled
composeSendToAll={composeSendToAll}
onToggleComposeSendTarget={() => { toggles.push('toggle'); }}
/>
</TooltipProvider>
</I18nProvider>
);
let root!: ReactTestRenderer;
await act(async () => {
root = create(render(true), { createNodeMock: (el) => (el.type === 'textarea' ? { focus() {} } : null) });
});
const button = root.root.findAll(
(node) => node.props?.['data-compose-send-target'] === 'all' || node.props?.['data-compose-send-target'] === 'active',
)[0];
assert.ok(button);
assert.equal(button.props['data-compose-send-target'], 'all');
assert.equal(button.props['aria-pressed'], true);
assert.match(String(button.props['aria-label']), /all sessions/i);
const pulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
assert.ok(pulsing.length > 0);
await act(async () => {
button.props.onClick({
preventDefault() {},
stopPropagation() {},
defaultPrevented: false,
nativeEvent: { defaultPrevented: false },
});
});
assert.deepEqual(toggles, ['toggle']);
await act(async () => { root.update(render(false)); });
const active = root.root.findAll((node) => node.props?.['data-compose-send-target'] === 'active')[0];
assert.ok(active);
assert.equal(active.props['aria-pressed'], false);
assert.match(String(active.props['aria-label']), /active session/i);
const stillPulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
assert.equal(stillPulsing.length, 0);
await act(async () => { root.unmount(); });
});
+32 -7
View File
@@ -280,6 +280,9 @@ export interface TerminalComposeBarProps {
onSnippetClick?: (snippet: Snippet) => void;
snippets?: Snippet[];
isBroadcastEnabled?: boolean;
/** Compose-bar send target. Undefined follows toolbar broadcast. */
composeSendToAll?: boolean;
onToggleComposeSendTarget?: () => void;
themeColors?: {
background: string;
foreground: string;
@@ -293,6 +296,8 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
onSnippetClick,
snippets = [],
isBroadcastEnabled,
composeSendToAll,
onToggleComposeSendTarget,
themeColors,
}) => {
const { t } = useI18n();
@@ -350,8 +355,9 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
};
}, [broadcastMenuAnchor]);
const sendToAll = composeSendToAll ?? Boolean(isBroadcastEnabled);
const handleBroadcastContextMenu = useCallback((event: React.MouseEvent) => {
if (!isBroadcastEnabled) return;
if (!isBroadcastEnabled && !sendToAll) return;
event.preventDefault();
event.stopPropagation();
setBroadcastMenuAnchor({
@@ -359,7 +365,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
y: event.clientY,
inTextarea: event.target instanceof Element && Boolean(event.target.closest('textarea')),
});
}, [isBroadcastEnabled]);
}, [isBroadcastEnabled, sendToAll]);
const handleComposeMenuEdit = useCallback(async (action: 'cut' | 'copy' | 'paste' | 'selectAll') => {
setBroadcastMenuAnchor(null);
@@ -607,14 +613,33 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
<div className="flex flex-1 min-h-0 items-start gap-1.5">
{isBroadcastEnabled && (
{(onToggleComposeSendTarget || sendToAll) && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center cursor-default pt-0.5 flex-shrink-0">
<Radio size={14} className="text-amber-400 animate-pulse" />
</div>
<button
type="button"
data-compose-send-target={sendToAll ? 'all' : 'active'}
aria-pressed={sendToAll}
aria-label={sendToAll
? t('terminal.composeBar.sendToAll')
: t('terminal.composeBar.sendToActive')}
className="flex items-center pt-0.5 flex-shrink-0 rounded-sm"
onMouseDown={(event) => event.preventDefault()}
onClick={() => onToggleComposeSendTarget?.()}
onContextMenu={handleBroadcastContextMenu}
>
<Radio
size={14}
className={sendToAll ? 'text-amber-400 animate-pulse' : undefined}
style={sendToAll ? undefined : { color: theme.mutedFg }}
/>
</button>
</TooltipTrigger>
<TooltipContent>{t('terminal.composeBar.broadcasting')}</TooltipContent>
<TooltipContent>
{sendToAll
? t('terminal.composeBar.broadcasting')
: t('terminal.composeBar.sendToActive')}
</TooltipContent>
</Tooltip>
)}
+7 -1
View File
@@ -390,7 +390,7 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
const isNetworkDevice = host.deviceType === 'network'
@@ -1328,6 +1328,10 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
let anyRecipientSensitive = false;
let broadcastRecipientIds: readonly string[] = [];
const sent = await executeSnippetCommand(text, false, {
// Explicit target: active-only stays local while toolbar
// broadcast (keystrokes) can remain on, and send-to-all
// still fans out when the toolbar switch is off.
broadcast: composeSendToAll ?? Boolean(isBroadcastEnabled),
onBroadcastDelivered: (sessionIds) => {
broadcastRecipientIds = sessionIds;
if (sessionIds.some((id) => isTerminalSensitiveInputActive(id))) {
@@ -1352,6 +1356,8 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
termRef.current?.focus();
}}
isBroadcastEnabled={isBroadcastEnabled}
composeSendToAll={composeSendToAll}
onToggleComposeSendTarget={onToggleComposeSendTarget}
themeColors={effectiveTheme.colors}
/>
)}
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { shouldBroadcastTerminalUserInput } from "./terminalUserPaste.ts";
test("toolbar broadcast still fans terminal keystrokes out to peers", () => {
const term = {};
assert.equal(
shouldBroadcastTerminalUserInput(term, "ls", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
true,
);
assert.equal(
shouldBroadcastTerminalUserInput(term, "ls", {
isBroadcastEnabled: false,
hasBroadcastInputHandler: true,
}),
false,
);
});
test("keystroke fan-out stays on the toolbar gate and ignores the compose-bar target", () => {
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
assert.match(runtime, /isBroadcastEnabled: ctx\.isBroadcastEnabledRef\.current/);
assert.match(
runtime,
/onBroadcastInput\?\.\(broadcastData, ctx\.sessionId, sensitive \? \{ sourceSensitive: true \} : undefined\)/,
);
assert.doesNotMatch(runtime, /composeSendToAll|composePeerFanOut/);
const layer = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
assert.match(layer, /includeGlobalPeersWithoutToolbar: options\?\.composePeerFanOut === true/);
const keystrokeCall = layer.slice(
layer.indexOf("const handleBroadcastInput"),
layer.indexOf("const handleCommandSubmitted"),
);
assert.doesNotMatch(keystrokeCall, /isComposeSendToAll/);
});
+8
View File
@@ -48,6 +48,11 @@ export interface TerminalBroadcastInputOptions {
* the dispatcher tags the payload sourceSensitive.
*/
sensitive?: boolean;
/**
* Compose-bar send-to-all for orphan sessions while the toolbar broadcast
* switch stays off. Keystroke fan-out never sets this.
*/
composePeerFanOut?: boolean;
/**
* Observer for the sessions that actually received the payload (#3491):
* the solo compose bar uses it to detect a fan-out delivered into a peer's
@@ -236,6 +241,9 @@ export interface TerminalProps {
onOpenTheme?: () => void;
onOpenSystem?: () => void;
isBroadcastEnabled?: boolean;
/** Compose-bar send target for solo sessions. Workspace compose lives on the layer. */
composeSendToAll?: boolean;
onToggleComposeSendTarget?: () => void;
onToggleBroadcast?: () => void;
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
+2
View File
@@ -54,6 +54,8 @@ export const terminalPropsAreEqual = (
&& prev.disableTerminalFontZoom === next.disableTerminalFontZoom
&& prev.keyBindings === next.keyBindings
&& prev.isBroadcastEnabled === next.isBroadcastEnabled
&& prev.composeSendToAll === next.composeSendToAll
&& prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget
&& prev.isWorkspaceComposeBarOpen === next.isWorkspaceComposeBarOpen
&& prev.sessionLog === next.sessionLog
&& prev.sshDebugLogEnabled === next.sshDebugLogEnabled
@@ -719,10 +719,14 @@ export interface TerminalLayerProps {
onCreateLocalTerminal?: () => void;
// Broadcast mode
isBroadcastEnabled?: (workspaceId: string) => boolean;
isComposeSendToAll?: (workspaceId: string) => boolean;
onToggleComposeSendTarget?: (workspaceId: string) => void;
isGlobalBroadcastEnabled?: boolean;
isGlobalComposeSendToAll?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleBroadcast?: (workspaceId: string) => void;
onToggleGlobalBroadcast?: () => void;
onToggleGlobalComposeSendTarget?: () => void;
// SFTP side panel
updateHosts: (hosts: Host[]) => void;
updateSnippets?: (snippets: Snippet[]) => void;
@@ -824,9 +828,12 @@ interface TerminalPaneProps {
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
isComposeSendToAll?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
isGlobalComposeSendToAll?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleGlobalBroadcast?: () => void;
onToggleGlobalComposeSendTarget?: () => void;
onBroadcastInput: (
data: string,
sourceSessionId: string,
@@ -952,9 +959,12 @@ const terminalPanePropsAreEqual = (
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
prev.isComposeSendToAll === next.isComposeSendToAll &&
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
prev.onBroadcastInput === next.onBroadcastInput &&
prev.onBroadcastInterruptPriorityChange === next.onBroadcastInterruptPriorityChange &&
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
@@ -1253,9 +1263,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
isComposeSendToAll,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
onToggleGlobalBroadcast,
onToggleGlobalComposeSendTarget,
onBroadcastInput,
onBroadcastInterruptPriorityChange,
onToggleWorkspaceComposeBar,
@@ -1485,6 +1498,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
const broadcastEnabled = activeWorkspaceId
? !!isBroadcastEnabled?.(activeWorkspaceId)
: (isGlobalBroadcastEnabled ?? false);
const composeSendToAll = activeWorkspaceId
? !!isComposeSendToAll?.(activeWorkspaceId)
: !!isGlobalComposeSendToAll;
const toggleComposeSendTarget = !activeWorkspaceId && canUseGlobalBroadcast
? onToggleGlobalComposeSendTarget
: undefined;
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
const sessionAppearance = useMemo(
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
@@ -1630,6 +1649,8 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
isBroadcastEnabled={broadcastEnabled}
composeSendToAll={inActiveWorkspace ? undefined : composeSendToAll}
onToggleComposeSendTarget={toggleComposeSendTarget}
onToggleBroadcast={
inActiveWorkspace
? workspaceBroadcastHandler
@@ -1637,7 +1658,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
}
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
onBroadcastInput={onBroadcastInput}
onBroadcastInterruptPriorityChange={onBroadcastInterruptPriorityChange}
onSnippetExecutorChange={onSnippetExecutorChange}
onProgrammaticCommandLogRewriteChange={onProgrammaticCommandLogRewriteChange}
@@ -1726,9 +1747,12 @@ interface TerminalPanesHostProps {
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
isComposeSendToAll?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
isGlobalComposeSendToAll?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleGlobalBroadcast?: () => void;
onToggleGlobalComposeSendTarget?: () => void;
onBroadcastInput: (
data: string,
sourceSessionId: string,
@@ -1816,9 +1840,12 @@ const terminalPanesHostPropsAreEqual = (
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
if (prev.onSplitSession !== next.onSplitSession) return false;
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
if (prev.isGlobalBroadcastEnabled !== next.isGlobalBroadcastEnabled) return false;
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
if (prev.canUseGlobalBroadcast !== next.canUseGlobalBroadcast) return false;
if (prev.onToggleGlobalBroadcast !== next.onToggleGlobalBroadcast) return false;
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
if (prev.onBroadcastInput !== next.onBroadcastInput) return false;
if (prev.onBroadcastInterruptPriorityChange !== next.onBroadcastInterruptPriorityChange) return false;
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
@@ -547,6 +547,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
restoreTerminalCwd: s.restoreTerminalCwd,
identities: s.identities,
isBroadcastEnabled: s.isBroadcastEnabled,
isComposeSendToAll: s.isComposeSendToAll,
onToggleComposeSendTarget: s.onToggleComposeSendTarget,
isComposeBarOpen: s.isComposeBarOpen,
isFocusMode,
isSidePanelOpenForCurrentTab,
@@ -659,6 +661,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
workspaceById,
isGlobalBroadcastEnabled: s.isGlobalBroadcastEnabled,
isGlobalComposeSendToAll: s.isGlobalComposeSendToAll,
onToggleGlobalComposeSendTarget: s.onToggleGlobalComposeSendTarget,
canUseGlobalBroadcast: s.canUseGlobalBroadcast,
onToggleGlobalBroadcast: s.onToggleGlobalBroadcastRef.current,
// AI scope maintenance (merge/dissolve handoff) needs the full list; do not
@@ -101,6 +101,8 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
isComposeSendToAll,
onToggleComposeSendTarget,
handleBroadcastInput,
handleBroadcastInterruptPriorityChange,
handleToggleWorkspaceComposeBar,
@@ -124,8 +126,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onStartSessionDrag,
onEndSessionDrag,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
onToggleGlobalBroadcast,
onToggleGlobalComposeSendTarget,
t,
} = ctx;
@@ -247,6 +251,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onSetWorkspaceFocusedSession={onSetWorkspaceFocusedSession}
onSplitSession={onSplitSession}
isBroadcastEnabled={isBroadcastEnabled}
isComposeSendToAll={isComposeSendToAll}
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
onBroadcastInput={handleBroadcastInput}
onBroadcastInterruptPriorityChange={handleBroadcastInterruptPriorityChange}
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
@@ -349,6 +356,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
refocusTerminalSession(focusedSessionId);
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
composeSendToAll={isComposeSendToAll?.(activeWorkspace.id) ?? false}
onToggleComposeSendTarget={onToggleComposeSendTarget
? () => onToggleComposeSendTarget(activeWorkspace.id)
: undefined}
themeColors={composeBarThemeColors}
/>
)}
@@ -23,6 +23,7 @@ function setup(
sensitive = new Set<string>(),
broadcastPasswordBypass = false,
restoredDisconnected = new Set<string>(),
composeSendToAll?: boolean,
) {
const writes: string[] = [];
const context = {
@@ -34,6 +35,7 @@ function setup(
restoreState: restoredDisconnected.has(String(index)) ? 'restored-disconnected' : undefined,
})) },
isBroadcastEnabled: () => broadcast,
isComposeSendToAll: composeSendToAll === undefined ? undefined : () => composeSendToAll,
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
broadcastPasswordBypassRef: { current: broadcastPasswordBypass },
snippetExecutorsRef: { current: executors },
@@ -60,6 +62,53 @@ for (const broadcast of [false, true]) {
}
}
test('compose active-only sends to the focused session while toolbar broadcast stays on', async () => {
const { send, writes } = setup(
['connected', 'connected'], true, new Map(), new Set(), false, new Set(), false,
);
assert.equal(await send('command'), true);
assert.deepEqual(writes, ['0']);
});
test('compose active-only does not run peer executors while toolbar broadcast stays on', async () => {
const calls: Array<{ id: string; broadcast: boolean | undefined }> = [];
const executors = new Map<string, Executor>([
['0', async (_text, _immediate, options) => {
calls.push({ id: '0', broadcast: options?.broadcast });
return true;
}],
['1', async () => { calls.push({ id: '1', broadcast: undefined }); return true; }],
]);
assert.equal(
await setup(
['connected', 'connected'], true, executors, new Set(), false, new Set(), false,
).send('command'),
true,
);
assert.deepEqual(calls, [{ id: '0', broadcast: false }]);
});
test('compose send-to-all fans out while toolbar broadcast stays off', async () => {
const { send, writes } = setup(
['connected', 'connected'], false, new Map(), new Set(), false, new Set(), true,
);
assert.equal(await send('command'), true);
assert.deepEqual(writes, ['0', '1']);
});
test('compose send-to-all keeps a bypassed sensitive peer out of history', async () => {
const executors = new Map<string, Executor>([
['0', async () => true],
['1', async () => true],
]);
assert.equal(
await setup(
['connected', 'connected'], false, executors, new Set(['1']), true, new Set(), true,
).send('secret'),
false,
);
});
test('a disconnected broadcast peer cannot undo another successful fallback', async () => {
assert.equal(await setup(['connected', 'disconnected'], true).send('command'), true);
});
@@ -169,18 +218,43 @@ for (const result of [true, false, Promise.resolve(true), Promise.resolve(false)
sessionId: 'a', sessionRef: { current: 'a' },
executeSnippetCommand: () => result,
isTerminalSensitiveInputActive: () => false,
composeSendToAll: undefined as boolean | undefined,
isBroadcastEnabled: false,
};
runInNewContext(soloCode, context);
assert.equal(await context.send('command'), await result);
});
}
test('solo compose send uses the compose-bar target while toolbar broadcast stays on', async () => {
const calls: Array<boolean | undefined> = [];
const context = {
send: undefined as unknown as (text: string) => Promise<boolean>,
sessionId: 'a', sessionRef: { current: 'a' },
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean }) => {
calls.push(options?.broadcast);
return true;
},
isTerminalSensitiveInputActive: () => false,
composeSendToAll: false,
isBroadcastEnabled: true,
};
runInNewContext(soloCode, context);
assert.equal(await context.send('command'), true);
context.composeSendToAll = true;
context.isBroadcastEnabled = false;
assert.equal(await context.send('command'), true);
assert.deepEqual(calls, [false, true]);
});
test('solo missing sessions and sensitive input are excluded; rejection propagates to the compose handler', async () => {
const context = {
send: undefined as unknown as (text: string) => Promise<boolean>,
sessionId: 'a', sessionRef: { current: null as string | null },
executeSnippetCommand: async () => true,
isTerminalSensitiveInputActive: () => false,
composeSendToAll: undefined as boolean | undefined,
isBroadcastEnabled: false,
};
runInNewContext(soloCode, context);
assert.equal(await context.send('command'), false);
@@ -414,6 +414,8 @@ const WORKSPACE_CTX_KEYS = [
'onSetWorkspaceFocusedSession',
'onSplitSession',
'isBroadcastEnabled',
'isComposeSendToAll',
'onToggleComposeSendTarget',
'handleBroadcastInput',
'handleBroadcastInterruptPriorityChange',
'handleToggleWorkspaceComposeBar',
@@ -440,8 +442,10 @@ const WORKSPACE_CTX_KEYS = [
'onStartSessionDrag',
'onEndSessionDrag',
'isGlobalBroadcastEnabled',
'isGlobalComposeSendToAll',
'canUseGlobalBroadcast',
'onToggleGlobalBroadcast',
'onToggleGlobalComposeSendTarget',
] as const;
export function terminalLayerSidePanelStableCtxEqual(prev: Ctx, next: Ctx): boolean {
@@ -477,6 +481,10 @@ export function terminalLayerViewCtxEqual(prev: Ctx, next: Ctx): boolean {
if (prev.refocusTerminalSession !== next.refocusTerminalSession) return false;
if (prev.setIsComposeBarOpen !== next.setIsComposeBarOpen) return false;
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
if (prev.onToggleComposeSendTarget !== next.onToggleComposeSendTarget) return false;
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
if (prev.composeBarThemeColors !== next.composeBarThemeColors) return false;
if (prev.workspaceOuterRef !== next.workspaceOuterRef) return false;
// Use stable side-panel equal only: LIVE fields (cwd, theme focus, etc.) flow
+4
View File
@@ -76,9 +76,13 @@ export const terminalLayerAreEqual = (
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
prev.onToggleBroadcast === next.onToggleBroadcast &&
prev.isComposeSendToAll === next.isComposeSendToAll &&
prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget &&
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
prev.updateSnippets === next.updateSnippets &&
prev.updateSnippetPackages === next.updateSnippetPackages &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
+45
View File
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
composeSendTargetAfterToolbarToggle,
flipComposeSendTarget,
resolveTerminalBroadcastTargetIds,
shouldBroadcastDuringSensitivePrompt,
} from './terminalBroadcast.ts';
@@ -93,6 +95,49 @@ test('missing or hidden global sources do not fan out', () => {
);
});
test('compose send target follows toolbar broadcast, then flips on its own', () => {
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
assert.equal(composeSendTargetAfterToolbarToggle(false), 'active');
assert.equal(flipComposeSendTarget('all'), 'active');
assert.equal(flipComposeSendTarget('active'), 'all');
// An independent flip does not rewrite the toolbar-derived default.
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
});
test('compose peer fan-out can reach orphan peers while toolbar broadcast is off', () => {
const sessions = [
session('source'),
session('peer'),
session('hidden-orphan', { hiddenFromTabs: true }),
];
assert.deepEqual(
resolveTerminalBroadcastTargetIds({
sessions,
sourceSessionId: 'source',
globalBroadcastEnabled: false,
includeGlobalPeersWithoutToolbar: true,
}),
['peer'],
);
assert.deepEqual(
resolveTerminalBroadcastTargetIds({
sessions,
sourceSessionId: 'source',
globalBroadcastEnabled: false,
}),
[],
);
assert.deepEqual(
resolveTerminalBroadcastTargetIds({
sessions: [session('hidden-source', { hiddenFromTabs: true }), session('peer')],
sourceSessionId: 'hidden-source',
globalBroadcastEnabled: false,
includeGlobalPeersWithoutToolbar: true,
}),
[],
);
});
test('sensitive prompts pause broadcasting unless the password bypass is on', () => {
assert.equal(
shouldBroadcastDuringSensitivePrompt({
+23 -1
View File
@@ -15,16 +15,37 @@ export function shouldBroadcastDuringSensitivePrompt(input: {
return !input.sensitivePromptActive || input.broadcastPasswordBypass;
}
export type ComposeSendTarget = 'all' | 'active';
/**
* Toolbar broadcast is the keystroke fan-out switch. Turning it on or off
* resets the compose-bar send target to match. A later compose-bar flip does
* not move the toolbar switch.
*/
export function composeSendTargetAfterToolbarToggle(toolbarEnabled: boolean): ComposeSendTarget {
return toolbarEnabled ? 'all' : 'active';
}
export function flipComposeSendTarget(target: ComposeSendTarget): ComposeSendTarget {
return target === 'all' ? 'active' : 'all';
}
export function resolveTerminalBroadcastTargetIds({
sessions,
sourceSessionId,
globalBroadcastEnabled,
directTargetSessionIds,
includeGlobalPeersWithoutToolbar = false,
}: {
sessions: readonly BroadcastSession[];
sourceSessionId: string;
globalBroadcastEnabled: boolean;
directTargetSessionIds?: readonly string[];
/**
* Compose-bar "send to all" can target orphan peers while the toolbar
* (global) broadcast switch stays off. Keystroke fan-out leaves this false.
*/
includeGlobalPeersWithoutToolbar?: boolean;
}): string[] {
if (directTargetSessionIds) {
const directTargetIds = new Set(directTargetSessionIds);
@@ -45,7 +66,8 @@ export function resolveTerminalBroadcastTargetIds({
.map((session) => session.id);
}
if (!globalBroadcastEnabled || sourceSession.hiddenFromTabs) return [];
if (sourceSession.hiddenFromTabs) return [];
if (!globalBroadcastEnabled && !includeGlobalPeersWithoutToolbar) return [];
return sessions
.filter((session) => (