@@ -76,41 +76,333 @@ function ask(question: string): Promise<string> {
} ) ;
}
async function handleConfig() {
const config = await loadConfig ( ) ;
type ConfigKey = keyof Config ;
console . log ( ` \ n ${ BOLD } Current configuration: ${ RESET } ` ) ;
console . log (
` API Key: ${ config . apiKey ? "****" + config . apiKey . slice ( - 4 ) : ` ${ YELLOW } (not set) ${ RESET } ` } ` ,
) ;
console . log ( ` API Base: ${ config . apiBase } ` ) ;
console . log ( ` Model: ${ config . model } ` ) ;
console . log ( ` Max Tokens: ${ config . maxTokens } ` ) ;
console . log ( ` Temperature: ${ config . temperature } ` ) ;
interface ConfigField {
key : ConfigKey ;
label : string ;
format : ( config : Config ) = > string ;
initialEditValue : ( config : Config ) = > string ;
parse : ( value : string ) = > { value : Config [ ConfigKey ] } | { error : string } ;
}
console . log (
` \ n ${ BOLD } Enter new values (leave empty to keep current): ${ RESET } ` ,
) ;
const apiKey = await ask ( " API Key: " ) ;
const apiBase = await ask ( ` API Base [ ${ config . apiBase } ]: ` ) ;
const model = await ask ( ` Model [ ${ config . model } ]: ` ) ;
const maxTokens = await ask ( ` Max Tokens [ ${ config . maxTokens } ]: ` ) ;
const temperature = await ask ( ` Temperature [ ${ config . temperature } ]: ` ) ;
const updates : Partial < Config > = { } ;
if ( apiKey ) updates . apiKey = apiKey ;
if ( apiBase ) updates . apiBase = apiBase ;
if ( model ) updates . model = model ;
if ( maxTokens ) updates . maxTokens = parseInt ( maxTokens ) ;
if ( temperature ) updates . temperature = parseFloat ( temperature ) ;
if ( Object . keys ( updates ) . length > 0 ) {
await saveConfig ( updates ) ;
console . log ( ` \ n ${ GREEN } Configuration saved! ${ RESET } ` ) ;
} else {
console . log ( "\n No changes." ) ;
const CONFIG_FIELDS : ConfigField [ ] = [
{
key : "apiKey" ,
label : "API Key" ,
format : ( config ) = >
config . apiKey ? ` **** ${ config . apiKey . slice ( - 4 ) } ` : ` ${ YELLOW } (not set) ${ RESET } ` ,
initialEditValue : ( ) = > "" ,
parse : ( value ) = > ( { value } ) ,
} ,
{
key : "apiBase" ,
label : "API Base" ,
format : ( config ) = > config . apiBase ,
initialEditValue : ( config ) = > config . apiBase ,
parse : ( value ) = > ( { value } ) ,
} ,
{
key : "model" ,
label : "Model" ,
format : ( config ) = > config . model ,
initialEditValue : ( config ) = > config . model ,
parse : ( value ) = > ( { value } ) ,
} ,
{
key : "maxTokens" ,
label : "Max Tokens" ,
format : ( config ) = > String ( config . maxTokens ) ,
initialEditValue : ( config ) = > String ( config . maxTokens ) ,
parse : ( value ) = > {
const parsed = Number ( value ) ;
if ( ! Number . isInteger ( parsed ) || parsed <= 0 ) {
return { error : "Max Tokens must be a positive integer." } ;
}
return { value : parsed } ;
} ,
} ,
{
key : "temperature" ,
label : "Temperature" ,
format : ( config ) = > String ( config . temperature ) ,
initialEditValue : ( config ) = > String ( config . temperature ) ,
parse : ( value ) = > {
const parsed = Number ( value ) ;
if ( ! Number . isFinite ( parsed ) ) {
return { error : "Temperature must be a finite number." } ;
}
return { value : parsed } ;
} ,
} ,
] ;
function visibleLength ( value : string ) {
return value . replace ( /\x1b\[[0-9;]*m/g , "" ) . length ;
}
function clearLine() {
process . stdout . write ( "\r\x1b[2K" ) ;
}
function moveUp ( lines : number ) {
if ( lines > 0 ) process . stdout . write ( ` \ x1b[ ${ lines } A ` ) ;
}
function renderConfigPage (
config : Config ,
cursor : number ,
previousLines : number ,
status : string | null ,
editState : { buffer : string ; cursor : number } | null ,
) {
if ( previousLines > 0 ) {
for ( let i = 0 ; i < previousLines ; i ++ ) {
clearLine ( ) ;
process . stdout . write ( "\n" ) ;
}
moveUp ( previousLines ) ;
}
const labelWidth = Math . max ( . . . CONFIG_FIELDS . map ( ( field ) = > field . label . length ) ) + 2 ;
const lines = [
"" ,
` ${ BOLD } Configuration ${ RESET } ` ,
editState
? ` ${ DIM } editing · enter save · esc cancel · ctrl+c cancel ${ RESET } `
: ` ${ DIM } ↑/↓ navigate · space edit · ←/backspace back · ctrl+c cancel ${ RESET } ` ,
"" ,
] ;
let activeValueOffset = 0 ;
for ( let i = 0 ; i < CONFIG_FIELDS . length ; i ++ ) {
const field = CONFIG_FIELDS [ i ] ! ;
const active = i === cursor ;
const pointer = active ? ` ${ CYAN } ❯ ${ RESET } ` : " " ;
const marker = active ? ` ${ GREEN } ● ${ RESET } ` : ` ${ DIM } ○ ${ RESET } ` ;
const label = active ? ` ${ BOLD } ${ field . label } ${ RESET } ` : field . label ;
const padding = " " . repeat ( Math . max ( 1 , labelWidth - visibleLength ( field . label ) ) ) ;
const value = active && editState ? editState.buffer : field.format ( config ) ;
if ( active && editState ) {
activeValueOffset = visibleLength ( ` ${ pointer } ${ marker } ${ label } ${ padding } ` ) ;
}
lines . push ( ` ${ pointer } ${ marker } ${ label } ${ padding } ${ value } ` ) ;
}
if ( status ) {
lines . push ( "" , ` ${ status } ` ) ;
}
for ( const line of lines ) {
process . stdout . write ( ` ${ line } \ n ` ) ;
}
if ( editState ) {
moveUp ( lines . length - ( 4 + cursor ) ) ;
const column = activeValueOffset + editState . cursor ;
process . stdout . write ( ` \ r ${ column > 0 ? ` \ x1b[ ${ column } C ` : "" } ` ) ;
} else {
moveUp ( lines . length ) ;
}
return lines . length ;
}
async function handleConfig ( ) : Promise < "done" | "back" > {
if ( process . stdin . isTTY !== true ) {
console . error ( ` ${ RED } Error: Configuration requires a TTY. ${ RESET } ` ) ;
process . exit ( 1 ) ;
}
let config = await loadConfig ( ) ;
let cursor = 0 ;
let renderedLines = 0 ;
let escapeBuf = "" ;
let status : string | null = null ;
let editState : { buffer : string ; cursor : number } | null = null ;
let renderedCursorRow = 0 ;
const wasRaw = process . stdin . isRaw ;
if ( wasRaw !== true ) process . stdin . setRawMode ( true ) ;
process . stdin . resume ( ) ;
process . stdout . write ( "\x1b[?25l" ) ;
const render = ( ) = > {
moveUp ( renderedCursorRow ) ;
renderedLines = renderConfigPage ( config , cursor , renderedLines , status , editState ) ;
renderedCursorRow = editState ? 4 + cursor : 0 ;
process . stdout . write ( editState ? "\x1b[?25h" : "\x1b[?25l" ) ;
} ;
render ( ) ;
return new Promise ( ( resolve ) = > {
const finish = ( value : "done" | "back" ) = > {
process . stdin . setRawMode ( wasRaw === true ) ;
process . stdin . pause ( ) ;
process . stdin . removeListener ( "data" , onData ) ;
moveUp ( renderedCursorRow ) ;
for ( let i = 0 ; i < renderedLines ; i ++ ) {
clearLine ( ) ;
process . stdout . write ( "\n" ) ;
}
moveUp ( renderedLines ) ;
process . stdout . write ( "\x1b[?25h" ) ;
resolve ( value ) ;
} ;
const saveEdit = async ( ) = > {
if ( ! editState ) return ;
const field = CONFIG_FIELDS [ cursor ] ! ;
const value = editState . buffer . trim ( ) ;
editState = null ;
if ( value === "" ) {
status = ` ${ DIM } No changes. ${ RESET } ` ;
} else {
const parsed = field . parse ( value ) ;
if ( "error" in parsed ) {
status = ` ${ RED } ${ parsed . error } ${ RESET } ` ;
} else {
await saveConfig ( { [ field . key ] : parsed . value } as Partial < Config > ) ;
config = await loadConfig ( ) ;
status = ` ${ GREEN } ${ field . label } saved. ${ RESET } ` ;
}
}
render ( ) ;
} ;
const onData = ( data : Buffer ) = > {
const key = data . toString ( ) ;
const UP = "\x1b[A" ;
const DOWN = "\x1b[B" ;
const LEFT = "\x1b[D" ;
const ALT_UP = "\x1bOA" ;
const ALT_DOWN = "\x1bOB" ;
const ALT_LEFT = "\x1bOD" ;
const SPACE = " " ;
const ENTER = "\r" ;
const ESC = "\x1b" ;
const RIGHT = "\x1b[C" ;
const ALT_RIGHT = "\x1bOC" ;
const CTRL_C = "\x03" ;
const BACKSPACE = "\x7f" ;
if ( editState ) {
if ( key === CTRL_C || key === ESC ) {
editState = null ;
status = ` ${ DIM } No changes. ${ RESET } ` ;
render ( ) ;
return ;
}
if ( key === ENTER ) {
void saveEdit ( ) ;
return ;
}
if ( key === "\x01" ) {
editState . cursor = 0 ;
render ( ) ;
return ;
}
if ( key === "\x05" ) {
editState . cursor = editState . buffer . length ;
render ( ) ;
return ;
}
if ( key === "\x0b" ) {
editState . buffer = editState . buffer . slice ( 0 , editState . cursor ) ;
render ( ) ;
return ;
}
if ( key === "\x15" ) {
editState . buffer = editState . buffer . slice ( editState . cursor ) ;
editState . cursor = 0 ;
render ( ) ;
return ;
}
if ( key === BACKSPACE ) {
if ( editState . cursor > 0 ) {
editState . buffer =
editState . buffer . slice ( 0 , editState . cursor - 1 ) +
editState . buffer . slice ( editState . cursor ) ;
editState . cursor -- ;
render ( ) ;
}
return ;
}
if ( key === LEFT || key === ALT_LEFT ) {
if ( editState . cursor > 0 ) editState . cursor -- ;
render ( ) ;
return ;
}
if ( key === RIGHT || key === ALT_RIGHT ) {
if ( editState . cursor < editState . buffer . length ) editState . cursor ++ ;
render ( ) ;
return ;
}
if ( key . startsWith ( "\x1b[" ) ) {
if ( key === "\x1b[H" || key === "\x1b[1~" ) {
editState . cursor = 0 ;
} else if ( key === "\x1b[F" || key === "\x1b[4~" ) {
editState . cursor = editState . buffer . length ;
} else if ( key === "\x1b[3~" && editState . cursor < editState . buffer . length ) {
editState . buffer =
editState . buffer . slice ( 0 , editState . cursor ) +
editState . buffer . slice ( editState . cursor + 1 ) ;
}
render ( ) ;
return ;
}
if ( key >= " " && key !== "\x7f" ) {
editState . buffer =
editState . buffer . slice ( 0 , editState . cursor ) +
key +
editState . buffer . slice ( editState . cursor ) ;
editState . cursor += key . length ;
render ( ) ;
}
return ;
}
const action = ( ( ) = > {
if ( key === UP || key === ALT_UP ) return "up" ;
if ( key === DOWN || key === ALT_DOWN ) return "down" ;
if ( key === LEFT || key === ALT_LEFT || key === BACKSPACE ) return "back" ;
if ( key === SPACE ) return "edit" ;
if ( key === CTRL_C ) return "cancel" ;
if ( key === "\x1b" || key . startsWith ( "\x1b[" ) ) {
escapeBuf = key ;
return null ;
}
if ( escapeBuf ) {
const next = escapeBuf + key ;
escapeBuf = /^[A-Za-z~]$/ . test ( key ) || next . length > 8 ? "" : next ;
if ( next === UP || next === ALT_UP ) return "up" ;
if ( next === DOWN || next === ALT_DOWN ) return "down" ;
if ( next === LEFT || next === ALT_LEFT ) return "back" ;
}
return null ;
} ) ( ) ;
if ( action === "cancel" ) return finish ( "done" ) ;
if ( action === "back" ) return finish ( "back" ) ;
if ( action === "up" && cursor > 0 ) {
cursor -- ;
status = null ;
render ( ) ;
} else if ( action === "down" && cursor < CONFIG_FIELDS . length - 1 ) {
cursor ++ ;
status = null ;
render ( ) ;
} else if ( action === "edit" ) {
const value = CONFIG_FIELDS [ cursor ] ! . initialEditValue ( config ) ;
editState = { buffer : value , cursor : value.length } ;
status = null ;
render ( ) ;
}
} ;
process . stdin . on ( "data" , onData ) ;
} ) ;
}
async function confirmCommit ( message : string ) : Promise < "y" | "n" | "e" > {
@@ -324,7 +616,7 @@ async function showMenu(): Promise<void> {
? await handleCommit ( false , false )
: selected === "pr"
? await handlePR ( false )
: await handleConfig ( ) . then ( ( ) = > "done" as const ) ;
: await handleConfig ( ) ;
if ( result !== "back" ) return ;
}
@@ -353,15 +645,6 @@ async function handleCommit(
autoMode : boolean ,
dryRun : boolean ,
) : Promise < "done" | "back" > {
const config = await loadConfig ( ) ;
if ( ! config . apiKey ) {
console . error (
` ${ RED } Error: API key not set. Run ${ BOLD } gai config ${ RESET } ${ RED } to configure. ${ RESET } ` ,
) ;
process . exit ( 1 ) ;
}
if ( ! ( await isGitRepo ( ) ) ) {
console . error ( ` ${ RED } Error: Not a git repository. ${ RESET } ` ) ;
process . exit ( 1 ) ;
@@ -371,7 +654,7 @@ async function handleCommit(
const unstagedFiles = await getUnstagedFiles ( ) ;
if ( stagedFiles . length === 0 && unstagedFiles . length === 0 ) {
console . log ( " Nothing to commit." ) ;
console . log ( ` ${ DIM } Nothing to commit. No staged or unstaged changes. ${ RESET } ` ) ;
return "done" ;
}
@@ -395,10 +678,19 @@ async function handleCommit(
const diff = await getStagedDiff ( ) ;
if ( ! diff ) {
console . log ( " No staged changes to commit." ) ;
console . log ( ` ${ DIM } Nothing to commit. No staged changes to commit.${ RESET } ` ) ;
return "done" ;
}
const config = await loadConfig ( ) ;
if ( ! config . apiKey ) {
console . error (
` ${ RED } Error: API key not set. Run ${ BOLD } gai config ${ RESET } ${ RED } to configure. ${ RESET } ` ,
) ;
process . exit ( 1 ) ;
}
const MAX_DIFF_SIZE = 15000 ;
const truncatedDiff =
diff . length > MAX_DIFF_SIZE
@@ -524,10 +816,13 @@ async function handlePR(draft: boolean): Promise<"done" | "back"> {
const commits = await getBranchCommits ( baseBranch ) ;
if ( commits . length === 0 ) {
console . error (
` ${ RED } Error: No commits on ${ branchName } compared to ${ baseBranch } . Commit something first. ${ RESET } ` ,
) ;
process . exit ( 1 ) ;
const choice = await selectOne ( {
title : "No commits to compare" ,
subtitle : ` No commits on ${ branchName } compared to ${ baseBranch } . Commit something first. ` ,
items : [ { label : "Back" , value : "back" as const } ] ,
} ) ;
if ( choice === null ) process . exit ( 0 ) ;
return "done" ;
}
console . log (