Skip to content

Commit 69f0974

Browse files
fix(ui): prevent chat from aggressively scrolling to bottom on slash command
Resolves #581. Previously, rendering the slash command completions menu outside of the main bordered input <Box> altered the height of the live viewport area. This height change triggered Ink.js to recalculate flexbox layouts, forcing the chat history to unexpectedly snap to the bottom in smaller terminal windows. This commit moves the rendering of both command completions and file suggestions inside the bounded layout. The container expands naturally without disrupting the overall window height calculation, keeping the user's scroll position intact. Additionally: - Adds structural layout tests verifying completions render inside the container. - Fixes test timing flakes where simulated stdin input was occasionally ignored.
1 parent 55ea894 commit 69f0974

2 files changed

Lines changed: 133 additions & 42 deletions

File tree

source/components/user-input.spec.tsx

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {render} from 'ink-testing-library';
33
import React from 'react';
44
import {themes} from '../config/themes';
55
import {ThemeContext} from '../hooks/useTheme';
6-
import {UIStateProvider} from '../hooks/useUIState';
6+
import {UIStateProvider, useUIStateContext} from '../hooks/useUIState';
77
import UserInput from './user-input';
88

99
console.log(`\nuser-input.spec.tsx – ${React.version}`);
@@ -393,6 +393,7 @@ test('UserInput does not show ctrl-o hint when onToggleCompactDisplay is not pro
393393
unmount();
394394
});
395395

396+
<<<<<<< HEAD
396397
// ============================================================================
397398
// Command Completion Navigation Tests
398399
// ============================================================================
@@ -484,3 +485,91 @@ test('completion menu dismissal/reset after selection or escape', async t => {
484485

485486
unmount();
486487
});
488+
=======
489+
test('UserInput renders completions text when typing /', async t => {
490+
const {stdin, lastFrame, unmount} = render(
491+
<TestWrapper>
492+
<UserInput customCommands={['help', 'model']} />
493+
</TestWrapper>
494+
);
495+
496+
await new Promise(resolve => setTimeout(resolve, 50));
497+
stdin.write('/');
498+
await new Promise(resolve => setTimeout(resolve, 150));
499+
500+
const output = lastFrame()!;
501+
t.truthy(output);
502+
t.regex(output, /Available commands:/);
503+
unmount();
504+
});
505+
506+
test('UserInput renders completions BEFORE the mode indicator (inside the input box)', async t => {
507+
const {stdin, lastFrame, unmount} = render(
508+
<TestWrapper>
509+
<UserInput developmentMode="normal" customCommands={['help', 'model']} />
510+
</TestWrapper>
511+
);
512+
513+
await new Promise(resolve => setTimeout(resolve, 50));
514+
stdin.write('/');
515+
await new Promise(resolve => setTimeout(resolve, 150));
516+
517+
const output = lastFrame()!;
518+
t.truthy(output);
519+
520+
const completionsIdx = output.indexOf('Available commands:');
521+
const modeIdx = output.indexOf('normal mode');
522+
t.true(completionsIdx > -1, 'Completions text should be present');
523+
t.true(modeIdx > -1, 'Mode indicator should be present');
524+
t.true(
525+
completionsIdx < modeIdx,
526+
'Completions must render before the mode indicator (inside the bordered input box)',
527+
);
528+
unmount();
529+
});
530+
531+
test('UserInput completions appear on a line above the mode indicator', async t => {
532+
const {stdin, lastFrame, unmount} = render(
533+
<TestWrapper>
534+
<UserInput developmentMode="normal" customCommands={['help', 'model']} />
535+
</TestWrapper>
536+
);
537+
538+
await new Promise(resolve => setTimeout(resolve, 50));
539+
stdin.write('/');
540+
await new Promise(resolve => setTimeout(resolve, 150));
541+
542+
const output = lastFrame()!;
543+
const lines = output.split('\n');
544+
545+
let completionLine = -1;
546+
let modeLine = -1;
547+
for (let i = 0; i < lines.length; i++) {
548+
if (lines[i].includes('Available commands:')) completionLine = i;
549+
if (lines[i].includes('normal mode')) modeLine = i;
550+
}
551+
552+
t.true(completionLine > -1, 'Should find completions line');
553+
t.true(modeLine > -1, 'Should find mode indicator line');
554+
t.true(
555+
completionLine < modeLine,
556+
`Completions (line ${completionLine}) must be above mode indicator (line ${modeLine})`,
557+
);
558+
unmount();
559+
});
560+
561+
test('UserInput does not show completions when input is empty', t => {
562+
const {lastFrame, unmount} = render(
563+
<TestWrapper>
564+
<UserInput />
565+
</TestWrapper>
566+
);
567+
568+
const output = lastFrame()!;
569+
t.truthy(output);
570+
t.notRegex(output, /Available commands:/);
571+
unmount();
572+
});
573+
574+
575+
>>>>>>> 9d48b592 (fix(ui): prevent chat from aggressively scrolling to bottom on slash command)

source/components/user-input.tsx

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -591,46 +591,6 @@ export default function UserInput({
591591
</Text>
592592
)}
593593

594-
{showCompletions && completions.length > 0 && (
595-
<Box flexDirection="column" marginTop={1}>
596-
<Text color={colors.secondary}>Available commands:</Text>
597-
{completions.map((completion, index) => {
598-
const isSelected = index === selectedCompletionIndex;
599-
return (
600-
<Text
601-
key={index}
602-
color={
603-
isSelected
604-
? colors.info
605-
: completion.isCustom
606-
? colors.info
607-
: colors.primary
608-
}
609-
bold={isSelected}
610-
>
611-
{isSelected ? '▸ ' : ' '}/{completion.name}
612-
</Text>
613-
);
614-
})}
615-
</Box>
616-
)}
617-
{isFileAutocompleteMode && fileCompletions.length > 0 && (
618-
<Box flexDirection="column" marginTop={1}>
619-
<Text color={colors.secondary}>
620-
File suggestions (↑/↓ to navigate, Tab to select):
621-
</Text>
622-
{fileCompletions.slice(0, 5).map((file, index) => (
623-
<Text
624-
key={index}
625-
color={index === selectedFileIndex ? colors.info : colors.primary}
626-
bold={index === selectedFileIndex}
627-
>
628-
{index === selectedFileIndex ? '▸ ' : ' '}
629-
{file.path}
630-
</Text>
631-
))}
632-
</Box>
633-
)}
634594

635595
<Box
636596
flexDirection="column"
@@ -666,8 +626,50 @@ export default function UserInput({
666626
{showClearMessage && (
667627
<Text color={colors.secondary}>Press escape again to clear</Text>
668628
)}
669-
</Box>
670629

630+
{showCompletions && completions.length > 0 && (
631+
<Box flexDirection="column" marginTop={1}>
632+
<Text color={colors.secondary}>Available commands:</Text>
633+
{completions.map((completion, index) => {
634+
const isSelected = index === selectedCompletionIndex;
635+
return (
636+
<Text
637+
key={index}
638+
color={
639+
isSelected
640+
? colors.info
641+
: completion.isCustom
642+
? colors.info
643+
: colors.primary
644+
}
645+
bold={isSelected}
646+
>
647+
{isSelected ? '▸ ' : ' '}/{completion.name}
648+
</Text>
649+
);
650+
})}
651+
</Box>
652+
)}
653+
{isFileAutocompleteMode && fileCompletions.length > 0 && (
654+
<Box flexDirection="column" marginTop={1}>
655+
<Text color={colors.secondary}>
656+
File suggestions (↑/↓ to navigate, Tab to select):
657+
</Text>
658+
{fileCompletions.slice(0, 5).map((file, index) => (
659+
<Text
660+
key={index}
661+
color={
662+
index === selectedFileIndex ? colors.info : colors.primary
663+
}
664+
bold={index === selectedFileIndex}
665+
>
666+
{index === selectedFileIndex ? '▸ ' : ' '}
667+
{file.path}
668+
</Text>
669+
))}
670+
</Box>
671+
)}
672+
</Box>
671673
{/* Development mode indicator - always visible */}
672674
<DevelopmentModeIndicator
673675
developmentMode={developmentMode}

0 commit comments

Comments
 (0)