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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/config/keybinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ When documentation shows `Ctrl`, it means:
| Increase thinking level | `Ctrl+Shift+]` |
| Decrease thinking level | `Ctrl+Shift+[` |

## Background Processes

In the desktop app and the VS Code extension, `Ctrl+Shift+J` focuses the background processes strip (again, or `Esc`, collapses it). On a focused row, `↓`/`↑` move, `Enter` opens the output, and `Backspace` terminates the process.

## Workspaces

| Action | Shortcut |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from "react";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Terminal, X, Loader2, FileText } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip";
import { cn } from "@/common/lib/utils";
Expand All @@ -11,6 +11,44 @@ import {
} from "@/browser/stores/BackgroundBashStore";
import { useBackgroundBashActions } from "@/browser/contexts/BackgroundBashContext";
import { useChatHostContext } from "@/browser/contexts/ChatHostContext";
import { stopKeyboardPropagation } from "@/browser/utils/events";
import {
KEYBINDS,
formatKeybind,
isDialogOpen,
isTerminalFocused,
matchesKeybind,
} from "@/browser/utils/ui/keybinds";
import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events";

const SHORTCUT_HINT_CLASS =
"ml-1.5 font-mono text-[10px] opacity-70 [@media(max-width:768px)]:hidden";

/** Focuses the expanded strip's row at `index`, clamped to the rows that exist. */
function focusProcessRow(list: HTMLElement | null, index: number): void {
const rows = list?.querySelectorAll<HTMLElement>("[data-process-row]");
if (!rows || rows.length === 0) return;
rows[Math.max(0, Math.min(index, rows.length - 1))].focus();
}

/** Refocuses the origin (usually the composer), else `fallback` (palette runs leave none). */
function restoreFocus(
ref: React.MutableRefObject<HTMLElement | null>,
fallback?: Element | null
): void {
const target = ref.current;
ref.current = null;
if (target?.isConnected) {
target.focus();
} else if (fallback instanceof HTMLElement) {
fallback.focus();
}
}

/** The strip's expand/collapse button, which ChatInputDecoration renders just before the list. */
function stripToggle(list: HTMLElement | null): Element | null {
return list?.previousElementSibling ?? null;
}

/**
* Truncate script to reasonable display length.
Expand Down Expand Up @@ -41,6 +79,17 @@ export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps>
const { terminate } = useBackgroundBashActions();
// Hosts without the output dialog hide the View output action.
const canViewOutput = useChatHostContext().uiSupport.backgroundBashOutput === "supported";
// Keyboard access (#5197): a roving tabindex over the rows, opened by FOCUS_BACKGROUND_PROCESSES.
const listRef = useRef<HTMLDivElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [focusRequest, setFocusRequest] = useState(0);
// The focused row; if its process leaves the list, a neighbor takes focus instead of the body.
const focusedRowRef = useRef<{ processId: string; index: number } | null>(null);
// The output dialog has no trigger to return focus to, so a keyboard open records its row.
const refocusAfterOutputRef = useRef<{ row: HTMLElement; index: number } | null>(null);
// Under immersive review desktop keeps the chat pane mounted but inert: ignore requests then.
const rootRef = useRef<HTMLDivElement>(null);

// Keep running processes visible, plus exited processes whose monitor matched but whose
// wake has not been delivered yet — otherwise a one-shot watcher that matched and exited
Expand All @@ -59,6 +108,127 @@ export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps>
return () => clearInterval(interval);
}, [isExpanded, hasRunning]);

// Listens only while there is something to focus, so the chord is not consumed otherwise.
useEffect(() => {
if (count === 0) return;
const open = () => {
const active = document.activeElement;
if (
active instanceof HTMLElement &&
active !== document.body &&
!listRef.current?.contains(active)
) {
returnFocusRef.current = active;
Comment thread
ThomasK33 marked this conversation as resolved.
}
setActiveIndex(0);
setIsExpanded(true);
setFocusRequest((request) => request + 1);
};
const onKeyDown = (event: KeyboardEvent) => {
if (!matchesKeybind(event, KEYBINDS.FOCUS_BACKGROUND_PROCESSES) || isDialogOpen()) return;
Comment thread
ThomasK33 marked this conversation as resolved.
Comment thread
ThomasK33 marked this conversation as resolved.
// isDialogOpen() misses the command palette (cmdk); focus must not move behind it.
if (event.target instanceof Element && event.target.closest("[cmdk-root]")) return;
Comment thread
ThomasK33 marked this conversation as resolved.
if (isTerminalFocused(event.target)) return; // the terminal owns its keystrokes
if (rootRef.current?.closest("[inert]")) return;
event.preventDefault();
Comment thread
ThomasK33 marked this conversation as resolved.
if (event.repeat) return;
if (listRef.current?.contains(document.activeElement)) {
const toggle = stripToggle(listRef.current);
setIsExpanded(false);
restoreFocus(returnFocusRef, toggle);
} else {
open();
}
};
const onFocusRequest = (event: Event) => {
const { detail } = event as CustomEvent<
CustomEventPayloads[typeof CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES]
>;
if (detail.workspaceId !== props.workspaceId || rootRef.current?.closest("[inert]")) return;
detail.handled = true;
open();
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, onFocusRequest);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, onFocusRequest);
};
}, [count, props.workspaceId]);

useEffect(() => {
if (focusRequest > 0) focusProcessRow(listRef.current, 0);
}, [focusRequest]);

const rowIdsKey = visibleProcesses.map((proc) => proc.id).join("\u0000");
useEffect(() => {
const removed = focusedRowRef.current;
if (removed == null || rowIdsKey.split("\u0000").includes(removed.processId)) return;
focusedRowRef.current = null;
// Only when the removal dropped focus, not when the user moved it elsewhere meanwhile.
if (document.activeElement != null && document.activeElement !== document.body) return;
if (listRef.current) {
focusProcessRow(listRef.current, removed.index);
} else {
restoreFocus(returnFocusRef);
}
}, [rowIdsKey]);

useEffect(() => {
if (viewingProcessId != null) return;
const target = refocusAfterOutputRef.current;
refocusAfterOutputRef.current = null;
if (target == null) return;
// The row's process may have left the list while the dialog was open.
if (target.row.isConnected) {
target.row.focus();
} else if (listRef.current) {
focusProcessRow(listRef.current, target.index);
} else {
restoreFocus(returnFocusRef);
}
}, [viewingProcessId]);

const handleListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const row =
event.target instanceof HTMLElement
? event.target.closest<HTMLElement>("[data-process-row]")
: null;
const index = Number(row?.dataset.processIndex ?? -1);
const proc = visibleProcesses[index];
if (!row || !proc) return;
if (matchesKeybind(event, KEYBINDS.CANCEL)) {
const toggle = stripToggle(listRef.current);
setIsExpanded(false);
restoreFocus(returnFocusRef, toggle);
} else if (matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_NEXT)) {
focusProcessRow(listRef.current, index + 1);
} else if (matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_PREV)) {
focusProcessRow(listRef.current, index - 1);
} else if (
// Enter on a row button keeps its own meaning.
event.target === row &&
matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_VIEW_OUTPUT) &&
proc.synthesized !== true &&
canViewOutput &&
!terminatingIds.has(proc.id)
) {
refocusAfterOutputRef.current = { row, index };
setViewingProcessId(proc.id);
} else if (
matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_TERMINATE) &&
proc.status === "running" &&
!terminatingIds.has(proc.id)
) {
terminate(proc.id);
} else {
return;
}
event.preventDefault();
// Keeps window-level handlers (e.g. Escape interrupting the stream) from also acting.
stopKeyboardPropagation(event);
};

const handleViewOutput = useCallback((processId: string, event: React.MouseEvent) => {
event.stopPropagation();
setViewingProcessId(processId);
Expand All @@ -82,12 +252,14 @@ export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps>
}

return (
<>
<div ref={rootRef} className="contents">
{count > 0 && (
<ChatInputDecoration
expanded={isExpanded}
onToggle={handleToggle}
contentClassName="max-h-48 space-y-1.5 overflow-y-auto py-2"
contentRef={listRef}
onContentKeyDown={handleListKeyDown}
summary={
<>
<Terminal className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
Expand All @@ -99,13 +271,27 @@ export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps>
</>
}
renderExpanded={() =>
visibleProcesses.map((proc) => {
visibleProcesses.map((proc, index) => {
const isTerminating = terminatingIds.has(proc.id);
return (
<div
key={proc.id}
data-process-row=""
data-process-index={index}
tabIndex={index === Math.min(activeIndex, count - 1) ? 0 : -1}
Comment thread
ThomasK33 marked this conversation as resolved.
onFocus={() => {
setActiveIndex(index);
focusedRowRef.current = { processId: proc.id, index };
}}
onBlur={(event) => {
// Only a removed row keeps the record, for the removal effect above.
if (event.relatedTarget != null || event.currentTarget.isConnected) {
focusedRowRef.current = null;
}
}}
className={cn(
"hover:bg-hover flex items-center justify-between gap-3 rounded px-2 py-1.5",
"focus-visible:ring-accent outline-none focus-visible:ring-1",
"transition-colors",
isTerminating && "pointer-events-none opacity-50"
)}
Expand Down Expand Up @@ -170,7 +356,12 @@ export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps>
<FileText size={14} />
</button>
</TooltipTrigger>
<TooltipContent>View output</TooltipContent>
<TooltipContent>
View output
<kbd className={SHORTCUT_HINT_CLASS}>
{formatKeybind(KEYBINDS.BACKGROUND_PROCESS_VIEW_OUTPUT)}
</kbd>
</TooltipContent>
</Tooltip>
)}
{/* Nothing to terminate once the process has exited */}
Expand All @@ -193,7 +384,12 @@ export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps>
)}
</button>
</TooltipTrigger>
<TooltipContent>Terminate process</TooltipContent>
<TooltipContent>
Terminate process
<kbd className={SHORTCUT_HINT_CLASS}>
{formatKeybind(KEYBINDS.BACKGROUND_PROCESS_TERMINATE)}
</kbd>
</TooltipContent>
</Tooltip>
)}
</div>
Expand All @@ -218,6 +414,6 @@ export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps>
script={viewingProcess?.script}
/>
)}
</>
</div>
);
};
13 changes: 11 additions & 2 deletions src/browser/components/ChatPane/ChatInputDecoration.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import type { KeyboardEventHandler, ReactNode, Ref } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { cn } from "@/common/lib/utils";
import { CHAT_DOCK_GUTTER_CLASS } from "@/constants/layout";
Expand All @@ -20,6 +20,9 @@ interface ChatInputDecorationProps {
* whose `onToggle` navigates elsewhere instead of expanding inline.
*/
trailingIcon?: ReactNode;
/** Ref and key handler for the expanded content, for decorations with keyboard navigation. */
contentRef?: Ref<HTMLDivElement>;
onContentKeyDown?: KeyboardEventHandler<HTMLDivElement>;
}

// Keep collapsible decorations aligned with the chat input gutter so swapping
Expand Down Expand Up @@ -58,7 +61,13 @@ export function ChatInputDecoration(props: ChatInputDecorationProps) {
</div>
</button>
{props.expanded && props.renderExpanded && (
<div className={cn(columnWidthClass, props.contentClassName)}>{props.renderExpanded()}</div>
<div
ref={props.contentRef}
onKeyDown={props.onContentKeyDown}
className={cn(columnWidthClass, props.contentClassName)}
>
{props.renderExpanded()}
</div>
)}
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions src/browser/features/Settings/Sections/KeybindsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ const KEYBIND_LABELS: Record<keyof typeof KEYBINDS, string> = {
SEND_QUEUED_MESSAGE_NOW: "Send queued message now",
SEND_HELD_INPUT: "Send oldest unsent message",
DISCARD_HELD_INPUT: "Discard oldest unsent message",
FOCUS_BACKGROUND_PROCESSES: "Focus background processes",
BACKGROUND_PROCESS_NEXT: "Next background process",
BACKGROUND_PROCESS_PREV: "Previous background process",
BACKGROUND_PROCESS_VIEW_OUTPUT: "View background process output",
BACKGROUND_PROCESS_TERMINATE: "Terminate background process",
SEND_MESSAGE_AFTER_TURN: "Send after turn",
NEW_LINE: "Insert newline",
CANCEL: "Cancel / Close modal",
Expand Down Expand Up @@ -193,6 +198,7 @@ const KEYBIND_GROUPS: Array<{
"TOGGLE_VOICE_INPUT",
"SHOW_LAST_PROMPT",
"REVEAL_LAST_PROMPT",
"FOCUS_BACKGROUND_PROCESSES",
],
},
{
Expand Down
1 change: 1 addition & 0 deletions src/browser/utils/commandIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const CommandIds = {
chatJumpBottom: () => "chat:jumpBottom" as const,
chatVoiceInput: () => "chat:voiceInput" as const,
chatRunLatestPlanAction: () => "chat:runLatestPlanAction" as const,
chatFocusBackgroundProcesses: () => "chat:focusBackgroundProcesses" as const,
chatClearTimingStats: () => "chat:clearTimingStats" as const,

// Mode commands
Expand Down
31 changes: 31 additions & 0 deletions src/browser/utils/commands/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1925,6 +1925,37 @@ describe("Implement Latest Plan palette action (#4963)", () => {
});
});

describe("Focus Background Processes palette action (#5197)", () => {
test("asks the selected workspace's strip to take focus, and says so when there is none", async () => {
await withTestWindow(async () => {
const action = getActions().find(
(candidate) => candidate.id === CommandIds.chatFocusBackgroundProcesses()
);
expect(action).toBeDefined();
const events = collectCommandEvents();
try {
// No strip handled the request (no running processes).
await action!.run();
expect(events.receivedToasts.map((toast) => toast.type)).toEqual(["error"]);

const requests: string[] = [];
const strip = (event: Event) => {
const { detail } = event as CustomEvent<{ workspaceId: string; handled: boolean }>;
requests.push(detail.workspaceId);
detail.handled = true;
};
window.addEventListener(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, strip);
await action!.run();
window.removeEventListener(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, strip);
expect(requests).toEqual(["w1"]);
expect(events.receivedToasts).toHaveLength(1);
} finally {
events.dispose();
}
});
});
});

test("artifact palette commands follow the experiment and pin the typed path", async () => {
expect(getActions().some((action) => action.id === CommandIds.navOpenFileAsArtifact())).toBe(
false
Expand Down
Loading
Loading