Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 12 additions & 11 deletions docs/users/reference/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ This document lists the available keyboard shortcuts in Qwen Code.

## General

| Shortcut | Description |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+O` | Toggle compact mode (hide/show tool output and thinking). |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+T` | Toggle the display of tool descriptions. |
| `Alt/Option+M` | Toggle Markdown output between rich rendered previews and raw/source mode. On macOS, the terminal must send Option as Meta. |
| `Shift+Tab` (`Tab` on Windows) | Cycle approval modes (`plan` → `default` → `auto-edit` → `yolo`) |
| Shortcut | Description |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+O` | Toggle compact mode (hide/show tool output and thinking). |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+T` | Toggle the display of tool descriptions. |
| `Ctrl+B` | While a foreground shell command is running: promote it to a background task. The child keeps running, the agent's turn unblocks, and the shell appears in `/tasks` + the Background tasks dialog. No-op when no shell is executing — Ctrl+B then falls through to its prompt-area binding (cursor-left). |
| `Alt/Option+M` | Toggle Markdown output between rich rendered previews and raw/source mode. On macOS, the terminal must send Option as Meta. |
| `Shift+Tab` (`Tab` on Windows) | Cycle approval modes (`plan` → `default` → `auto-edit` → `yolo`) |

## Input Prompt

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export enum Command {
RETRY_LAST = 'retryLast',
TOGGLE_COMPACT_MODE = 'toggleCompactMode',
TOGGLE_RENDER_MODE = 'toggleRenderMode',
/**
* Promote the running foreground shell command to a background task.
* The child process keeps running and the agent's turn unblocks; the
* shell becomes a regular `BackgroundShellEntry` visible in `/tasks`,
* the Background tasks dialog, and stoppable via `task_stop`.
* No-op when no foreground shell is currently executing.
*/
PROMOTE_SHELL_TO_BACKGROUND = 'promoteShellToBackground',

// Shell commands
REVERSE_SEARCH = 'reverseSearch',
Expand Down Expand Up @@ -176,6 +184,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.RETRY_LAST]: [{ key: 'y', ctrl: true }],
[Command.TOGGLE_COMPACT_MODE]: [{ key: 'o', ctrl: true }],
[Command.TOGGLE_RENDER_MODE]: [{ key: 'm', meta: true }],
[Command.PROMOTE_SHELL_TO_BACKGROUND]: [{ key: 'b', ctrl: true }],

// Shell commands
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1840,6 +1840,118 @@ describe('AppContainer State Management', () => {

vi.useRealTimers();
});

it('Ctrl+B promotes the running foreground shell tool call (#3831 PR-3)', () => {
// E2E for the keybind layer: Ctrl+B during an executing shell
// tool call must call abort({ kind: 'background' }) on the
// tool call's promoteAbortController. ShellExecutionService +
// shell.ts (covered by PR-1 / PR-2 unit tests) translate the
// abort reason into a registry-registered BackgroundShellEntry.
const promoteAc = new AbortController();
const abortSpy = vi.spyOn(promoteAc, 'abort');
const executingShell = {
status: 'executing',
request: { callId: 'call-shell-1', name: 'run_shell_command' },
promoteAbortController: promoteAc,
};
mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
pendingToolCalls: [executingShell],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});

render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);

// Find the global keypress handler. AppContainer registers
// multiple via useKeypress (text buffer, dialogs, etc.); the
// global one is identifiable by its body — it references the
// PROMOTE_SHELL_TO_BACKGROUND command we just added.
const handleKeypress = mockedUseKeypress.mock.calls
.map((call) => call[0])
.reverse()
.find(
(handler): handler is (key: Key) => void =>
typeof handler === 'function' &&
handler.toString().includes('PROMOTE_SHELL_TO_BACKGROUND'),
) as ((key: Key) => void) | undefined;
expect(handleKeypress).toBeDefined();

// Fire Ctrl+B.
const ctrlBKey = {
name: 'b',
ctrl: true,
meta: false,
shift: false,
paste: false,
sequence: '\x02',
} as unknown as Key;
Comment thread
wenshao marked this conversation as resolved.
Resolved
handleKeypress!(ctrlBKey);

expect(abortSpy).toHaveBeenCalledTimes(1);
const reason = abortSpy.mock.calls[0][0];
expect(reason).toEqual({ kind: 'background' });
});

it('Ctrl+B is a no-op when no foreground shell is currently executing', () => {
Comment thread
wenshao marked this conversation as resolved.
Resolved
// Pin the safety contract: pressing Ctrl+B mid-prompt with no
// running shell must NOT throw, must NOT mistakenly fire any
// promote on a non-shell tool. Without the
// `promoteAbortController !== undefined` guard, the find would
// pick up a non-shell executing tool whose schema doesn't even
// expose a promote AC.
mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
pendingToolCalls: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});

render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);

const handleKeypress = mockedUseKeypress.mock.calls
.map((call) => call[0])
.reverse()
.find(
(handler): handler is (key: Key) => void =>
typeof handler === 'function' &&
handler.toString().includes('PROMOTE_SHELL_TO_BACKGROUND'),
) as ((key: Key) => void) | undefined;
expect(handleKeypress).toBeDefined();

const ctrlBKey = {
name: 'b',
ctrl: true,
meta: false,
shift: false,
paste: false,
sequence: '\x02',
} as unknown as Key;
// No-op: no throw.
expect(() => handleKeypress!(ctrlBKey)).not.toThrow();
});
});

describe('Model Dialog Integration', () => {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import { clearScreen } from '../utils/stdioHelpers.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import type { TrackedExecutingToolCall } from './hooks/useReactToolScheduler.js';
import { useVim } from './hooks/vim.js';
import { isBtwCommand, isSlashCommand } from './utils/commandUtils.js';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
Expand Down Expand Up @@ -2235,6 +2236,35 @@ export const AppContainer = (props: AppContainerProps) => {
setCompactMode(newValue);
void settings.setValue(SettingScope.User, 'ui.compactMode', newValue);
refreshStatic();
} else if (keyMatchers[Command.PROMOTE_SHELL_TO_BACKGROUND](key)) {
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] embeddedShellFocused guard missing for the new PROMOTE_SHELL_TO_BACKGROUND branch.

The Escape handler at this same level has if (embeddedShellFocused) { return; } to avoid intercepting keys during embedded PTY sessions, but the new Ctrl+B handler lacks this guard. When the user is in an embedded PTY shell and presses Ctrl+B (bash's cursor-backward key):

  • If a shell tool call is executing → the global handler incorrectly promotes it instead of forwarding Ctrl+B to the PTY
  • If no shell is executing → .find() and debugLogger.debug() still run unnecessarily
Suggested change
} else if (keyMatchers[Command.PROMOTE_SHELL_TO_BACKGROUND](key)) {
} else if (keyMatchers[Command.PROMOTE_SHELL_TO_BACKGROUND](key)) {
if (embeddedShellFocused) {
return;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// Ctrl+B: promote a running foreground shell command to a
// background task (#3831). The child keeps running, the
// agent's turn unblocks, and the shell becomes a regular
// BackgroundShellEntry visible in `/tasks` + the dialog and
// stoppable via `task_stop`.
//
// Read from the ref (NOT the destructured `pendingToolCalls`)
// so we don't have to put `pendingToolCalls` in the deps
// array — that would re-bind the keypress handler on every
// tool-call status update, which is noisy.
//
// No-op when no foreground shell is currently executing OR
// the executing tool call is non-shell (no
// `promoteAbortController` projected). Falling through in
// the no-op case is intentional: while the agent is idle the
// input layer's own Ctrl+B handler (cursor-left in the
// prompt) should still fire as before.
const executingShell = pendingToolCallsRef.current.find(
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
(tc) =>
tc.status === 'executing' &&
tc.promoteAbortController !== undefined,
) as TrackedExecutingToolCall | undefined;
if (executingShell?.promoteAbortController) {
executingShell.promoteAbortController.abort({
Comment thread
wenshao marked this conversation as resolved.
Resolved
kind: 'background',
});
return;
}
}
},
[
Expand Down
25 changes: 24 additions & 1 deletion packages/cli/src/ui/hooks/useReactToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ export type TrackedWaitingToolCall = WaitingToolCall & {
export type TrackedExecutingToolCall = ExecutingToolCall & {
responseSubmittedToGemini?: boolean;
pid?: number;
/**
* AbortController exposed by the shell tool so a UI surface (Ctrl+B
* keybind in `AppContainer`) can promote a running foreground shell
* to background mid-flight. Set by `coreToolScheduler` via the
* shell tool's `setPromoteAbortControllerCallback` when the shell
* branch enters the executing state. Undefined for non-shell tools.
*
* Aborting with reason `{ kind: 'background', shellId }` is the
Comment thread
wenshao marked this conversation as resolved.
Resolved
* trigger; `ShellExecutionService` resolves with `promoted: true`
* and `shell.ts` registers a `BackgroundShellEntry` — the child
* keeps running.
*/
promoteAbortController?: AbortController;
Comment thread
wenshao marked this conversation as resolved.
Resolved
};
export type TrackedCompletedToolCall = CompletedToolCall & {
responseSubmittedToGemini?: boolean;
Expand Down Expand Up @@ -114,12 +127,21 @@ export function useReactToolScheduler(
existingTrackedCall?.responseSubmittedToGemini ?? false;

if (coreTc.status === 'executing') {
const executingCore = coreTc as ExecutingToolCall;
return {
...coreTc,
responseSubmittedToGemini,
liveOutput: (existingTrackedCall as TrackedExecutingToolCall)
?.liveOutput,
pid: (coreTc as ExecutingToolCall).pid,
pid: executingCore.pid,
// Project the promote AbortController so the Ctrl+B
// keybind handler in AppContainer can find it via the
// executing tool call without re-plumbing through the
// scheduler. Set on shell-tool branches by
// `coreToolScheduler` when shell.ts fires its
// `setPromoteAbortControllerCallback`. Undefined for
// non-shell tools.
promoteAbortController: executingCore.promoteAbortController,
};
Comment thread
wenshao marked this conversation as resolved.
Resolved
}

Expand All @@ -130,6 +152,7 @@ export function useReactToolScheduler(
responseSubmittedToGemini,
liveOutput: undefined,
pid: undefined,
promoteAbortController: undefined,
};
}),
);
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/ui/keyMatchers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ describe('keyMatchers', () => {
[Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y',
[Command.TOGGLE_COMPACT_MODE]: (key: Key) => key.ctrl && key.name === 'o',
[Command.TOGGLE_RENDER_MODE]: (key: Key) => key.meta && key.name === 'm',
[Command.PROMOTE_SHELL_TO_BACKGROUND]: (key: Key) =>
key.ctrl && key.name === 'b',
[Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r',
[Command.SUBMIT_REVERSE_SEARCH]: (key: Key) =>
key.name === 'return' && !key.ctrl,
Expand Down Expand Up @@ -297,6 +299,17 @@ describe('keyMatchers', () => {
createKey('', { sequence: 'µ', paste: true }),
],
},
{
command: Command.PROMOTE_SHELL_TO_BACKGROUND,
positive: [createKey('b', { ctrl: true })],
// No bare `b`, no Ctrl+other, no meta+b — Ctrl is required so
// typing `b` mid-prompt isn't accidentally swallowed.
negative: [
createKey('b'),
createKey('b', { meta: true }),
createKey('a', { ctrl: true }),
],
},
];

describe('Data-driven key binding matches original logic', () => {
Expand Down
Loading