2026-06-01 00:59:22 +00:00
import readline from 'readline' ;
import chalk from 'chalk' ;
import { AppState , Issue , Comment } from './types.js' ;
2026-06-01 02:02:23 +00:00
import { fetchIssues , fetchIssueComments , validateConnection , normalizeUrl , authenticateAndFetchRepos } from './api.js' ;
2026-06-01 02:27:57 +00:00
import { saveGlobalConfig } from './config.js' ;
2026-06-01 00:59:22 +00:00
// Setup readline for stdin keypress events
readline . emitKeypressEvents ( process . stdin ) ;
// Spinner chars for loading animations
const SPINNER_FRAMES = [ '⠋' , '⠙' , '⠹' , '⠸' , '⠼' , '⠴' , '⠦' , '⠧' , '⠇' , '⠏' ] ;
let spinnerIndex = 0 ;
let spinnerInterval : NodeJS.Timeout | null = null ;
/ * *
* Word wraps text into lines of a maximum width .
* /
export function wordWrap ( text : string , maxWidth : number ) : string [ ] {
if ( ! text ) return [ ] ;
const lines : string [ ] = [ ] ;
const rawLines = text . split ( '\n' ) ;
for ( const rawLine of rawLines ) {
if ( rawLine . trim ( ) === '' ) {
lines . push ( '' ) ;
continue ;
}
let currentLine = '' ;
const words = rawLine . split ( /\s+/ ) ;
for ( const word of words ) {
if ( ! word ) continue ;
if ( currentLine . length + word . length + 1 <= maxWidth ) {
currentLine += ( currentLine ? ' ' : '' ) + word ;
} else {
if ( currentLine ) {
lines . push ( currentLine ) ;
}
// If a single word is longer than maxWidth, force break it
let tempWord = word ;
while ( tempWord . length > maxWidth ) {
lines . push ( tempWord . substring ( 0 , maxWidth ) ) ;
tempWord = tempWord . substring ( maxWidth ) ;
}
currentLine = tempWord ;
}
}
if ( currentLine ) {
lines . push ( currentLine ) ;
}
}
return lines ;
}
/ * *
* Formats an ISO date string into YYYY - MM - DD HH :MM.
* /
export function formatDate ( dateStr : string ) : string {
try {
const d = new Date ( dateStr ) ;
if ( isNaN ( d . getTime ( ) ) ) return 'n/a' ;
const yr = d . getFullYear ( ) ;
const mo = String ( d . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
const day = String ( d . getDate ( ) ) . padStart ( 2 , '0' ) ;
const hr = String ( d . getHours ( ) ) . padStart ( 2 , '0' ) ;
const min = String ( d . getMinutes ( ) ) . padStart ( 2 , '0' ) ;
return ` ${ yr } - ${ mo } - ${ day } ${ hr } : ${ min } ` ;
} catch {
return 'n/a' ;
}
}
/ * *
* Helper to truncate strings to a specific length .
* /
function truncate ( str : string , length : number ) : string {
if ( str . length <= length ) return str ;
return str . substring ( 0 , length - 3 ) + '...' ;
}
2026-06-01 02:02:23 +00:00
/ * *
* Helper to strip ANSI escape codes from a string for accurate length calculations .
* /
function stripAnsi ( str : string ) : string {
return str . replace ( /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g , '' ) ;
}
2026-06-01 00:59:22 +00:00
/ * *
* TUI State Controller and Render Engine
* /
export class TuiEngine {
private state : AppState ;
private onQuit : ( ) = > void = ( ) = > { } ;
private activeSearchInput : boolean = false ;
private searchInputBuffer : string = '' ;
constructor ( initialState : AppState ) {
this . state = initialState ;
// Set up resize handler
process . stdout . on ( 'resize' , ( ) = > {
this . render ( ) ;
} ) ;
}
/ * *
* Starts the TUI engine and registers keypress listeners .
* /
public start ( onQuit : ( ) = > void ) {
this . onQuit = onQuit ;
// Put terminal in raw mode
if ( process . stdin . isTTY ) {
process . stdin . setRawMode ( true ) ;
}
process . stdin . resume ( ) ;
process . stdin . on ( 'keypress' , this . handleKeypress . bind ( this ) ) ;
2026-06-01 02:40:52 +00:00
// Enter alternate screen buffer and hide standard cursor
process . stdout . write ( '\x1B[?1049h\x1B[?25l' ) ;
2026-06-01 00:59:22 +00:00
// Bootstrap if config exists
if ( this . state . config . url && this . state . config . owner && this . state . config . repo ) {
this . state . screen = 'list' ;
this . loadIssues ( ) ;
} else {
this . render ( ) ;
}
}
/ * *
* Stops the TUI engine , restores terminal state .
* /
public stop() {
this . stopSpinner ( ) ;
2026-06-01 02:40:52 +00:00
// Exit alternate screen buffer and show standard cursor
process . stdout . write ( '\x1B[?1049l\x1B[?25h' ) ;
2026-06-01 00:59:22 +00:00
if ( process . stdin . isTTY ) {
process . stdin . setRawMode ( false ) ;
}
process . stdin . pause ( ) ;
this . onQuit ( ) ;
}
private startSpinner() {
this . stopSpinner ( ) ;
spinnerInterval = setInterval ( ( ) = > {
spinnerIndex = ( spinnerIndex + 1 ) % SPINNER_FRAMES . length ;
this . render ( ) ;
} , 80 ) ;
}
private stopSpinner() {
if ( spinnerInterval ) {
clearInterval ( spinnerInterval ) ;
spinnerInterval = null ;
}
}
/ * *
* Fetches issues from the API client based on current state parameters .
* /
private async loadIssues() {
this . state . loading = true ;
this . state . error = null ;
this . startSpinner ( ) ;
try {
const { issues , totalCount } = await fetchIssues ( this . state . config , {
page : this.state.currentPage ,
limit : this.state.issuesPerPage ,
state : this.state.stateFilter ,
type : this . state . typeFilter ,
q : this.state.searchQuery ,
sortField : this.state.sortField ,
sortOrder : this.state.sortOrder ,
} ) ;
this . state . issues = issues ;
this . state . totalIssuesCount = totalCount ;
// Keep cursor bounds-checked
if ( this . state . selectedIssueIndex >= issues . length ) {
this . state . selectedIssueIndex = Math . max ( 0 , issues . length - 1 ) ;
}
} catch ( err : any ) {
this . state . error = err . message ;
this . state . issues = [ ] ;
this . state . totalIssuesCount = 0 ;
} finally {
this . state . loading = false ;
this . stopSpinner ( ) ;
this . render ( ) ;
}
}
/ * *
* Fetches comments for the selected issue .
* /
private async loadComments ( issue : Issue ) {
this . state . commentsLoading = true ;
this . state . selectedIssueComments = [ ] ;
this . render ( ) ;
try {
const comments = await fetchIssueComments ( this . state . config , issue . number ) ;
this . state . selectedIssueComments = comments ;
} catch ( err : any ) {
// Gracefully show comment loading error
this . state . selectedIssueComments = [ {
id : - 1 ,
user : { id : 0 , login : 'system' , full_name : 'System Error' } ,
body : ` Failed to load comments: ${ err . message } ` ,
created_at : new Date ( ) . toISOString ( ) ,
updated_at : new Date ( ) . toISOString ( ) ,
} ] ;
} finally {
this . state . commentsLoading = false ;
this . render ( ) ;
}
}
/ * *
* Keyboard input routers
* /
private async handleKeypress ( str : string , key : any ) {
// Standard Ctrl+C quit
if ( key && key . ctrl && key . name === 'c' ) {
this . stop ( ) ;
return ;
}
if ( this . state . screen === 'setup' ) {
this . handleSetupKeypress ( str , key ) ;
2026-06-01 02:02:23 +00:00
} else if ( this . state . screen === 'repo-picker' ) {
this . handleRepoPickerKeypress ( str , key ) ;
2026-06-01 00:59:22 +00:00
} else if ( this . state . screen === 'list' ) {
if ( this . activeSearchInput ) {
this . handleSearchKeypress ( str , key ) ;
} else {
this . handleListKeypress ( str , key ) ;
}
} else if ( this . state . screen === 'details' ) {
this . handleDetailsKeypress ( str , key ) ;
}
}
/ * *
* Key handling for Setup form
* /
private async handleSetupKeypress ( str : string , key : any ) {
const form = this . state . setupForm ;
2026-06-01 02:27:57 +00:00
const fields : Array < 'url' | 'userid' | 'token' | 'saveConfig' > = [ 'url' , 'userid' , 'token' , 'saveConfig' ] ;
2026-06-01 00:59:22 +00:00
const currentIdx = fields . indexOf ( form . activeField ) ;
if ( key && key . name === 'down' ) {
form . activeField = fields [ ( currentIdx + 1 ) % fields . length ] ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'up' ) {
form . activeField = fields [ ( currentIdx - 1 + fields . length ) % fields . length ] ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'tab' ) {
form . activeField = fields [ ( currentIdx + 1 ) % fields . length ] ;
this . render ( ) ;
return ;
}
2026-06-01 02:27:57 +00:00
if ( str === ' ' ) {
if ( form . activeField === 'saveConfig' ) {
form . saveConfig = ! form . saveConfig ;
this . render ( ) ;
return ;
}
}
2026-06-01 00:59:22 +00:00
if ( ( key && key . name === 'return' ) || str === '\r' || str === '\n' ) {
2026-06-01 02:27:57 +00:00
if ( form . activeField === 'saveConfig' ) {
form . saveConfig = ! form . saveConfig ;
this . render ( ) ;
return ;
}
2026-06-01 00:59:22 +00:00
// Submit form
if ( ! form . url . trim ( ) ) {
this . state . error = 'Instance URL is required.' ;
this . render ( ) ;
return ;
}
2026-06-01 02:02:23 +00:00
if ( ! form . userid . trim ( ) ) {
this . state . error = 'User ID / Username is required.' ;
2026-06-01 00:59:22 +00:00
this . render ( ) ;
return ;
}
this . state . loading = true ;
this . state . error = null ;
this . render ( ) ;
try {
2026-06-01 02:02:23 +00:00
const repos = await authenticateAndFetchRepos ( form . url , form . userid , form . token ) ;
this . state . repos = repos ;
this . state . selectedRepoIndex = 0 ;
this . state . repoSearchQuery = '' ;
this . state . repoPickerActiveSearch = false ;
if ( repos . length === 0 ) {
throw new Error ( 'No repositories found for the provided credentials.' ) ;
}
// Go to repo picker screen!
this . state . screen = 'repo-picker' ;
this . state . loading = false ;
this . render ( ) ;
2026-06-01 00:59:22 +00:00
} catch ( err : any ) {
this . state . error = err . message ;
this . state . loading = false ;
this . render ( ) ;
}
return ;
}
if ( ( key && key . name === 'escape' ) || str === '\u001b' ) {
this . stop ( ) ;
return ;
}
if ( key && key . name === 'backspace' ) {
if ( form . activeField === 'url' ) form . url = form . url . slice ( 0 , - 1 ) ;
2026-06-01 02:02:23 +00:00
if ( form . activeField === 'userid' ) form . userid = form . userid . slice ( 0 , - 1 ) ;
2026-06-01 00:59:22 +00:00
if ( form . activeField = == 'token' ) form . token = form . token . slice ( 0 , - 1 ) ;
this . render ( ) ;
return ;
}
// Type character
if ( str && ! key . ctrl && ! key . meta && str . length === 1 && str . charCodeAt ( 0 ) >= 32 ) {
if ( form . activeField === 'url' ) form . url += str ;
2026-06-01 02:02:23 +00:00
if ( form . activeField === 'userid' ) form . userid += str ;
2026-06-01 00:59:22 +00:00
if ( form . activeField === 'token' ) form . token += str ;
this . render ( ) ;
}
}
2026-06-01 02:02:23 +00:00
/ * *
* Key handling for Repository Picker screen
* /
private handleRepoPickerKeypress ( str : string , key : any ) {
if ( this . state . loading ) return ;
const filteredRepos = this . state . repos . filter ( r = >
r . full_name . toLowerCase ( ) . includes ( this . state . repoSearchQuery . toLowerCase ( ) ) ||
( r . description && r . description . toLowerCase ( ) . includes ( this . state . repoSearchQuery . toLowerCase ( ) ) )
) ;
if ( this . state . repoPickerActiveSearch ) {
if ( ( key && key . name === 'escape' ) || str === '\u001b' ) {
this . state . repoPickerActiveSearch = false ;
this . state . repoSearchQuery = '' ;
this . state . selectedRepoIndex = 0 ;
process . stdout . write ( '\x1B[?25l' ) ; // Hide cursor
this . render ( ) ;
return ;
}
if ( ( key && key . name === 'return' ) || str === '\r' || str === '\n' ) {
this . state . repoPickerActiveSearch = false ;
process . stdout . write ( '\x1B[?25l' ) ; // Hide cursor
this . render ( ) ;
return ;
}
if ( key && key . name === 'backspace' ) {
this . state . repoSearchQuery = this . state . repoSearchQuery . slice ( 0 , - 1 ) ;
this . state . selectedRepoIndex = 0 ;
this . render ( ) ;
return ;
}
if ( str && ! key . ctrl && ! key . meta && str . length === 1 && str . charCodeAt ( 0 ) >= 32 ) {
this . state . repoSearchQuery += str ;
this . state . selectedRepoIndex = 0 ;
this . render ( ) ;
}
return ;
}
// Search inactive keypresses
if ( ( key && key . name === 'escape' ) || str === '\u001b' ) {
this . state . screen = 'setup' ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'up' ) {
if ( this . state . selectedRepoIndex > 0 ) {
this . state . selectedRepoIndex -- ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'down' ) {
if ( this . state . selectedRepoIndex < filteredRepos . length - 1 ) {
this . state . selectedRepoIndex ++ ;
this . render ( ) ;
}
return ;
}
if ( str === '/' ) {
this . state . repoPickerActiveSearch = true ;
this . render ( ) ;
return ;
}
if ( ( key && key . name === 'return' ) || str === '\r' || str === '\n' ) {
if ( filteredRepos . length > 0 && this . state . selectedRepoIndex < filteredRepos . length ) {
const selectedRepo = filteredRepos [ this . state . selectedRepoIndex ] ;
const parts = selectedRepo . full_name . split ( '/' ) ;
this . state . config = {
url : normalizeUrl ( this . state . setupForm . url ) ,
token : this.state.setupForm.token.trim ( ) || null ,
userid : this.state.setupForm.userid.trim ( ) ,
owner : parts [ 0 ] ,
repo : parts [ 1 ] ,
} ;
2026-06-01 02:27:57 +00:00
if ( this . state . setupForm . saveConfig ) {
saveGlobalConfig ( {
url : this.state.config.url ,
userid : this.state.config.userid ,
token : this.state.config.token ,
repo : ` ${ parts [ 0 ] } / ${ parts [ 1 ] } ` ,
} ) ;
}
2026-06-01 02:02:23 +00:00
this . state . screen = 'list' ;
this . state . currentPage = 1 ;
this . state . selectedIssueIndex = 0 ;
this . state . searchQuery = '' ;
this . loadIssues ( ) ;
}
}
}
2026-06-01 00:59:22 +00:00
/ * *
* Key handling for searching overlay
* /
private handleSearchKeypress ( str : string , key : any ) {
if ( ( key && key . name === 'escape' ) || str === '\u001b' ) {
this . activeSearchInput = false ;
this . searchInputBuffer = '' ;
process . stdout . write ( '\x1B[?25l' ) ; // Hide cursor
this . render ( ) ;
return ;
}
if ( ( key && key . name === 'return' ) || str === '\r' || str === '\n' ) {
this . activeSearchInput = false ;
this . state . searchQuery = this . searchInputBuffer ;
this . state . currentPage = 1 ;
this . state . selectedIssueIndex = 0 ;
process . stdout . write ( '\x1B[?25l' ) ; // Hide cursor
this . loadIssues ( ) ;
return ;
}
if ( key && key . name === 'backspace' ) {
this . searchInputBuffer = this . searchInputBuffer . slice ( 0 , - 1 ) ;
this . render ( ) ;
return ;
}
if ( str && ! key . ctrl && ! key . meta && str . length === 1 && str . charCodeAt ( 0 ) >= 32 ) {
this . searchInputBuffer += str ;
this . render ( ) ;
}
}
/ * *
* Key handling for Dashboard list
* /
private handleListKeypress ( str : string , key : any ) {
if ( this . state . loading ) return ;
if ( ( key && key . name === 'escape' ) || str === '\u001b' ) {
2026-06-01 02:02:23 +00:00
this . stop ( ) ;
return ;
}
if ( str === 'o' || str === 'O' ) {
this . state . screen = 'setup' ;
this . state . error = null ;
this . render ( ) ;
2026-06-01 00:59:22 +00:00
return ;
}
if ( key && key . name === 'up' ) {
if ( this . state . selectedIssueIndex > 0 ) {
this . state . selectedIssueIndex -- ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'down' ) {
if ( this . state . selectedIssueIndex < this . state . issues . length - 1 ) {
this . state . selectedIssueIndex ++ ;
this . render ( ) ;
}
return ;
}
// Pagination: Right/PageDown -> Next, Left/PageUp -> Prev
if ( ( key && key . name === 'right' ) || ( key && key . name === 'pagedown' ) || str === 'n' ) {
const maxPages = Math . ceil ( this . state . totalIssuesCount / this . state . issuesPerPage ) ;
if ( this . state . currentPage < maxPages ) {
this . state . currentPage ++ ;
this . state . selectedIssueIndex = 0 ;
this . loadIssues ( ) ;
}
return ;
}
if ( ( key && key . name === 'left' ) || ( key && key . name === 'pageup' ) || str === 'p' ) {
if ( this . state . currentPage > 1 ) {
this . state . currentPage -- ;
this . state . selectedIssueIndex = 0 ;
this . loadIssues ( ) ;
}
return ;
}
if ( ( key && key . name === 'return' ) || str === '\r' || str === '\n' ) {
if ( this . state . issues . length > 0 ) {
const issue = this . state . issues [ this . state . selectedIssueIndex ] ;
this . state . selectedIssue = issue ;
this . state . detailScrollOffset = 0 ;
this . state . screen = 'details' ;
this . loadComments ( issue ) ;
}
return ;
}
// Toggle Filters & Sorting
if ( str === '/' ) {
// Focus Search input
this . activeSearchInput = true ;
this . searchInputBuffer = this . state . searchQuery ;
this . render ( ) ;
return ;
}
if ( str === 's' || str === 'S' ) {
// Cycle sorts: created -> updated -> comments
const fields : Array < 'created' | 'updated' | 'comments' > = [ 'created' , 'updated' , 'comments' ] ;
const currentSortIdx = fields . indexOf ( this . state . sortField ) ;
// If desc, switch to asc. If asc, switch to next field desc!
if ( this . state . sortOrder === 'desc' ) {
this . state . sortOrder = 'asc' ;
} else {
this . state . sortOrder = 'desc' ;
this . state . sortField = fields [ ( currentSortIdx + 1 ) % fields . length ] ;
}
this . state . currentPage = 1 ;
this . state . selectedIssueIndex = 0 ;
this . loadIssues ( ) ;
return ;
}
if ( str === 'f' || str === 'F' ) {
// Cycle states: open -> closed -> all
const states : Array < 'open' | 'closed' | 'all' > = [ 'open' , 'closed' , 'all' ] ;
const currentIdx = states . indexOf ( this . state . stateFilter ) ;
this . state . stateFilter = states [ ( currentIdx + 1 ) % states . length ] ;
this . state . currentPage = 1 ;
this . state . selectedIssueIndex = 0 ;
this . loadIssues ( ) ;
return ;
}
if ( str === 't' || str === 'T' ) {
// Cycle type: issues -> pulls -> all
const types : Array < 'issues' | 'pulls' | 'all' > = [ 'issues' , 'pulls' , 'all' ] ;
const currentIdx = types . indexOf ( this . state . typeFilter ) ;
this . state . typeFilter = types [ ( currentIdx + 1 ) % types . length ] ;
this . state . currentPage = 1 ;
this . state . selectedIssueIndex = 0 ;
this . loadIssues ( ) ;
return ;
}
if ( str === 'r' || str === 'R' ) {
// Refresh
this . loadIssues ( ) ;
return ;
}
if ( str === 'q' || str === 'Q' ) {
this . stop ( ) ;
}
}
/ * *
* Key handling for detail screen scrolling
* /
private handleDetailsKeypress ( str : string , key : any ) {
if ( key && key . name === 'up' ) {
if ( this . state . detailScrollOffset > 0 ) {
this . state . detailScrollOffset -- ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'down' ) {
// We will bounds check this dynamically based on content length during render
this . state . detailScrollOffset ++ ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'pageup' ) {
this . state . detailScrollOffset = Math . max ( 0 , this . state . detailScrollOffset - 10 ) ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'pagedown' ) {
this . state . detailScrollOffset += 10 ;
this . render ( ) ;
return ;
}
2026-06-01 02:02:23 +00:00
if ( str === 'o' || str === 'O' ) {
this . state . screen = 'setup' ;
this . state . selectedIssue = null ;
this . state . selectedIssueComments = [ ] ;
this . state . error = null ;
this . render ( ) ;
return ;
}
2026-06-01 00:59:22 +00:00
if ( ( key && ( key . name === 'escape' || key . name === 'backspace' ) ) || str === '\u001b' || str === 'q' || str === 'Q' ) {
this . state . screen = 'list' ;
this . state . selectedIssue = null ;
this . state . selectedIssueComments = [ ] ;
this . render ( ) ;
}
}
/ * *
* Renders the current screen to stdout
* /
public render() {
const cols = process . stdout . columns || 80 ;
const rows = process . stdout . rows || 24 ;
2026-06-01 02:40:52 +00:00
// Clear terminal screen, clear scrollback buffer, and reset cursor position
process . stdout . write ( '\x1B[2J\x1B[3J\x1B[H' ) ;
2026-06-01 00:59:22 +00:00
if ( this . state . screen === 'setup' ) {
this . renderSetupScreen ( cols , rows ) ;
2026-06-01 02:02:23 +00:00
} else if ( this . state . screen === 'repo-picker' ) {
this . renderRepoPickerScreen ( cols , rows ) ;
2026-06-01 00:59:22 +00:00
} else if ( this . state . screen === 'list' ) {
this . renderListScreen ( cols , rows ) ;
} else if ( this . state . screen === 'details' ) {
this . renderDetailsScreen ( cols , rows ) ;
}
}
/ * *
* Draw Setup Form
* /
private renderSetupScreen ( cols : number , rows : number ) {
const form = this . state . setupForm ;
const width = Math . min ( 68 , cols - 4 ) ;
const border = '│' ;
const lines : string [ ] = [ ] ;
lines . push ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( width - 2 ) + '┐' ) ) ;
const title = 'FORGEJO & GITEA TUI ISSUE EXPLORER' ;
const titlePadding = ' ' . repeat ( Math . max ( 0 , Math . floor ( ( width - 2 - title . length ) / 2 ) ) ) ;
const titleLine = border + titlePadding + chalk . bold . white ( title ) + ' ' . repeat ( width - 2 - titlePadding . length - title . length ) + border ;
lines . push ( titleLine ) ;
lines . push ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( width - 2 ) + '┤' ) ) ;
// Instructions
lines . push ( border + ' ' . repeat ( width - 2 ) + border ) ;
const desc = 'Please connect to a Forgejo/Gitea server to explore repository issues.' ;
lines . push ( border + ' ' + chalk . gray ( desc . padEnd ( width - 4 ) ) + ' ' + border ) ;
lines . push ( border + ' ' . repeat ( width - 2 ) + border ) ;
// Form inputs
const renderField = ( label : string , value : string , active : boolean , secret : boolean = false ) = > {
const fieldWidth = width - 8 ;
const displayVal = secret ? '*' . repeat ( value . length ) : value ;
const cursorStr = active ? chalk . inverse ( ' ' ) : '' ;
const content = label . padEnd ( 16 ) + ': [ ' +
( active ? chalk . bold . cyan ( displayVal ) + cursorStr : chalk.white ( displayVal ) ) . padEnd ( active ? displayVal . length + cursorStr . length + ( fieldWidth - 19 - displayVal . length ) : fieldWidth - 19 ) +
' ]' ;
const coloredContent = active ? chalk . bold . white ( content ) : chalk . gray ( content ) ;
const activeMarker = active ? chalk . cyan ( '▶ ' ) : ' ' ;
return border + ' ' + activeMarker + coloredContent . padEnd ( width - 6 ) + ' ' + border ;
} ;
2026-06-01 02:27:57 +00:00
const renderCheckbox = ( label : string , checked : boolean , active : boolean ) = > {
const activeMarker = active ? chalk . cyan ( '▶ ' ) : ' ' ;
const box = checked ? '[X]' : '[ ]' ;
const content = label . padEnd ( 16 ) + ': ' + ( active ? chalk . bold . cyan ( box ) : chalk . white ( box ) ) ;
const coloredContent = active ? chalk . bold . white ( content ) : chalk . gray ( content ) ;
return border + ' ' + activeMarker + coloredContent . padEnd ( width - 6 ) + ' ' + border ;
} ;
2026-06-01 00:59:22 +00:00
lines . push ( renderField ( 'Gitea URL' , form . url , form . activeField === 'url' ) ) ;
lines . push ( border + ' ' . repeat ( width - 2 ) + border ) ;
2026-06-01 02:02:23 +00:00
lines . push ( renderField ( 'User ID' , form . userid , form . activeField === 'userid' ) ) ;
lines . push ( border + ' ' + chalk . gray ( 'Forgejo/Gitea username (e.g. gitea_admin)' ) . padEnd ( width - 5 ) + border ) ;
2026-06-01 00:59:22 +00:00
lines . push ( border + ' ' . repeat ( width - 2 ) + border ) ;
lines . push ( renderField ( 'Access Token' , form . token , form . activeField === 'token' , true ) ) ;
lines . push ( border + ' ' + chalk . gray ( 'Optional. Required for private repos.' ) . padEnd ( width - 5 ) + border ) ;
lines . push ( border + ' ' . repeat ( width - 2 ) + border ) ;
2026-06-01 02:27:57 +00:00
lines . push ( renderCheckbox ( 'Save to config' , form . saveConfig , form . activeField === 'saveConfig' ) ) ;
lines . push ( border + ' ' + chalk . gray ( 'Saves settings to ~/.config/fjtui/fjtui.json' ) . padEnd ( width - 5 ) + border ) ;
lines . push ( border + ' ' . repeat ( width - 2 ) + border ) ;
2026-06-01 00:59:22 +00:00
// Error message
if ( this . state . error ) {
lines . push ( chalk . bold . red ( '├' + '─' . repeat ( width - 2 ) + '┤' ) ) ;
const errorWrapped = wordWrap ( this . state . error , width - 4 ) ;
for ( const errLine of errorWrapped ) {
lines . push ( border + ' ' + chalk . bold . red ( errLine . padEnd ( width - 4 ) ) + ' ' + border ) ;
}
}
lines . push ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( width - 2 ) + '┘' ) ) ;
// Loading / Footer indicator
const spinner = this . state . loading ? chalk . cyan ( SPINNER_FRAMES [ spinnerIndex ] ) : ' ' ;
2026-06-01 02:27:57 +00:00
const prompt = this . state . loading ? ' Connecting to instance...' : ' [Tab/Arrows] Navigate [Space/Enter] Toggle Checkbox [Enter] Connect [Esc] Quit' ;
2026-06-01 00:59:22 +00:00
// Draw centered on terminal
const vertPadding = Math . max ( 0 , Math . floor ( ( rows - lines . length - 2 ) / 2 ) ) ;
process . stdout . write ( '\n' . repeat ( vertPadding ) ) ;
for ( const line of lines ) {
console . log ( ' ' . repeat ( Math . max ( 0 , Math . floor ( ( cols - width ) / 2 ) ) ) + line ) ;
}
const footerText = ' ' . repeat ( Math . max ( 0 , Math . floor ( ( cols - width ) / 2 ) ) ) + spinner + chalk . gray ( prompt ) ;
console . log ( '\n' + footerText ) ;
// Cursor trick: if typing, place cursor at appropriate spot
if ( ! this . state . loading ) {
// Find current active field offset row
let activeRowOffset = vertPadding + 6 ; // URL row
2026-06-01 02:02:23 +00:00
if ( form . activeField === 'userid' ) activeRowOffset = vertPadding + 8 ;
if ( form . activeField === 'token' ) activeRowOffset = vertPadding + 11 ;
2026-06-01 02:27:57 +00:00
if ( form . activeField === 'saveConfig' ) activeRowOffset = vertPadding + 14 ;
2026-06-01 00:59:22 +00:00
2026-06-01 02:27:57 +00:00
let cursorCol = Math . max ( 0 , Math . floor ( ( cols - width ) / 2 ) ) ;
if ( form . activeField === 'saveConfig' ) {
cursorCol += 24 ; // positions cursor inside the checkbox brackets [ ]
} else {
const activeValue = form . activeField === 'token' ? '*' . repeat ( form . token . length ) : form [ form . activeField ] ;
cursorCol += 25 + activeValue . length ;
}
2026-06-01 00:59:22 +00:00
process . stdout . write ( ` \ x1B[ ${ activeRowOffset + 1 } ; ${ cursorCol } H \ x1B[?25h ` ) ; // Show cursor
}
}
/ * *
* Draw Issues List
* /
private renderListScreen ( cols : number , rows : number ) {
// Header Bar
const spinnerStr = this . state . loading ? chalk . bold . cyan ( SPINNER_FRAMES [ spinnerIndex ] ) + ' ' : '' ;
const instanceName = normalizeUrl ( this . state . config . url ) . replace ( /^https?:\/\// , '' ) ;
2026-06-01 02:40:52 +00:00
const rightHeader = ` repo: ${ chalk . bold . cyan ( ` ${ this . state . config . owner } / ${ this . state . config . repo } ` ) } ` ;
const rightLen = stripAnsi ( rightHeader ) . length ;
// We want the total line length to fit exactly within 'cols', so max left side width is cols - rightLen - 1
const maxLeftWidth = cols - rightLen - 1 ;
// Spinner plain length is 2 if loading, 0 otherwise
const spinnerPlainLen = this . state . loading ? 2 : 0 ;
const titlePlain = 'Forgejo Issue Explorer' ;
const titleColor = chalk . bold . hex ( '#4A90E2' ) ( titlePlain ) ;
2026-06-01 00:59:22 +00:00
2026-06-01 02:40:52 +00:00
const instanceColor = ` ─ ${ chalk . bold . white ( instanceName ) } ` ;
const instancePlain = ` ─ ${ instanceName } ` ;
let statsColor = '' ;
let statsPlain = '' ;
let shortStatsColor = '' ;
let shortStatsPlain = '' ;
2026-06-01 00:59:22 +00:00
if ( this . state . totalIssuesCount > 0 ) {
const totalPages = Math . ceil ( this . state . totalIssuesCount / this . state . issuesPerPage ) ;
2026-06-01 02:40:52 +00:00
statsColor = ` ─ Page ${ chalk . bold . white ( this . state . currentPage ) } of ${ chalk . bold . white ( totalPages ) } ( ${ chalk . yellow ( this . state . totalIssuesCount ) } matching) ` ;
statsPlain = ` ─ Page ${ this . state . currentPage } of ${ totalPages } ( ${ this . state . totalIssuesCount } matching) ` ;
shortStatsColor = ` ─ P. ${ chalk . bold . white ( this . state . currentPage ) } / ${ chalk . bold . white ( totalPages ) } ( ${ chalk . yellow ( this . state . totalIssuesCount ) } ) ` ;
shortStatsPlain = ` ─ P. ${ this . state . currentPage } / ${ totalPages } ( ${ this . state . totalIssuesCount } ) ` ;
2026-06-01 00:59:22 +00:00
} else if ( ! this . state . loading ) {
2026-06-01 02:40:52 +00:00
statsColor = ' ─ (0 issues found)' ;
statsPlain = ' ─ (0 issues found)' ;
shortStatsColor = ' ─ (0)' ;
shortStatsPlain = ' ─ (0)' ;
2026-06-01 00:59:22 +00:00
}
2026-06-01 02:40:52 +00:00
let leftHeader = '' ;
// Attempt 1: Full header: spinner + Title + instance + stats
if ( 1 + spinnerPlainLen + titlePlain . length + instancePlain . length + statsPlain . length <= maxLeftWidth ) {
leftHeader = ` ${ spinnerStr } ${ titleColor } ${ instanceColor } ${ statsColor } ` ;
}
// Attempt 2: Drop instance, keep full stats
else if ( 1 + spinnerPlainLen + titlePlain . length + statsPlain . length <= maxLeftWidth ) {
leftHeader = ` ${ spinnerStr } ${ titleColor } ${ statsColor } ` ;
}
// Attempt 3: Drop instance, use short stats
else if ( 1 + spinnerPlainLen + titlePlain . length + shortStatsPlain . length <= maxLeftWidth ) {
leftHeader = ` ${ spinnerStr } ${ titleColor } ${ shortStatsColor } ` ;
}
// Attempt 4: Drop stats, keep title + shortened instance if it fits
else if ( 1 + spinnerPlainLen + titlePlain . length + instancePlain . length <= maxLeftWidth ) {
leftHeader = ` ${ spinnerStr } ${ titleColor } ${ instanceColor } ` ;
}
// Attempt 5: Just Title
else {
leftHeader = ` ${ spinnerStr } ${ titleColor } ` ;
}
2026-06-01 02:27:57 +00:00
const leftLen = stripAnsi ( leftHeader ) . length ;
let spacesCount = cols - leftLen - rightLen ;
if ( spacesCount < 1 ) spacesCount = 1 ;
2026-06-01 02:46:36 +00:00
console . log ( leftHeader + ' ' . repeat ( spacesCount - 1 ) + rightHeader ) ;
2026-06-01 00:59:22 +00:00
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( cols - 2 ) + '┐' ) ) ;
// Column widths
const idWidth = 6 ;
const typeWidth = 5 ;
const stateWidth = 7 ;
const authorWidth = 14 ;
const createdWidth = 12 ;
const commentsWidth = 6 ;
// Title takes all remaining space
const titleWidth = Math . max ( 20 , cols - idWidth - typeWidth - stateWidth - authorWidth - createdWidth - commentsWidth - 8 ) ;
// Render Table Header
const padHeader = ( title : string , w : number ) = > chalk . bold . white ( title . padEnd ( w ) ) ;
const borderCh = chalk . bold . hex ( '#4A90E2' ) ( '│' ) ;
console . log (
borderCh + ' ' +
padHeader ( 'ID' , idWidth ) + borderCh + ' ' +
padHeader ( 'Type' , typeWidth ) + borderCh + ' ' +
padHeader ( 'State' , stateWidth ) + borderCh + ' ' +
padHeader ( 'Title' , titleWidth ) + borderCh + ' ' +
padHeader ( 'Author' , authorWidth ) + borderCh + ' ' +
padHeader ( 'Created' , createdWidth ) + borderCh + ' ' +
padHeader ( 'Coms' , commentsWidth ) + borderCh
) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
2026-06-01 02:46:36 +00:00
// Table Content Height available (rows - 10 ensures total printed lines is rows - 2, preventing terminal scrolling)
const tableHeight = rows - 10 ;
2026-06-01 00:59:22 +00:00
if ( this . state . issues . length === 0 ) {
const msg = this . state . loading ? 'Fetching issues from server...' : ( this . state . error ? ` Error: ${ this . state . error } ` : 'No issues found.' ) ;
const paddingRows = Math . floor ( ( tableHeight - 1 ) / 2 ) ;
for ( let i = 0 ; i < paddingRows ; i ++ ) console . log ( borderCh + ' ' . repeat ( cols - 2 ) + borderCh ) ;
const contentLine = ( ' ' . repeat ( Math . max ( 0 , Math . floor ( ( cols - 2 - msg . length ) / 2 ) ) ) + msg ) . padEnd ( cols - 2 ) ;
console . log ( borderCh + ( this . state . error ? chalk . bold . red ( contentLine ) : chalk . gray ( contentLine ) ) + borderCh ) ;
for ( let i = 0 ; i < tableHeight - paddingRows - 1 ; i ++ ) console . log ( borderCh + ' ' . repeat ( cols - 2 ) + borderCh ) ;
} else {
// Loop through issues on current page
for ( let i = 0 ; i < tableHeight ; i ++ ) {
if ( i < this . state . issues . length ) {
const issue = this . state . issues [ i ] ;
const isSelected = i === this . state . selectedIssueIndex ;
const idStr = ` # ${ issue . number } ` . padEnd ( idWidth ) ;
const typeStr = ( issue . pull_request ? 'PR' : 'Issue' ) . padEnd ( typeWidth ) ;
let stateStr = issue . state . toUpperCase ( ) . padEnd ( stateWidth ) ;
if ( issue . state === 'open' ) {
stateStr = issue . pull_request ? chalk . bold . magenta ( stateStr ) : chalk . bold . green ( stateStr ) ;
} else {
stateStr = chalk . bold . red ( stateStr ) ;
}
const titleStr = truncate ( issue . title , titleWidth ) . padEnd ( titleWidth ) ;
const authorStr = truncate ( issue . user . login , authorWidth ) . padEnd ( authorWidth ) ;
const createdStr = formatDate ( issue . created_at ) . substring ( 0 , 10 ) . padEnd ( createdWidth ) ;
const commentsStr = String ( issue . comments ) . padEnd ( commentsWidth ) ;
let rowText =
' ' + idStr + borderCh +
' ' + typeStr + borderCh +
' ' + stateStr + borderCh +
' ' + ( issue . pull_request ? chalk . magenta ( titleStr ) : titleStr ) + borderCh +
' ' + authorStr + borderCh +
' ' + createdStr + borderCh +
' ' + commentsStr + borderCh ;
if ( isSelected ) {
rowText = chalk . bgHex ( '#2E4E7E' ) . white . bold ( rowText ) ;
}
console . log ( borderCh + rowText ) ;
} else {
// Fill empty space
console . log ( borderCh + ' ' . repeat ( cols - 2 ) + borderCh ) ;
}
}
}
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
// Render Filters line
let searchLabel = this . state . searchQuery ? chalk . yellow ( ` " ${ this . state . searchQuery } " ` ) : chalk . gray ( 'none' ) ;
if ( this . activeSearchInput ) {
searchLabel = chalk . inverse ( this . searchInputBuffer + ' ' ) ;
}
const stateFilterLabel = chalk . bold ( this . state . stateFilter . toUpperCase ( ) ) ;
const typeFilterLabel = chalk . bold ( this . state . typeFilter . toUpperCase ( ) ) ;
const sortLabel = chalk . bold ( ` ${ this . state . sortField } ( ${ this . state . sortOrder . toUpperCase ( ) } ) ` ) ;
const filtersText = ` Search: ${ searchLabel } ─ State: ${ stateFilterLabel } ─ Type: ${ typeFilterLabel } ─ Sort: ${ sortLabel } ` ;
console . log ( borderCh + filtersText . padEnd ( cols - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
// Keyboard controls help line
2026-06-01 02:02:23 +00:00
const helpLine = chalk . gray ( ' [↑/↓] Navigate [Enter] View [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [O] Settings [Esc] Quit' ) ;
2026-06-01 00:59:22 +00:00
process . stdout . write ( helpLine ) ;
// If search active, position terminal cursor in search box
if ( this . activeSearchInput ) {
const searchCursorCol = 11 + this . searchInputBuffer . length ;
process . stdout . write ( ` \ x1B[ ${ rows - 1 } ; ${ searchCursorCol } H \ x1B[?25h ` ) ; // Show cursor
}
}
/ * *
* Draw Issue Detail scrollable view
* /
private renderDetailsScreen ( cols : number , rows : number ) {
const issue = this . state . selectedIssue ;
if ( ! issue ) return ;
// Header info line
const isPR = ! ! issue . pull_request ;
const typeTag = isPR ? chalk . bold . magenta ( '[PR] ' ) : chalk . bold . green ( '[Issue] ' ) ;
2026-06-01 02:27:57 +00:00
const rightHeader = ` repo: ${ chalk . bold . cyan ( ` ${ this . state . config . owner } / ${ this . state . config . repo } ` ) } ` ;
const rightLen = stripAnsi ( rightHeader ) . length ;
const maxLeftWidth = cols - rightLen - 2 ;
const prefix = ` ${ typeTag } # ${ issue . number } ─ ` ;
const prefixLen = stripAnsi ( prefix ) . length ;
const titleWidth = Math . max ( 10 , maxLeftWidth - prefixLen ) ;
const leftHeader = prefix + chalk . bold . white ( truncate ( issue . title , titleWidth ) ) ;
const leftLen = stripAnsi ( leftHeader ) . length ;
let spacesCount = cols - leftLen - rightLen ;
if ( spacesCount < 1 ) spacesCount = 1 ;
console . log ( leftHeader + ' ' . repeat ( spacesCount ) + rightHeader ) ;
2026-06-01 00:59:22 +00:00
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( cols - 2 ) + '┐' ) ) ;
// Metadata lines
const borderCh = chalk . bold . hex ( '#4A90E2' ) ( '│' ) ;
let stateLabel = issue . state . toUpperCase ( ) ;
if ( issue . state === 'open' ) {
stateLabel = isPR ? chalk . bold . magenta ( stateLabel ) : chalk . bold . green ( stateLabel ) ;
} else {
stateLabel = chalk . bold . red ( stateLabel ) ;
}
const labels = issue . labels . map ( l = > chalk . bgHex ( '#' + l . color ) . black ( ` ${ l . name } ` ) ) . join ( ' ' ) ;
console . log ( borderCh + ` State: ${ stateLabel } Author: ${ chalk . cyan ( issue . user . login ) } Created: ${ formatDate ( issue . created_at ) } Updated: ${ formatDate ( issue . updated_at ) } ` . padEnd ( cols - 2 ) + borderCh ) ;
if ( labels ) {
console . log ( borderCh + ` Labels: ${ labels } ` . padEnd ( cols - 2 + labels . length - issue . labels . map ( l = > l . name . length + 10 ) . reduce ( ( a , b ) = > a + b , 0 ) ) + borderCh ) ; // offset for raw ANSI chars
}
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
// Gather and format all detail text (Body + Comments)
const contentLines : string [ ] = [ ] ;
// 1. Render Body
contentLines . push ( chalk . bold . yellow ( '--- DESCRIPTION ---' ) ) ;
if ( issue . body . trim ( ) ) {
const wrappedBody = wordWrap ( issue . body , cols - 6 ) ;
contentLines . push ( . . . wrappedBody ) ;
} else {
contentLines . push ( chalk . italic . gray ( 'No description provided.' ) ) ;
}
contentLines . push ( '' ) ;
// 2. Render Comments Section
contentLines . push ( chalk . bold . yellow ( ` --- COMMENTS ( ${ issue . comments } ) --- ` ) ) ;
if ( this . state . commentsLoading ) {
contentLines . push ( chalk . cyan ( ' Loading comments...' ) ) ;
} else if ( this . state . selectedIssueComments . length === 0 ) {
contentLines . push ( chalk . italic . gray ( ' No comments.' ) ) ;
} else {
for ( const comment of this . state . selectedIssueComments ) {
contentLines . push ( chalk . bold . cyan ( ` ▶ ${ comment . user . login } ` ) + chalk . gray ( ` at ${ formatDate ( comment . created_at ) } ` ) ) ;
const wrappedComment = wordWrap ( comment . body , cols - 8 ) ;
for ( const cLine of wrappedComment ) {
contentLines . push ( ' ' + cLine ) ;
}
contentLines . push ( '' ) ; // blank line between comments
}
}
// Paginate/Scroll calculations
2026-06-01 02:40:52 +00:00
const metaHeight = labels ? 3 : 2 ; // Meta 1, optionally Meta 2 (Labels), and Separator
const displayHeight = rows - 5 - metaHeight ; // Header: 1, Borders: 2, Meta lines, Help: 1, and -1 to avoid scrolling
2026-06-01 00:59:22 +00:00
const maxScroll = Math . max ( 0 , contentLines . length - displayHeight ) ;
// Bounds check scroll offset
if ( this . state . detailScrollOffset > maxScroll ) {
this . state . detailScrollOffset = maxScroll ;
}
// Print visible window of content
for ( let r = 0 ; r < displayHeight ; r ++ ) {
const lineIndex = r + this . state . detailScrollOffset ;
if ( lineIndex < contentLines . length ) {
const line = contentLines [ lineIndex ] ;
console . log ( borderCh + ' ' + line . padEnd ( cols - 4 ) + ' ' + borderCh ) ;
} else {
console . log ( borderCh + ' ' . repeat ( cols - 2 ) + borderCh ) ;
}
}
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
// Footer Scroll percentage
let scrollHelp = ' Scroll: [↑/↓] / [PgUp/PgDn]' ;
if ( maxScroll > 0 ) {
const pct = Math . round ( ( this . state . detailScrollOffset / maxScroll ) * 100 ) ;
scrollHelp += chalk . yellow ( ` ( ${ pct } %) ` ) ;
}
2026-06-01 02:02:23 +00:00
const helpLine = chalk . gray ( ` [Esc/Backspace] Back to List [O] Settings ${ scrollHelp } ` ) ;
process . stdout . write ( helpLine ) ;
}
/ * *
* Draw Repository Picker Screen
* /
private renderRepoPickerScreen ( cols : number , rows : number ) {
const title = 'SELECT REPOSITORY' ;
const subtitle = ` Connected to ${ normalizeUrl ( this . state . setupForm . url ) . replace ( /^https?:\/\// , '' ) } as ${ this . state . setupForm . userid } ` ;
console . log ( ` ${ chalk . bold . hex ( '#4A90E2' ) ( title ) } ─ ${ chalk . gray ( subtitle ) } ` ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( cols - 2 ) + '┐' ) ) ;
const borderCh = chalk . bold . hex ( '#4A90E2' ) ( '│' ) ;
// Filter repositories based on query
const filteredRepos = this . state . repos . filter ( r = >
r . full_name . toLowerCase ( ) . includes ( this . state . repoSearchQuery . toLowerCase ( ) ) ||
( r . description && r . description . toLowerCase ( ) . includes ( this . state . repoSearchQuery . toLowerCase ( ) ) )
) ;
const listHeight = rows - 7 ; // Header, borders, search, footer
// Scroll window calculation
let startIndex = 0 ;
if ( this . state . selectedRepoIndex >= listHeight ) {
startIndex = this . state . selectedRepoIndex - listHeight + 1 ;
}
if ( filteredRepos . length === 0 ) {
const msg = this . state . repoSearchQuery ? 'No repositories match your filter.' : 'No repositories found.' ;
const paddingRows = Math . floor ( ( listHeight - 1 ) / 2 ) ;
for ( let i = 0 ; i < paddingRows ; i ++ ) console . log ( borderCh + ' ' . repeat ( cols - 2 ) + borderCh ) ;
console . log ( borderCh + ( ' ' . repeat ( Math . max ( 0 , Math . floor ( ( cols - 2 - msg . length ) / 2 ) ) ) + chalk . gray ( msg ) ) . padEnd ( cols - 2 ) + borderCh ) ;
for ( let i = 0 ; i < listHeight - paddingRows - 1 ; i ++ ) console . log ( borderCh + ' ' . repeat ( cols - 2 ) + borderCh ) ;
} else {
for ( let i = 0 ; i < listHeight ; i ++ ) {
const repoIdx = i + startIndex ;
if ( repoIdx < filteredRepos . length ) {
const repo = filteredRepos [ repoIdx ] ;
const isSelected = repoIdx === this . state . selectedRepoIndex ;
const typeStr = repo . private ? chalk . bold . yellow ( '[Private]' ) : chalk . bold . green ( '[Public] ' ) ;
const nameStr = chalk . bold ( repo . full_name ) ;
const descStr = repo . description ? chalk . gray ( ` ─ ${ truncate ( repo . description , cols - repo . full_name . length - 20 ) } ` ) : '' ;
let lineContent = ` ${ typeStr } ${ nameStr } ${ descStr } ` ;
// Pad the line content properly to match terminal width
const visibleLen = stripAnsi ( lineContent ) . length ;
const padding = ' ' . repeat ( Math . max ( 0 , cols - 4 - visibleLen ) ) ;
lineContent = lineContent + padding ;
if ( isSelected ) {
lineContent = chalk . bgHex ( '#2E4E7E' ) . white . bold ( lineContent ) ;
}
console . log ( borderCh + ' ' + lineContent + ' ' + borderCh ) ;
} else {
console . log ( borderCh + ' ' . repeat ( cols - 2 ) + borderCh ) ;
}
}
}
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
// Filter status line
let filterLabel = this . state . repoSearchQuery ? chalk . yellow ( ` " ${ this . state . repoSearchQuery } " ` ) : chalk . gray ( 'none' ) ;
if ( this . state . repoPickerActiveSearch ) {
filterLabel = chalk . inverse ( this . state . repoSearchQuery + ' ' ) ;
}
const filterText = ` Filter: ${ filterLabel } ` ;
console . log ( borderCh + filterText . padEnd ( cols - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
const helpLine = chalk . gray ( ' [↑/↓] Navigate [Enter] Select [/] Filter [Esc] Back to Setup' ) ;
2026-06-01 00:59:22 +00:00
process . stdout . write ( helpLine ) ;
2026-06-01 02:02:23 +00:00
if ( this . state . repoPickerActiveSearch ) {
const searchCursorCol = 10 + this . state . repoSearchQuery . length ;
process . stdout . write ( ` \ x1B[ ${ rows - 1 } ; ${ searchCursorCol } H \ x1B[?25h ` ) ; // Show cursor
}
2026-06-01 00:59:22 +00:00
}
}
2026-06-01 02:02:23 +00:00