Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/shortcuts-overlay-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nanocollective/nanocoder": patch
---

Fixed the `?` keyboard shortcuts overlay running off the bottom of short terminals. It now shows only as many shortcuts as fit, keeps the close hint and lower border on screen, and scrolls with Up/Down and PgUp/PgDn. While it is open, the live task list and other status panels around the prompt are hidden so the overlay has room, and PgUp/PgDn scroll only the overlay, not the chat behind it. Closes #1813.
2 changes: 1 addition & 1 deletion docs/features/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ sidebar_order: 11

This page covers the main chat input and common interactive views. Some specialised screens may show additional inline controls.

Press `?` in an empty prompt to open an in-app overlay listing the main shortcuts; press `?` or Esc to close it.
Press `?` in an empty prompt to open an in-app overlay listing the main shortcuts; press `?` or Esc to close it. On a short terminal the overlay shows as many shortcuts as fit; scroll the rest with Up/Down or PgUp/PgDn.

## Submitting & Multi-line Input

Expand Down
39 changes: 27 additions & 12 deletions source/app/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export interface ChatInputProps {
/** Follow-up command offered in the empty prompt after a turn. */
suggestedCommand?: string | null;
onDismissSuggestion?: () => void;
/**
* Whether UserInput's `?` shortcuts overlay is open. It sizes itself as if
* it had the footer to itself, so the live panels above the prompt hide.
*/
shortcutsOverlayOpen?: boolean;
onShortcutsOverlayChange?: (open: boolean) => void;
}

/**
Expand Down Expand Up @@ -168,6 +174,8 @@ export function ChatInput({
isSaving,
suggestedCommand,
onDismissSuggestion,
shortcutsOverlayOpen = false,
onShortcutsOverlayChange,
}: ChatInputProps): React.ReactElement {
const {colors} = useTheme();
const activeToolCall = pendingToolCalls[currentToolIndex];
Expand All @@ -178,6 +186,7 @@ export function ChatInput({
activeToolCall.function.name !== 'agent';

const footerPadding = fullscreen ? 2 : 0;
const showLivePanels = !shortcutsOverlayOpen;

// Memoised: DevelopmentModeIndicator is memo()'d, so handing it a fresh
// object on every keystroke would re-render it for nothing.
Expand All @@ -201,26 +210,31 @@ export function ChatInput({
return (
<Box flexDirection="column" marginLeft={fullscreen ? 0 : -1}>
{/* Live compact tool counts - running tally during auto-execution */}
{compactToolCounts && Object.keys(compactToolCounts).length > 0 && (
<Box paddingLeft={footerPadding}>
<LiveCompactCounts counts={compactToolCounts} />
</Box>
)}
{showLivePanels &&
compactToolCounts &&
Object.keys(compactToolCounts).length > 0 && (
<Box paddingLeft={footerPadding}>
<LiveCompactCounts counts={compactToolCounts} />
</Box>
)}

{/* Live task list - updates in-place below tool counts, above spinner */}
{showTaskList && liveTaskList && liveTaskList.length > 0 && (
<Box paddingLeft={footerPadding}>
<TaskListDisplay tasks={liveTaskList} title="Tasks" />
</Box>
)}
{showLivePanels &&
showTaskList &&
liveTaskList &&
liveTaskList.length > 0 && (
<Box paddingLeft={footerPadding}>
<TaskListDisplay tasks={liveTaskList} title="Tasks" />
</Box>
)}

{isCancelling && (
{showLivePanels && isCancelling && (
<Box paddingLeft={footerPadding}>
<CancellingIndicator />
</Box>
)}

{showToolExecutionIndicator && (
{showLivePanels && showToolExecutionIndicator && (
<Box paddingLeft={footerPadding}>
<ToolExecutionIndicator
toolName={activeToolCall.function.name}
Expand Down Expand Up @@ -303,6 +317,7 @@ export function ChatInput({
isSaving={isSaving}
suggestedCommand={suggestedCommand}
onDismissSuggestion={onDismissSuggestion}
onShortcutsOverlayChange={onShortcutsOverlayChange}
/>
) : /* Client Missing */
mcpInitialized && !client ? (
Expand Down
98 changes: 97 additions & 1 deletion source/app/sections/interactive-app.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React from 'react';
import {DELAY_COMMAND_COMPLETE_MS} from '@/constants';
import {useUserMessageQueue} from '@/hooks/useUserMessageQueue';
import stripAnsi from 'strip-ansi';
import type {Task} from '@/tools/tasks/types';
import type {Message} from '@/types';
import {renderWithTheme} from '../../test-utils/render-with-theme.js';
import {InteractiveApp} from './interactive-app.js';
Expand Down Expand Up @@ -55,6 +56,7 @@ interface Overrides {
currentSessionId?: string | null;
toolManager?: unknown;
queuedMessages?: Array<{id: string; message: string; displayValue: string}>;
liveTaskList?: Task[] | null;
handleUserSubmit?: (message: string) => Promise<void>;
drainNextMessage?: (
dispatch: (message: {id: string; message: string; displayValue: string}) =>
Expand Down Expand Up @@ -105,7 +107,7 @@ function makeProps(o: Overrides = {}) {
sessionName: '',
compactToolCounts: null,
compactToolDisplay: false,
liveTaskList: null,
liveTaskList: o.liveTaskList ?? null,
showTaskList: true,
taskListHasUnread: false,
toggleTaskList: noop,
Expand Down Expand Up @@ -1193,6 +1195,100 @@ test('ChatInput is NOT rendered while plan review bar is showing', t => {
t.notRegex(output, /What now\?/);
});

// ============================================================================
// Shortcuts overlay
// ============================================================================

// The overlay sizes itself from process.stdout.rows; restore it afterwards so
// a later test never renders for this test's terminal size.
const withTerminalRows = async (rows: number, run: () => Promise<void>) => {
const originalRows = process.stdout.rows;
process.stdout.rows = rows;
try {
await run();
} finally {
process.stdout.rows = originalRows;
}
};

const frameText = (lastFrame: () => string | undefined) =>
stripAnsi(lastFrame() ?? '');

const makeTask = (n: number): Task => ({
id: `task-${n}`,
title: `Live task ${n}`,
status: 'pending',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
});

test.serial('shortcuts overlay keeps its close hint on screen above a live task list', async t => {
await withTerminalRows(18, async () => {
const {stdin, lastFrame, unmount} = renderWithTheme(
<InteractiveApp
{...makeProps({
startChat: true,
client: {},
altScreenActive: true,
liveTaskList: [1, 2, 3].map(makeTask),
})}
/>,
);
await waitForCondition(() => /Live task 3/.test(frameText(lastFrame)));

stdin.write('?');
await waitForCondition(() =>
/Keyboard Shortcuts/.test(frameText(lastFrame)),
);
const frame = frameText(lastFrame);
t.regex(frame, /\? or Esc to close/);
t.regex(frame, /╰─+╯/);
t.regex(frame, /▶ normal/);
t.notRegex(frame, /Live task/);

stdin.write('?');
await waitForCondition(() => /Live task 3/.test(frameText(lastFrame)));
unmount();
});
});

test.serial('PgUp/PgDn scroll the shortcuts overlay but not the transcript behind it', async t => {
await withTerminalRows(18, async () => {
const transcript = Array.from({length: 40}, (_, i) => (
<Text key={`line-${i}`}>{`transcript line ${i}`}</Text>
));
const {stdin, lastFrame, unmount} = renderWithTheme(
<InteractiveApp
{...makeProps({
startChat: true,
client: {},
altScreenActive: true,
chatComponents: transcript,
})}
/>,
);
await waitForCondition(() =>
/transcript line 39/.test(frameText(lastFrame)),
);

stdin.write('?');
await waitForCondition(() => /1-7 of 18/.test(frameText(lastFrame)));
stdin.write('\x1B[6~');
await waitForCondition(() => /8-14 of 18/.test(frameText(lastFrame)));
stdin.write('\x1B[5~');
await waitForCondition(() => /1-7 of 18/.test(frameText(lastFrame)));

stdin.write('?');
await waitForCondition(
() => !/Keyboard Shortcuts/.test(frameText(lastFrame)),
);
const frame = frameText(lastFrame);
t.notRegex(frame, /new output returns to bottom/);
t.regex(frame, /transcript line 39/);
unmount();
});
});

// FileExplorer/IdeSelector start watchers that keep the event loop alive
// past test completion. Force-exit so the spec doesn't time out.
test.after.always(() => {
Expand Down
19 changes: 16 additions & 3 deletions source/app/sections/interactive-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,10 @@ export function InteractiveApp({
// with Static + native scrollback.
const fullscreen = altScreenActive;
const terminalRows = useTerminalRows();
// UserInput's `?` overlay takes over PgUp/PgDn and sizes itself as if it
// had the footer to itself, so the transcript stops paging and the rows
// around the composer hide while it is open.
const [shortcutsOverlayOpen, setShortcutsOverlayOpen] = React.useState(false);
const artifactRefreshKey = `${appState.isConversationComplete}:${
appState.planReviewState?.show ?? false
}:${appState.liveTaskList?.map(task => `${task.id}:${task.status}`).join(',') ?? ''}`;
Expand Down Expand Up @@ -503,7 +507,8 @@ export function InteractiveApp({
scrollActive={
!showModalSelectors &&
!appState.isExplorerMode &&
!appState.isIdeSelectionMode
!appState.isIdeSelectionMode &&
!shortcutsOverlayOpen
}
/>

Expand Down Expand Up @@ -698,14 +703,21 @@ export function InteractiveApp({
isSaving={isSaving}
suggestedCommand={suggestedCommand}
onDismissSuggestion={() => setSuggestedCommand(null)}
shortcutsOverlayOpen={shortcutsOverlayOpen}
onShortcutsOverlayChange={setShortcutsOverlayOpen}
/>
)}

{/* Artifact shortcuts sit below the input alongside the mode
indicator — everything ambient lives under the composer. The
marginLeft mirrors ChatInput's own offset so the row lines up
with the mode line rather than sitting one column in. */}
<Box marginLeft={fullscreen ? 0 : -1}>
with the mode line rather than sitting one column in. Hidden,
not unmounted, under the shortcuts overlay so closing it does
not refetch the links. */}
<Box
marginLeft={fullscreen ? 0 : -1}
display={shortcutsOverlayOpen ? 'none' : 'flex'}
>
<SessionArtifactLinks
sessionId={appState.currentSessionId}
refreshKey={artifactRefreshKey}
Expand All @@ -717,6 +729,7 @@ export function InteractiveApp({
appState.activeMode === null &&
!appState.isSettingsMode &&
!appState.planReviewState?.show &&
!shortcutsOverlayOpen &&
voicePref.enabled && (
<VoiceStatusBar
state={voiceState}
Expand Down
2 changes: 1 addition & 1 deletion source/components/json-viewer/json-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ function renderRowContent(
);
}

export function HelpRow({
function HelpRow({
label,
keybind,
colors,
Expand Down
Loading
Loading