@@ -4,7 +4,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useReducer, useRef } f
44import { VERSION } from "../help.js" ;
55import { redactSecrets , safeErrorMessage , safeTerminalText } from "../safety.js" ;
66import { isThemeName , type ThemeName } from "../theme.js" ;
7- import { parseSlashCommand , SLASH_COMMANDS } from "./commands.js" ;
7+ import { completeSlashCommand , parseSlashCommand , SLASH_COMMANDS } from "./commands.js" ;
88import { Composer } from "./components/Composer.js" ;
99import { Conversation } from "./components/Conversation.js" ;
1010import { Footer } from "./components/Footer.js" ;
@@ -47,6 +47,9 @@ export interface AppProps {
4747 readonly now ?: ( ) => number ;
4848 /** Overrides Ink's own exit so the exit path can be observed in tests. */
4949 readonly onExit ?: ( ) => void ;
50+ /** Deterministic terminal dimensions for render tests. */
51+ readonly terminalWidth ?: number ;
52+ readonly terminalHeight ?: number ;
5053}
5154
5255let noticeCounter = 0 ;
@@ -68,11 +71,12 @@ export function App(props: AppProps): ReactNode {
6871 [ state . themeName , state . colorEnabled ] ,
6972 ) ;
7073
71- const width = Math . max ( 40 , stdout . columns ?? 80 ) ;
72- const height = Math . max ( 10 , stdout . rows ?? 24 ) ;
74+ const terminalWidth = Math . max ( 1 , props . terminalWidth ?? stdout . columns ?? 80 ) ;
75+ const width = Math . min ( 112 , terminalWidth ) ;
76+ const height = Math . max ( 10 , props . terminalHeight ?? stdout . rows ?? 24 ) ;
7377 // The conversation and any overlay share one fixed region. Both the key handler and the renderer
7478 // need this height, so it is derived once here rather than recomputed at each use.
75- const conversationHeight = Math . max ( 3 , height - 12 ) ;
79+ const conversationHeight = Math . max ( 3 , height - 10 ) ;
7680
7781 const notify = useCallback (
7882 ( level : "info" | "warning" | "error" | "success" , message : string ) : void => {
@@ -437,7 +441,7 @@ export function App(props: AppProps): ReactNode {
437441 // start a second Harness run and overwrite that slot, orphaning the first run beyond the reach
438442 // of Ctrl+C. Slash commands stay available because they never start a run.
439443 if ( current . runStatus !== "idle" && ! trimmed . startsWith ( "/" ) ) {
440- notify ( "warning" , "A response is still streaming. Press Ctrl+C to cancel it first." ) ;
444+ notify ( "warning" , "A response is still streaming. Press Ctrl+X to cancel it first." ) ;
441445 return ;
442446 }
443447 dispatch ( { type : "composer/submit" } ) ;
@@ -453,14 +457,15 @@ export function App(props: AppProps): ReactNode {
453457 useInput ( ( input , key ) => {
454458 const current = stateRef . current ;
455459
456- // Ctrl+C cancels an active run and keeps the app mounted; when idle it exits.
457- if ( key . ctrl && input === "c" ) {
460+ // Ctrl+C belongs to the terminal and is deliberately a no-op in the app. Ctrl+X is the
461+ // explicit in-app cancellation binding; /exit is the only normal exit command.
462+ if ( key . ctrl && input === "c" ) return ;
463+ if ( key . ctrl && input === "x" ) {
458464 if ( activeRun . current !== undefined ) {
459465 activeRun . current . abort ( ) ;
460466 dispatch ( { type : "run/status" , status : "cancelling" } ) ;
461467 return ;
462468 }
463- dispatch ( { type : "exit" } ) ;
464469 return ;
465470 }
466471
@@ -492,6 +497,13 @@ export function App(props: AppProps): ReactNode {
492497 insertText ( "\n" ) ;
493498 return ;
494499 }
500+ if ( key . tab ) {
501+ const completed = completeSlashCommand ( current . composer . value , current . composer . cursor ) ;
502+ if ( completed . value !== current . composer . value ) {
503+ dispatch ( { type : "composer/set" , value : completed . value , cursor : completed . cursor } ) ;
504+ }
505+ return ;
506+ }
495507 if ( key . return ) {
496508 submitComposer ( ) ;
497509 return ;
@@ -776,43 +788,45 @@ export function App(props: AppProps): ReactNode {
776788 const overlayNode = renderOverlay ( ) ;
777789
778790 return (
779- < Box flexDirection = "column" width = { width } height = { height } >
780- < Header theme = { theme } state = { state } version = { VERSION } width = { width } />
781- { overlayNode === undefined ? (
782- < Conversation
791+ < Box flexDirection = "column" width = { terminalWidth } height = { height } alignItems = "center" >
792+ < Box flexDirection = "column" width = { width } height = { height } >
793+ < Header theme = { theme } state = { state } version = { VERSION } width = { width } />
794+ { overlayNode === undefined ? (
795+ < Conversation
796+ theme = { theme }
797+ entries = { state . conversation }
798+ height = { conversationHeight }
799+ scrollOffset = { state . scrollOffset }
800+ emptyHint = {
801+ state . connection === undefined
802+ ? "No provider is connected. Use /provider to connect OpenRouter or an OpenAI-compatible endpoint."
803+ : state . model === undefined
804+ ? "Connected. Use /model to select a model from the live catalog."
805+ : "Ready. Type a prompt, or / for commands."
806+ }
807+ />
808+ ) : (
809+ < Box
810+ flexDirection = "column"
811+ flexGrow = { 1 }
812+ flexShrink = { 1 }
813+ minHeight = { 0 }
814+ height = { conversationHeight }
815+ paddingX = { 1 }
816+ overflow = "hidden"
817+ >
818+ { overlayNode }
819+ </ Box >
820+ ) }
821+ < Notices theme = { theme } notices = { state . notices } width = { width } />
822+ < Composer
783823 theme = { theme }
784- entries = { state . conversation }
785- height = { conversationHeight }
786- scrollOffset = { state . scrollOffset }
787- emptyHint = {
788- state . connection === undefined
789- ? "No provider is connected. Use /provider to connect OpenRouter or an OpenAI-compatible endpoint."
790- : state . model === undefined
791- ? "Connected. Use /model to select a model from the live catalog."
792- : "Ready. Type a prompt, or / for commands."
793- }
824+ composer = { state . composer }
825+ disabled = { state . runStatus !== "idle" }
826+ width = { width }
794827 />
795- ) : (
796- < Box
797- flexDirection = "column"
798- flexGrow = { 1 }
799- flexShrink = { 1 }
800- minHeight = { 0 }
801- height = { conversationHeight }
802- paddingX = { 1 }
803- overflow = "hidden"
804- >
805- { overlayNode }
806- </ Box >
807- ) }
808- < Notices theme = { theme } notices = { state . notices } width = { width } />
809- < Composer
810- theme = { theme }
811- composer = { state . composer }
812- disabled = { state . runStatus !== "idle" }
813- width = { width }
814- />
815- < Footer theme = { theme } state = { state } width = { width } />
828+ < Footer theme = { theme } state = { state } width = { width } />
829+ </ Box >
816830 </ Box >
817831 ) ;
818832
@@ -883,12 +897,10 @@ export function App(props: AppProps): ReactNode {
883897
884898/** Describes the exact external endpoint the form will contact, so network use is never implicit. */
885899export function providerDisclosure ( form : ProviderFormState ) : string {
886- const raw =
887- form . baseUrl . trim ( ) . length > 0
888- ? form . baseUrl . trim ( )
889- : form . kind === "openrouter"
890- ? OPENROUTER_DEFAULT_BASE_URL
891- : "" ;
900+ if ( form . kind === "openrouter" ) {
901+ return `connecting will contact ${ OPENROUTER_DEFAULT_BASE_URL } using the built-in OPENROUTER_API_KEY reference` ;
902+ }
903+ const raw = form . baseUrl . trim ( ) . length > 0 ? form . baseUrl . trim ( ) : "" ;
892904 if ( raw . length === 0 ) return "a base URL is required before any request is made" ;
893905 try {
894906 const url = new URL ( raw ) ;
0 commit comments