2026-06-01 00:59:22 +00:00
import readline from 'readline' ;
import chalk from 'chalk' ;
2026-06-02 22:31:24 +00:00
import fs from 'fs' ;
import path from 'path' ;
import { fileURLToPath } from 'url' ;
2026-06-01 00:59:22 +00:00
import { AppState , Issue , Comment } from './types.js' ;
2026-06-02 23:42:07 +00:00
import { fetchIssues , fetchIssue , fetchIssueComments , validateConnection , normalizeUrl , authenticateAndFetchRepos , createIssue , createIssueComment , editIssue , addIssueTime , changeIssueState , setIssueAssignees , fetchLabels , createLabel , updateLabel , deleteLabel } 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 ;
2026-06-02 00:36:39 +00:00
const GRAVESTONE_FRAMES = [
[
chalk . gray ( ' ' ) ,
chalk . gray ( ' ' ) ,
chalk . gray ( ' ' ) ,
chalk . gray ( ' ' ) ,
chalk . gray ( ' ___________ ' ) ,
] ,
[
chalk . gray ( ' ' ) ,
chalk . gray ( ' ' ) ,
chalk . gray ( ' ' ) ,
chalk . gray ( ' ___ ' ) ,
chalk . gray ( ' ___/___\\___ ' ) ,
] ,
[
chalk . gray ( ' ' ) ,
chalk . gray ( ' ' ) ,
chalk . gray ( ' ___ ' ) ,
chalk . gray ( ' / \\ ' ) ,
chalk . gray ( ' __|_____|__ ' ) ,
] ,
[
chalk . gray ( ' ' ) ,
chalk . gray ( ' ___ ' ) ,
chalk . gray ( ' / \\ ' ) ,
chalk . gray ( ' | RIP | ' ) ,
chalk . gray ( ' __|_____|__ ' ) ,
] ,
[
chalk . gray ( ' ___ ' ) ,
chalk . gray ( ' / \\ ' ) ,
chalk . gray ( ' | RIP | ' ) ,
chalk . gray ( ' | | ' ) ,
chalk . gray ( ' __|_____|__ ' ) ,
]
] ;
const ZOMBIE_FRAMES = [
[
chalk . gray ( ' ___ ' ) ,
chalk . gray ( ' / \\ ' ) ,
chalk . gray ( ' | RIP | ' ) ,
chalk . gray ( ' | | ' ) ,
chalk . gray ( ' __|_____|__ ' ) ,
] ,
[
chalk . gray ( ' ___ ' ) ,
chalk . gray ( ' / \\ ' ) ,
chalk . gray ( ' | RIP | ' ) ,
chalk . gray ( ' | | ' ) ,
chalk . gray ( ' __|_ ' ) + chalk . green ( '.' ) + chalk . gray ( ' _|__ ' ) ,
] ,
[
chalk . gray ( ' ___ ' ) ,
chalk . gray ( ' / \\ ' ) ,
chalk . gray ( ' | RIP | ' ) ,
chalk . gray ( ' | ' ) + chalk . green ( '^' ) + chalk . gray ( ' | ' ) ,
chalk . gray ( ' __|_' ) + chalk . green ( '/ \\' ) + chalk . gray ( '_|__ ' ) ,
] ,
[
chalk . gray ( ' ___ ' ) ,
chalk . gray ( ' / \\ ' ) ,
chalk . gray ( ' | ' ) + chalk . green ( 'o_o' ) + chalk . gray ( ' | ' ) ,
chalk . gray ( ' | ' ) + chalk . green ( '/|\\' ) + chalk . gray ( ' | ' ) ,
chalk . gray ( ' __|_ ' ) + chalk . green ( '|' ) + chalk . gray ( ' _|__ ' ) ,
] ,
[
chalk . gray ( ' ___ ' ) ,
chalk . gray ( ' /' ) + chalk . green ( 'o_o' ) + chalk . gray ( '\\ ' ) ,
chalk . gray ( ' | ' ) + chalk . green ( '/|\\' ) + chalk . gray ( ' | ' ) ,
chalk . gray ( ' | ' ) + chalk . green ( '|' ) + chalk . gray ( ' | ' ) ,
chalk . gray ( ' __|_' ) + chalk . green ( '/ \\' ) + chalk . gray ( '_|__ ' ) ,
] ,
[
chalk . gray ( ' \\' ) + chalk . green ( 'o_o' ) + chalk . gray ( '/ ' ) ,
chalk . gray ( ' / ' ) + chalk . green ( '|' ) + chalk . gray ( ' \\ ' ) ,
chalk . gray ( ' | ' ) + chalk . green ( '/ \\' ) + chalk . gray ( ' | ' ) ,
chalk . gray ( ' | | ' ) ,
chalk . gray ( ' __|_____|__ ' ) ,
]
] ;
2026-06-01 00:59:22 +00:00
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' ;
}
}
2026-06-01 23:34:20 +00:00
// Utility to safely truncate strings
2026-06-01 00:59:22 +00:00
function truncate ( str : string , length : number ) : string {
if ( str . length <= length ) return str ;
2026-06-01 23:34:20 +00:00
return str . substring ( 0 , length - 1 ) + '…' ;
}
// Utility to format seconds into h m format
function formatTime ( seconds? : number ) : string {
if ( ! seconds || seconds <= 0 ) return '0m' ;
const h = Math . floor ( seconds / 3600 ) ;
const m = Math . floor ( ( seconds % 3600 ) / 60 ) ;
if ( h > 0 && m > 0 ) return ` ${ h } h ${ m } m ` ;
if ( h > 0 ) return ` ${ h } h ` ;
return ` ${ m } m ` ;
2026-06-01 00:59:22 +00:00
}
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 = '' ;
2026-06-02 00:36:39 +00:00
private animationFrame : number = 0 ;
private animationInterval : NodeJS.Timeout | null = null ;
2026-06-02 22:31:24 +00:00
private launchDestScreen : 'list' | 'setup' = 'setup' ;
private launchFrame : string [ ] = [ ] ;
private launchFrameIndex = 0 ;
private launchInterval : NodeJS.Timeout | null = null ;
private maxLaunchFrames = 60 ;
2026-06-01 00:59:22 +00:00
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
2026-06-02 22:31:24 +00:00
// Bootstrap destination
2026-06-01 00:59:22 +00:00
if ( this . state . config . url && this . state . config . owner && this . state . config . repo ) {
2026-06-02 22:31:24 +00:00
this . launchDestScreen = 'list' ;
2026-06-01 00:59:22 +00:00
} else {
2026-06-02 22:31:24 +00:00
this . launchDestScreen = 'setup' ;
2026-06-01 00:59:22 +00:00
}
2026-06-02 22:31:24 +00:00
this . state . screen = 'launch' ;
this . startLaunchAnimation ( ) ;
2026-06-01 00:59:22 +00:00
}
/ * *
* Stops the TUI engine , restores terminal state .
* /
public stop() {
this . stopSpinner ( ) ;
2026-06-02 00:36:39 +00:00
if ( this . animationInterval ) {
clearInterval ( this . animationInterval ) ;
this . animationInterval = null ;
}
2026-06-02 22:31:24 +00:00
if ( this . launchInterval ) {
clearInterval ( this . launchInterval ) ;
this . launchInterval = null ;
}
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 ( ) ;
}
}
2026-06-01 23:20:58 +00:00
/ * *
* Fetches the current single issue and its comments again .
* /
private async reloadSingleIssue() {
if ( ! this . state . selectedIssue ) return ;
this . state . loading = true ;
this . state . error = null ;
this . startSpinner ( ) ;
try {
const issue = await fetchIssue ( this . state . config , this . state . selectedIssue . number ) ;
this . state . selectedIssue = issue ;
// Update in the main list as well
const listIndex = this . state . issues . findIndex ( i = > i . number === issue . number ) ;
if ( listIndex !== - 1 ) {
this . state . issues [ listIndex ] = issue ;
}
await this . loadComments ( issue ) ;
} catch ( err : any ) {
this . state . error = ` Failed to reload issue: ${ err . message } ` ;
} finally {
this . state . loading = false ;
this . stopSpinner ( ) ;
this . render ( ) ;
}
}
2026-06-01 00:59:22 +00:00
/ * *
* 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 ;
}
2026-06-02 22:31:24 +00:00
if ( this . state . screen === 'launch' ) {
this . skipLaunchAnimation ( ) ;
return ;
}
2026-06-01 00:59:22 +00:00
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 ) ;
2026-06-01 03:06:53 +00:00
} else if ( this . state . screen === 'create-issue' ) {
this . handleCreateIssueKeypress ( str , key ) ;
2026-06-01 03:18:12 +00:00
} else if ( this . state . screen === 'add-comment' ) {
this . handleAddCommentKeypress ( str , key ) ;
2026-06-01 23:15:01 +00:00
} else if ( this . state . screen === 'edit-issue' ) {
this . handleEditIssueKeypress ( str , key ) ;
2026-06-01 23:34:20 +00:00
} else if ( this . state . screen === 'add-time' ) {
this . handleAddTimeKeypress ( str , key ) ;
2026-06-02 18:41:33 +00:00
} else if ( this . state . screen === 'set-assignees' ) {
this . handleSetAssigneesKeypress ( str , key ) ;
2026-06-02 23:42:07 +00:00
} else if ( this . state . screen === 'labels-list' ) {
this . handleLabelsListKeypress ( str , key ) ;
} else if ( this . state . screen === 'create-label' || this . state . screen === 'edit-label' ) {
this . handleLabelFormKeypress ( str , key ) ;
2026-06-03 13:34:31 +00:00
} else if ( this . state . screen === 'help' ) {
this . handleHelpKeypress ( str , key ) ;
}
}
/ * *
* Key handling for Help screen
* /
private handleHelpKeypress ( str : string , key : any ) {
if ( ( key && ( key . name === 'escape' || key . name === 'return' ) ) || str === 'q' || str === 'Q' || str === '?' ) {
this . state . screen = this . state . previousScreen || 'list' ;
this . render ( ) ;
2026-06-01 00:59:22 +00:00
}
}
/ * *
* 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 ;
2026-06-03 13:34:31 +00:00
if ( key && key . name === 'tab' ) {
this . state . focusedPane = this . state . focusedPane === 'settings' ? 'list' : 'settings' ;
this . render ( ) ;
return ;
}
if ( this . state . focusedPane === 'settings' ) {
if ( key && key . name === 'left' ) {
this . state . selectedSettingIndex = ( this . state . selectedSettingIndex - 1 + 4 ) % 4 ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'right' ) {
this . state . selectedSettingIndex = ( this . state . selectedSettingIndex + 1 ) % 4 ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'up' ) {
return ;
}
if ( key && key . name === 'down' ) {
return ;
}
if ( ( key && key . name === 'return' ) || str === '\r' || str === '\n' ) {
const selIdx = this . state . selectedSettingIndex ;
if ( selIdx === 0 ) {
// Search
this . activeSearchInput = true ;
this . searchInputBuffer = this . state . searchQuery ;
this . render ( ) ;
} else if ( selIdx === 1 ) {
// State
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 ( ) ;
} else if ( selIdx === 2 ) {
// Type
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 ( ) ;
} else if ( selIdx === 3 ) {
// Sort
const fields : Array < 'created' | 'updated' | 'comments' | 'assignees' > = [ 'created' , 'updated' , 'comments' , 'assignees' ] ;
const currentSortIdx = fields . indexOf ( this . state . sortField ) ;
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 === '?' ) {
this . state . previousScreen = 'list' ;
this . state . screen = 'help' ;
this . render ( ) ;
return ;
}
2026-06-01 00:59:22 +00:00
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' ) {
2026-06-02 18:41:33 +00:00
// Cycle sorts: created -> updated -> comments -> assignees
const fields : Array < 'created' | 'updated' | 'comments' | 'assignees' > = [ 'created' , 'updated' , 'comments' , 'assignees' ] ;
2026-06-01 00:59:22 +00:00
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 ;
}
2026-06-01 03:06:53 +00:00
if ( str === 'c' || str === 'C' ) {
// Navigate to Create Issue Screen
this . state . screen = 'create-issue' ;
this . state . createIssueForm . title = '' ;
this . state . createIssueForm . body = '' ;
this . state . createIssueForm . activeField = 'title' ;
this . render ( ) ;
return ;
}
2026-06-01 00:59:22 +00:00
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 ;
}
2026-06-02 23:42:07 +00:00
if ( str === 'l' || str === 'L' ) {
this . state . screen = 'labels-list' ;
this . state . selectedLabelIndex = 0 ;
this . loadLabels ( ) ;
return ;
}
2026-06-01 00:59:22 +00:00
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 03:18:12 +00:00
if ( str === 'c' || str === 'C' ) {
this . state . screen = 'add-comment' ;
this . state . addCommentForm . body = '' ;
this . state . error = null ;
this . render ( ) ;
return ;
}
2026-06-01 02:02:23 +00:00
2026-06-01 23:15:01 +00:00
if ( str === 'e' || str === 'E' ) {
if ( this . state . selectedIssue ) {
this . state . screen = 'edit-issue' ;
this . state . editIssueForm . title = this . state . selectedIssue . title ;
this . state . editIssueForm . body = this . state . selectedIssue . body ;
this . state . editIssueForm . activeField = 'title' ;
this . state . error = null ;
this . render ( ) ;
}
return ;
}
2026-06-01 23:20:58 +00:00
if ( str === 'r' || str === 'R' ) {
if ( this . state . selectedIssue ) {
this . reloadSingleIssue ( ) ;
}
return ;
}
2026-06-01 23:15:01 +00:00
2026-06-01 23:34:20 +00:00
if ( str === 't' || str === 'T' ) {
if ( this . state . selectedIssue ) {
this . state . screen = 'add-time' ;
this . state . addTimeForm . timeInput = '' ;
this . state . error = null ;
this . render ( ) ;
}
return ;
}
2026-06-02 00:15:30 +00:00
if ( str === 'x' || str === 'X' ) {
if ( this . state . selectedIssue ) {
this . state . screen = 'confirm-state-change' ;
this . state . error = null ;
this . render ( ) ;
}
return ;
}
2026-06-02 18:41:33 +00:00
if ( str === 'a' || str === 'A' ) {
if ( this . state . selectedIssue ) {
this . state . screen = 'set-assignees' ;
this . state . assigneesInput = ( this . state . selectedIssue . assignees || [ ] ) . map ( u = > u . login ) . join ( ', ' ) ;
this . state . error = null ;
this . render ( ) ;
}
return ;
}
2026-06-03 13:34:31 +00:00
if ( str === '?' ) {
this . state . previousScreen = 'details' ;
this . state . screen = 'help' ;
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
2026-06-02 22:31:24 +00:00
if ( this . state . screen === 'launch' ) {
this . renderLaunchScreen ( cols , rows ) ;
} else if ( this . state . screen === 'setup' ) {
2026-06-01 00:59:22 +00:00
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 ) ;
2026-06-01 03:06:53 +00:00
} else if ( this . state . screen === 'create-issue' ) {
this . renderCreateIssueScreen ( cols , rows ) ;
2026-06-01 03:18:12 +00:00
} else if ( this . state . screen === 'add-comment' ) {
this . renderAddCommentScreen ( cols , rows ) ;
2026-06-01 23:15:01 +00:00
} else if ( this . state . screen === 'edit-issue' ) {
this . renderEditIssueScreen ( cols , rows ) ;
2026-06-01 23:34:20 +00:00
} else if ( this . state . screen === 'add-time' ) {
this . renderAddTimeScreen ( cols , rows ) ;
2026-06-02 00:15:30 +00:00
} else if ( this . state . screen === 'confirm-state-change' ) {
this . renderConfirmStateChangeScreen ( cols , rows ) ;
2026-06-02 00:36:39 +00:00
} else if ( this . state . screen === 'animating-close' ) {
this . renderAnimationScreen ( cols , rows , GRAVESTONE_FRAMES ) ;
} else if ( this . state . screen === 'animating-reopen' ) {
this . renderAnimationScreen ( cols , rows , ZOMBIE_FRAMES ) ;
2026-06-02 18:41:33 +00:00
} else if ( this . state . screen === 'set-assignees' ) {
this . renderSetAssigneesScreen ( cols , rows ) ;
2026-06-02 23:42:07 +00:00
} else if ( this . state . screen === 'labels-list' ) {
this . renderLabelsList ( cols , rows ) ;
} else if ( this . state . screen === 'create-label' || this . state . screen === 'edit-label' ) {
this . renderLabelForm ( cols , rows ) ;
2026-06-03 13:34:31 +00:00
} else if ( this . state . screen === 'help' ) {
this . renderHelpScreen ( cols , rows ) ;
2026-06-01 00:59:22 +00:00
}
}
2026-06-02 00:36:39 +00:00
private renderAnimationScreen ( cols : number , rows : number , frames : string [ ] [ ] ) {
const frame = frames [ Math . min ( this . animationFrame , frames . length - 1 ) ] ;
const paddingRows = Math . max ( 0 , Math . floor ( ( rows - frame . length ) / 2 ) ) ;
for ( let i = 0 ; i < paddingRows ; i ++ ) console . log ( ' ' . repeat ( cols ) ) ;
for ( const line of frame ) {
const plainLen = stripAnsi ( line ) . length ;
const padLeft = Math . max ( 0 , Math . floor ( ( cols - plainLen ) / 2 ) ) ;
console . log ( ' ' . repeat ( padLeft ) + line ) ;
}
const remainingRows = Math . max ( 0 , rows - paddingRows - frame . length - 1 ) ;
for ( let i = 0 ; i < remainingRows ; i ++ ) console . log ( ' ' . repeat ( cols ) ) ;
}
2026-06-01 00:59:22 +00:00
/ * *
* 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 ;
2026-06-03 13:34:31 +00:00
const valStrLength = displayVal . length + ( active ? 1 : 0 ) ;
const paddingInsideBracket = Math . max ( 0 , fieldWidth - 19 - valStrLength ) ;
2026-06-01 00:59:22 +00:00
2026-06-03 13:34:31 +00:00
let coloredValStr = displayVal ;
if ( active ) {
coloredValStr = chalk . bold . cyan ( displayVal ) + chalk . inverse ( ' ' ) + ' ' . repeat ( paddingInsideBracket ) ;
} else {
coloredValStr = chalk . white ( displayVal ) + ' ' . repeat ( paddingInsideBracket ) ;
}
2026-06-01 00:59:22 +00:00
2026-06-03 13:34:31 +00:00
const plainContent = label . padEnd ( 16 ) + ': [ ' + displayVal + ( active ? ' ' : '' ) + ' ' . repeat ( paddingInsideBracket ) + ' ]' ;
const coloredContent = ( active ? chalk . bold . white ( label . padEnd ( 16 ) + ': [ ' ) : chalk . gray ( label . padEnd ( 16 ) + ': [ ' ) ) +
coloredValStr +
( active ? chalk . bold . white ( ' ]' ) : chalk . gray ( ' ]' ) ) ;
2026-06-01 00:59:22 +00:00
const activeMarker = active ? chalk . cyan ( '▶ ' ) : ' ' ;
2026-06-03 13:34:31 +00:00
const remainingPadding = Math . max ( 0 , width - 6 - plainContent . length ) ;
return border + ' ' + activeMarker + coloredContent + ' ' . repeat ( remainingPadding ) + ' ' + border ;
2026-06-01 00:59:22 +00:00
} ;
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]' : '[ ]' ;
2026-06-03 13:34:31 +00:00
const plainContent = label . padEnd ( 16 ) + ': ' + box ;
const coloredContent = active
? chalk . bold . white ( label . padEnd ( 16 ) + ': ' ) + chalk . bold . cyan ( box )
: chalk . gray ( label . padEnd ( 16 ) + ': ' ) + chalk . gray ( box ) ;
const remainingPadding = Math . max ( 0 , width - 6 - plainContent . length ) ;
return border + ' ' + activeMarker + coloredContent + ' ' . repeat ( remainingPadding ) + ' ' + border ;
2026-06-01 02:27:57 +00:00
} ;
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:49:26 +00:00
const rightHeader = '' ;
const rightLen = 0 ;
2026-06-01 02:40:52 +00:00
2026-06-01 02:49:26 +00:00
// We want the total line length to fit exactly within 'cols'
const maxLeftWidth = cols - 1 ;
2026-06-01 02:40:52 +00:00
// 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 ;
2026-06-02 18:41:33 +00:00
const assigneesWidth = 14 ;
2026-06-02 23:42:07 +00:00
const labelsWidth = 16 ;
2026-06-01 00:59:22 +00:00
const createdWidth = 12 ;
const commentsWidth = 6 ;
2026-06-01 23:34:20 +00:00
const timeWidth = 7 ;
2026-06-02 23:42:07 +00:00
// Title takes all remaining space. There are 10 columns + 11 borders + 10 spaces = 21 extra chars
const titleWidth = Math . max ( 10 , cols - idWidth - typeWidth - stateWidth - authorWidth - assigneesWidth - labelsWidth - createdWidth - commentsWidth - timeWidth - 21 ) ;
2026-06-01 00:59:22 +00:00
// 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 + ' ' +
2026-06-02 18:41:33 +00:00
padHeader ( 'Assignees' , assigneesWidth ) + borderCh + ' ' +
2026-06-02 23:42:07 +00:00
padHeader ( 'Labels' , labelsWidth ) + borderCh + ' ' +
2026-06-01 00:59:22 +00:00
padHeader ( 'Created' , createdWidth ) + borderCh + ' ' +
2026-06-01 23:34:20 +00:00
padHeader ( 'Coms' , commentsWidth ) + borderCh + ' ' +
padHeader ( 'Time' , timeWidth ) + borderCh
2026-06-01 00:59:22 +00:00
) ;
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 ) ;
2026-06-02 18:41:33 +00:00
const assigneesNames = ( issue . assignees || [ ] ) . map ( u = > u . login ) . join ( ',' ) ;
const assigneesStr = truncate ( assigneesNames , assigneesWidth ) . padEnd ( assigneesWidth ) ;
2026-06-02 23:42:07 +00:00
let plainLen = 0 ;
let issueLabelsStr = '' ;
for ( let idx = 0 ; idx < issue . labels . length ; idx ++ ) {
const l = issue . labels [ idx ] ;
let name = l . name ;
if ( name . length + 2 > labelsWidth ) {
name = name . substring ( 0 , labelsWidth - 3 ) + '…' ;
}
const lText = ` ${ name } ` ;
if ( plainLen + lText . length > labelsWidth ) break ;
issueLabelsStr += chalk . bgHex ( '#' + l . color ) . black ( lText ) ;
plainLen += lText . length ;
if ( idx < issue . labels . length - 1 && plainLen + 1 <= labelsWidth ) {
issueLabelsStr += ' ' ;
plainLen += 1 ;
} else if ( idx < issue . labels . length - 1 ) {
break ;
}
}
issueLabelsStr += ' ' . repeat ( Math . max ( 0 , labelsWidth - plainLen ) ) ;
2026-06-01 00:59:22 +00:00
const createdStr = formatDate ( issue . created_at ) . substring ( 0 , 10 ) . padEnd ( createdWidth ) ;
const commentsStr = String ( issue . comments ) . padEnd ( commentsWidth ) ;
2026-06-01 23:34:20 +00:00
const timeStr = formatTime ( issue . total_tracked_time ) . padEnd ( timeWidth ) ;
2026-06-01 00:59:22 +00:00
let rowText =
' ' + idStr + borderCh +
' ' + typeStr + borderCh +
' ' + stateStr + borderCh +
' ' + ( issue . pull_request ? chalk . magenta ( titleStr ) : titleStr ) + borderCh +
' ' + authorStr + borderCh +
2026-06-02 18:41:33 +00:00
' ' + assigneesStr + borderCh +
2026-06-02 23:42:07 +00:00
' ' + issueLabelsStr + borderCh +
2026-06-01 00:59:22 +00:00
' ' + createdStr + borderCh +
2026-06-01 23:34:20 +00:00
' ' + commentsStr + borderCh +
' ' + timeStr + borderCh ;
2026-06-01 00:59:22 +00:00
if ( isSelected ) {
2026-06-03 13:34:31 +00:00
if ( this . state . focusedPane === 'settings' ) {
rowText = chalk . bgHex ( '#1C2F4D' ) . white ( rowText ) ;
} else {
rowText = chalk . bgHex ( '#2E4E7E' ) . white . bold ( rowText ) ;
}
2026-06-01 00:59:22 +00:00
}
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
2026-06-03 13:34:31 +00:00
const isSettings = this . state . focusedPane === 'settings' ;
const selIdx = this . state . selectedSettingIndex ? ? 0 ;
const buildSegment = ( idx : number , label : string , val : string , formattedVal : string ) = > {
const text = ` ${ label } : ${ val } ` ;
if ( isSettings && selIdx === idx ) {
return chalk . bgHex ( '#2E4E7E' ) . white . bold ( text ) ;
}
return chalk . gray ( ` ${ label } : ` ) + formattedVal ;
} ;
const searchPlain = this . activeSearchInput ? this . searchInputBuffer : ( this . state . searchQuery || 'none' ) ;
const searchLabel = this . activeSearchInput
? chalk . inverse ( this . searchInputBuffer + ' ' )
: ( this . state . searchQuery ? chalk . yellow ( ` " ${ this . state . searchQuery } " ` ) : chalk . gray ( 'none' ) ) ;
const statePlain = this . state . stateFilter . toUpperCase ( ) ;
const stateLabel = chalk . bold ( statePlain ) ;
const typePlain = this . state . typeFilter . toUpperCase ( ) ;
const typeLabel = chalk . bold ( typePlain ) ;
const sortPlain = ` ${ this . state . sortField } ( ${ this . state . sortOrder . toUpperCase ( ) } ) ` ;
const sortLabel = chalk . bold ( sortPlain ) ;
const seg0 = buildSegment ( 0 , 'Search' , searchPlain , searchLabel ) ;
const seg1 = buildSegment ( 1 , 'State' , statePlain , stateLabel ) ;
const seg2 = buildSegment ( 2 , 'Type' , typePlain , typeLabel ) ;
const seg3 = buildSegment ( 3 , 'Sort' , sortPlain , sortLabel ) ;
const separator = chalk . bold . hex ( '#4A90E2' ) ( ' ─ ' ) ;
const filtersText = ` ${ seg0 } ${ separator } ${ seg1 } ${ separator } ${ seg2 } ${ separator } ${ seg3 } ` ;
2026-06-01 00:59:22 +00:00
2026-06-01 23:45:27 +00:00
const filtersPlainLen = stripAnsi ( filtersText ) . length ;
const padding = Math . max ( 0 , cols - 2 - filtersPlainLen ) ;
console . log ( borderCh + filtersText + ' ' . repeat ( padding ) + borderCh ) ;
2026-06-01 00:59:22 +00:00
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
// Keyboard controls help line
2026-06-03 13:34:31 +00:00
let fullHelpLine = ' [?] Help [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [L] Labels [N/P] Page [R] Reload [O] Settings [Esc] Quit' ;
if ( isSettings ) {
fullHelpLine = ' [?] Help [Tab] Back to List [←/→] Select Setting [Enter] Select/Toggle [C] Create [R] Reload [Esc] Quit' ;
}
2026-06-01 02:49:26 +00:00
const repoStr = chalk . bold . cyan ( ` repo: ${ this . state . config . owner } / ${ this . state . config . repo } ` ) ;
const repoLen = stripAnsi ( repoStr ) . length ;
// We want total length to be cols - 1 to prevent any wrapping
const maxHelpLen = cols - repoLen - 1 ;
let helpLinePlain = fullHelpLine ;
if ( helpLinePlain . length > maxHelpLen ) {
helpLinePlain = helpLinePlain . substring ( 0 , maxHelpLen - 3 ) + '...' ;
}
const spaces = cols - helpLinePlain . length - repoLen - 1 ;
process . stdout . write ( chalk . gray ( helpLinePlain ) + ' ' . repeat ( Math . max ( 0 , spaces ) ) + repoStr ) ;
2026-06-01 00:59:22 +00:00
// 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 ( ' ' ) ;
2026-06-02 18:41:33 +00:00
const assigneesStr = ( issue . assignees || [ ] ) . map ( u = > u . login ) . join ( ', ' ) ;
const assigneesDisp = assigneesStr ? chalk . magenta ( assigneesStr ) : chalk . gray ( 'none' ) ;
2026-06-01 23:34:20 +00:00
const timeStr = formatTime ( issue . total_tracked_time ) ;
2026-06-01 00:59:22 +00:00
2026-06-02 18:41:33 +00:00
const metaLine = ` State: ${ stateLabel } Author: ${ chalk . cyan ( issue . user . login ) } Assignees: ${ assigneesDisp } Created: ${ formatDate ( issue . created_at ) } Updated: ${ formatDate ( issue . updated_at ) } Time: ${ chalk . yellow ( timeStr ) } ` ;
2026-06-01 23:40:04 +00:00
const metaPlainLen = stripAnsi ( metaLine ) . length ;
const padding = Math . max ( 0 , cols - 2 - metaPlainLen ) ;
console . log ( borderCh + metaLine + ' ' . repeat ( padding ) + borderCh ) ;
2026-06-01 00:59:22 +00:00
if ( labels ) {
2026-06-01 23:40:04 +00:00
const labelsLine = ` Labels: ${ labels } ` ;
const labelsPlainLen = stripAnsi ( labelsLine ) . length ;
const labelsPadding = Math . max ( 0 , cols - 2 - labelsPlainLen ) ;
console . log ( borderCh + labelsLine + ' ' . repeat ( labelsPadding ) + borderCh ) ;
2026-06-01 00:59:22 +00:00
}
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 ] ;
2026-06-02 00:02:10 +00:00
const linePlainLen = stripAnsi ( line ) . length ;
const padding = Math . max ( 0 , cols - 4 - linePlainLen ) ;
console . log ( borderCh + ' ' + line + ' ' . repeat ( padding ) + ' ' + borderCh ) ;
2026-06-01 00:59:22 +00:00
} 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-02 00:15:30 +00:00
const actionKey = issue . state === 'open' ? 'Close' : 'Reopen' ;
2026-06-03 13:34:31 +00:00
const helpLine = chalk . gray ( ` [?] Help [Esc/Backspace] Back to List [C] Add Comment [A] Assign [E] Edit [T] Add Time [X] ${ actionKey } [R] Reload [O] Settings ${ scrollHelp } ` ) ;
2026-06-01 02:02:23 +00:00
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 03:06:53 +00:00
/ * *
* Draw Create Issue Screen
* /
private renderCreateIssueScreen ( cols : number , rows : number ) {
const borderCh = chalk . bold . hex ( '#4A90E2' ) ( '│' ) ;
// Header
const title = ' Create New Issue ' ;
const leftLen = stripAnsi ( title ) . length ;
let leftHeader = chalk . bold . hex ( '#4A90E2' ) ( title ) ;
console . log ( leftHeader + ' ' . repeat ( Math . max ( 1 , cols - leftLen - 1 ) ) ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( cols - 2 ) + '┐' ) ) ;
if ( this . state . error ) {
console . log ( borderCh + chalk . red ( ` Error: ${ this . state . error } ` ) . padEnd ( cols - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
}
const form = this . state . createIssueForm ;
// Title Field
let titleLabel = ' Title: ' ;
let titleVisible = form . title ;
if ( titleVisible . length > cols - 13 ) {
titleVisible = titleVisible . substring ( titleVisible . length - ( cols - 13 ) ) ;
}
let titleContent = titleVisible . padEnd ( cols - 12 ) ;
if ( form . activeField === 'title' ) {
titleLabel = chalk . yellow ( ' Title: ' ) ;
titleContent = chalk . inverse ( titleVisible + ' ' ) + ' ' . repeat ( Math . max ( 0 , cols - 13 - titleVisible . length ) ) ;
}
console . log ( borderCh + titleLabel + titleContent + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
// Body Field
let bodyLabel = chalk . bold ( ' Body (markdown supported): ' ) ;
if ( form . activeField === 'body' ) {
bodyLabel = chalk . yellow . bold ( ' Body (markdown supported): ' ) ;
}
console . log ( borderCh + bodyLabel + ' ' . repeat ( cols - stripAnsi ( bodyLabel ) . length - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
// Body Content (multiline)
const errorOffset = this . state . error ? 2 : 0 ;
const bodyRows = rows - 10 - errorOffset ; // available space for body
const lines = form . body . split ( '\n' ) ;
// Pagination for body if it gets too long
const startLine = Math . max ( 0 , lines . length - bodyRows ) ;
for ( let i = 0 ; i < bodyRows ; i ++ ) {
const lineIdx = startLine + i ;
let lineContent = '' ;
if ( lineIdx < lines . length ) {
lineContent = lines [ lineIdx ] ;
}
let visLine = lineContent ;
if ( visLine . length > cols - 5 ) {
visLine = visLine . substring ( visLine . length - ( cols - 5 ) ) ;
}
if ( form . activeField === 'body' && lineIdx === lines . length - 1 ) {
visLine = visLine + chalk . inverse ( ' ' ) + ' ' . repeat ( Math . max ( 0 , cols - 5 - stripAnsi ( visLine ) . length ) ) ;
} else {
visLine = visLine . padEnd ( cols - 4 ) ;
}
console . log ( borderCh + ' ' + visLine + ' ' + borderCh ) ;
}
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
// Help line
const helpLine = chalk . gray ( ' [Tab] Switch Fields [Ctrl+S] Submit [Esc] Cancel' ) ;
const repoStr = chalk . bold . cyan ( ` repo: ${ this . state . config . owner } / ${ this . state . config . repo } ` ) ;
const repoLen = stripAnsi ( repoStr ) . length ;
const maxHelpLen = cols - repoLen - 1 ;
let helpLinePlain = helpLine ;
if ( stripAnsi ( helpLinePlain ) . length > maxHelpLen ) {
helpLinePlain = chalk . gray ( stripAnsi ( helpLinePlain ) . substring ( 0 , maxHelpLen - 3 ) + '...' ) ;
}
const spaces = cols - stripAnsi ( helpLinePlain ) . length - repoLen - 1 ;
process . stdout . write ( helpLinePlain + ' ' . repeat ( Math . max ( 0 , spaces ) ) + repoStr ) ;
}
/ * *
* Handle Keypress for Create Issue Screen
* /
private async handleCreateIssueKeypress ( str : string , key : any ) {
if ( key && key . name === 'escape' ) {
this . state . screen = 'list' ;
this . state . error = null ;
this . render ( ) ;
return ;
}
if ( key && key . ctrl && key . name === 's' ) {
// Submit issue
if ( ! this . state . createIssueForm . title . trim ( ) ) {
this . state . error = 'Title is required to create an issue.' ;
this . render ( ) ;
return ;
}
this . state . error = null ;
this . state . loading = true ;
this . render ( ) ;
try {
await createIssue ( this . state . config , this . state . createIssueForm . title , this . state . createIssueForm . body ) ;
this . state . screen = 'list' ;
this . state . loading = false ;
// Refresh issues
this . state . currentPage = 1 ;
this . state . selectedIssueIndex = 0 ;
this . loadIssues ( ) ;
} catch ( err : any ) {
this . state . error = err . message ;
this . state . loading = false ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'tab' ) {
this . state . createIssueForm . activeField = this . state . createIssueForm . activeField === 'title' ? 'body' : 'title' ;
this . render ( ) ;
return ;
}
// Text input handling
const activeField = this . state . createIssueForm . activeField ;
let val = this . state . createIssueForm [ activeField ] ;
if ( key && key . name === 'backspace' ) {
if ( val . length > 0 ) {
this . state . createIssueForm [ activeField ] = val . slice ( 0 , - 1 ) ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'return' ) {
if ( activeField === 'body' ) {
this . state . createIssueForm . body += '\n' ;
this . render ( ) ;
} else {
// In title, return switches to body
this . state . createIssueForm . activeField = 'body' ;
this . render ( ) ;
}
return ;
}
if ( str && str . length === 1 && ! key . ctrl && ! key . meta ) {
this . state . error = null ;
this . state . createIssueForm [ activeField ] += str ;
this . render ( ) ;
}
}
2026-06-01 03:18:12 +00:00
private renderAddCommentScreen ( cols : number , rows : number ) {
const borderCh = chalk . bold . hex ( '#4A90E2' ) ( '│' ) ;
// Header
const title = ' Add Comment ' ;
const leftLen = stripAnsi ( title ) . length ;
let leftHeader = chalk . bold . hex ( '#4A90E2' ) ( title ) ;
console . log ( leftHeader + ' ' . repeat ( Math . max ( 1 , cols - leftLen - 1 ) ) ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( cols - 2 ) + '┐' ) ) ;
if ( this . state . error ) {
console . log ( borderCh + chalk . red ( ` Error: ${ this . state . error } ` ) . padEnd ( cols - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
}
const form = this . state . addCommentForm ;
// Body Field
const bodyLabel = chalk . yellow . bold ( ' Comment (markdown supported): ' ) ;
console . log ( borderCh + bodyLabel + ' ' . repeat ( cols - stripAnsi ( bodyLabel ) . length - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
// Body Content (multiline)
const errorOffset = this . state . error ? 2 : 0 ;
const bodyRows = rows - 8 - errorOffset ; // available space for body
const lines = form . body . split ( '\n' ) ;
// Pagination for body if it gets too long
const startLine = Math . max ( 0 , lines . length - bodyRows ) ;
for ( let i = 0 ; i < bodyRows ; i ++ ) {
const lineIdx = startLine + i ;
let lineContent = '' ;
if ( lineIdx < lines . length ) {
lineContent = lines [ lineIdx ] ;
}
let visLine = lineContent ;
if ( visLine . length > cols - 5 ) {
visLine = visLine . substring ( visLine . length - ( cols - 5 ) ) ;
}
if ( lineIdx === lines . length - 1 ) {
visLine = visLine + chalk . inverse ( ' ' ) + ' ' . repeat ( Math . max ( 0 , cols - 5 - stripAnsi ( visLine ) . length ) ) ;
} else {
visLine = visLine . padEnd ( cols - 4 ) ;
}
console . log ( borderCh + ' ' + visLine + ' ' + borderCh ) ;
}
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
// Help line
const helpLine = chalk . gray ( ' [Ctrl+S] Submit [Esc] Cancel' ) ;
const issueStr = this . state . selectedIssue ? chalk . bold . cyan ( ` Issue # ${ this . state . selectedIssue . number } ` ) : '' ;
const issueLen = stripAnsi ( issueStr ) . length ;
const maxHelpLen = cols - issueLen - 1 ;
let helpLinePlain = helpLine ;
if ( stripAnsi ( helpLinePlain ) . length > maxHelpLen ) {
helpLinePlain = chalk . gray ( stripAnsi ( helpLinePlain ) . substring ( 0 , maxHelpLen - 3 ) + '...' ) ;
}
const spaces = cols - stripAnsi ( helpLinePlain ) . length - issueLen - 1 ;
process . stdout . write ( helpLinePlain + ' ' . repeat ( Math . max ( 0 , spaces ) ) + issueStr ) ;
}
private async handleAddCommentKeypress ( str : string , key : any ) {
if ( key && key . name === 'escape' ) {
this . state . screen = 'details' ;
this . state . error = null ;
this . render ( ) ;
return ;
}
if ( key && key . ctrl && key . name === 's' ) {
// Submit comment
if ( ! this . state . addCommentForm . body . trim ( ) ) {
this . state . error = 'Comment cannot be empty.' ;
this . render ( ) ;
return ;
}
if ( ! this . state . selectedIssue ) {
this . state . error = 'No issue selected.' ;
this . render ( ) ;
return ;
}
this . state . error = null ;
this . state . loading = true ;
this . render ( ) ;
try {
await createIssueComment ( this . state . config , this . state . selectedIssue . number , this . state . addCommentForm . body ) ;
this . state . screen = 'details' ;
this . state . loading = false ;
// Refresh comments
this . loadComments ( this . state . selectedIssue ) ;
} catch ( err : any ) {
this . state . error = err . message ;
this . state . loading = false ;
this . render ( ) ;
}
return ;
}
let val = this . state . addCommentForm . body ;
if ( key && key . name === 'backspace' ) {
if ( val . length > 0 ) {
this . state . addCommentForm . body = val . slice ( 0 , - 1 ) ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'return' ) {
this . state . addCommentForm . body += '\n' ;
this . render ( ) ;
return ;
}
if ( str && str . length === 1 && ! key . ctrl && ! key . meta ) {
this . state . error = null ;
this . state . addCommentForm . body += str ;
this . render ( ) ;
}
}
2026-06-01 23:15:01 +00:00
private renderEditIssueScreen ( cols : number , rows : number ) {
const borderCh = chalk . bold . hex ( '#4A90E2' ) ( '│' ) ;
// Header
const title = ' Edit Issue ' ;
const leftLen = stripAnsi ( title ) . length ;
let leftHeader = chalk . bold . hex ( '#4A90E2' ) ( title ) ;
console . log ( leftHeader + ' ' . repeat ( Math . max ( 1 , cols - leftLen - 1 ) ) ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( cols - 2 ) + '┐' ) ) ;
if ( this . state . error ) {
console . log ( borderCh + chalk . red ( ` Error: ${ this . state . error } ` ) . padEnd ( cols - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
}
const form = this . state . editIssueForm ;
// Title Field
let titleLabel = ' Title: ' ;
let titleVisible = form . title ;
if ( titleVisible . length > cols - 13 ) {
titleVisible = titleVisible . substring ( titleVisible . length - ( cols - 13 ) ) ;
}
let titleContent = titleVisible . padEnd ( cols - 12 ) ;
if ( form . activeField === 'title' ) {
titleLabel = chalk . yellow ( ' Title: ' ) ;
titleContent = chalk . inverse ( titleVisible + ' ' ) + ' ' . repeat ( Math . max ( 0 , cols - 13 - titleVisible . length ) ) ;
}
console . log ( borderCh + titleLabel + titleContent + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
// Body Field
let bodyLabel = chalk . bold ( ' Body (markdown supported): ' ) ;
if ( form . activeField === 'body' ) {
bodyLabel = chalk . yellow . bold ( ' Body (markdown supported): ' ) ;
}
console . log ( borderCh + bodyLabel + ' ' . repeat ( cols - stripAnsi ( bodyLabel ) . length - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
// Body Content (multiline)
const errorOffset = this . state . error ? 2 : 0 ;
const bodyRows = rows - 10 - errorOffset ; // available space for body
const lines = form . body . split ( '\n' ) ;
// Pagination for body if it gets too long
const startLine = Math . max ( 0 , lines . length - bodyRows ) ;
for ( let i = 0 ; i < bodyRows ; i ++ ) {
const lineIdx = startLine + i ;
let lineContent = '' ;
if ( lineIdx < lines . length ) {
lineContent = lines [ lineIdx ] ;
}
let visLine = lineContent ;
if ( visLine . length > cols - 5 ) {
visLine = visLine . substring ( visLine . length - ( cols - 5 ) ) ;
}
if ( form . activeField === 'body' && lineIdx === lines . length - 1 ) {
visLine = visLine + chalk . inverse ( ' ' ) + ' ' . repeat ( Math . max ( 0 , cols - 5 - stripAnsi ( visLine ) . length ) ) ;
} else {
visLine = visLine . padEnd ( cols - 4 ) ;
}
console . log ( borderCh + ' ' + visLine + ' ' + borderCh ) ;
}
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
// Help line
const helpLine = chalk . gray ( ' [Tab] Switch Fields [Ctrl+S] Save [Esc] Cancel' ) ;
const issueStr = this . state . selectedIssue ? chalk . bold . cyan ( ` Issue # ${ this . state . selectedIssue . number } ` ) : '' ;
const issueLen = stripAnsi ( issueStr ) . length ;
const maxHelpLen = cols - issueLen - 1 ;
let helpLinePlain = helpLine ;
if ( stripAnsi ( helpLinePlain ) . length > maxHelpLen ) {
helpLinePlain = chalk . gray ( stripAnsi ( helpLinePlain ) . substring ( 0 , maxHelpLen - 3 ) + '...' ) ;
}
const spaces = cols - stripAnsi ( helpLinePlain ) . length - issueLen - 1 ;
process . stdout . write ( helpLinePlain + ' ' . repeat ( Math . max ( 0 , spaces ) ) + issueStr ) ;
}
private async handleEditIssueKeypress ( str : string , key : any ) {
if ( key && key . name === 'escape' ) {
this . state . screen = 'details' ;
this . state . error = null ;
this . render ( ) ;
return ;
}
if ( key && key . ctrl && key . name === 's' ) {
// Save issue
if ( ! this . state . editIssueForm . title . trim ( ) ) {
this . state . error = 'Title is required.' ;
this . render ( ) ;
return ;
}
if ( ! this . state . selectedIssue ) {
this . state . error = 'No issue selected.' ;
this . render ( ) ;
return ;
}
this . state . error = null ;
this . state . loading = true ;
this . render ( ) ;
try {
const updatedIssue = await editIssue ( this . state . config , this . state . selectedIssue . number , this . state . editIssueForm . title , this . state . editIssueForm . body ) ;
// Update the list and selected issue with the new data
this . state . selectedIssue = updatedIssue ;
const listIndex = this . state . issues . findIndex ( ( i ) = > i . number === updatedIssue . number ) ;
if ( listIndex !== - 1 ) {
this . state . issues [ listIndex ] = updatedIssue ;
}
this . state . screen = 'details' ;
this . state . loading = false ;
this . render ( ) ;
} catch ( err : any ) {
this . state . error = err . message ;
this . state . loading = false ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'tab' ) {
this . state . editIssueForm . activeField = this . state . editIssueForm . activeField === 'title' ? 'body' : 'title' ;
this . render ( ) ;
return ;
}
// Text input handling
const activeField = this . state . editIssueForm . activeField ;
let val = this . state . editIssueForm [ activeField ] ;
if ( key && key . name === 'backspace' ) {
if ( val . length > 0 ) {
this . state . editIssueForm [ activeField ] = val . slice ( 0 , - 1 ) ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'return' ) {
if ( activeField === 'body' ) {
this . state . editIssueForm . body += '\n' ;
this . render ( ) ;
} else {
// In title, return switches to body
this . state . editIssueForm . activeField = 'body' ;
this . render ( ) ;
}
return ;
}
if ( str && str . length === 1 && ! key . ctrl && ! key . meta ) {
this . state . error = null ;
this . state . editIssueForm [ activeField ] += str ;
this . render ( ) ;
}
}
2026-06-01 23:34:20 +00:00
private renderAddTimeScreen ( cols : number , rows : number ) {
const borderCh = chalk . bold . hex ( '#4A90E2' ) ( '│' ) ;
// Header
const title = ' Add Time ' ;
const leftLen = stripAnsi ( title ) . length ;
let leftHeader = chalk . bold . hex ( '#4A90E2' ) ( title ) ;
console . log ( leftHeader + ' ' . repeat ( Math . max ( 1 , cols - leftLen - 1 ) ) ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( cols - 2 ) + '┐' ) ) ;
if ( this . state . error ) {
console . log ( borderCh + chalk . red ( ` Error: ${ this . state . error } ` ) . padEnd ( cols - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
}
const form = this . state . addTimeForm ;
// Time Field
const timeLabel = chalk . yellow . bold ( ' Time Spent (e.g. 1h 30m, 45m): ' ) ;
console . log ( borderCh + timeLabel + ' ' . repeat ( Math . max ( 0 , cols - stripAnsi ( timeLabel ) . length - 2 ) ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
// Input Box
let visLine = form . timeInput ;
if ( visLine . length > cols - 5 ) {
visLine = visLine . substring ( visLine . length - ( cols - 5 ) ) ;
}
visLine = visLine + chalk . inverse ( ' ' ) + ' ' . repeat ( Math . max ( 0 , cols - 5 - stripAnsi ( visLine ) . length ) ) ;
console . log ( borderCh + ' ' + visLine + ' ' + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
// Help line
const helpLine = chalk . gray ( ' [Ctrl+S] Submit [Esc] Cancel' ) ;
const issueStr = this . state . selectedIssue ? chalk . bold . cyan ( ` Issue # ${ this . state . selectedIssue . number } ` ) : '' ;
const issueLen = stripAnsi ( issueStr ) . length ;
const maxHelpLen = cols - issueLen - 1 ;
let helpLinePlain = helpLine ;
if ( stripAnsi ( helpLinePlain ) . length > maxHelpLen ) {
helpLinePlain = chalk . gray ( stripAnsi ( helpLinePlain ) . substring ( 0 , maxHelpLen - 3 ) + '...' ) ;
}
const spaces = cols - stripAnsi ( helpLinePlain ) . length - issueLen - 1 ;
process . stdout . write ( helpLinePlain + ' ' . repeat ( Math . max ( 0 , spaces ) ) + issueStr ) ;
}
private async handleAddTimeKeypress ( str : string , key : any ) {
if ( key && key . name === 'escape' ) {
this . state . screen = 'details' ;
this . state . error = null ;
this . render ( ) ;
return ;
}
if ( key && key . ctrl && key . name === 's' ) {
const input = this . state . addTimeForm . timeInput . trim ( ) ;
if ( ! input ) {
this . state . error = 'Time input cannot be empty.' ;
this . render ( ) ;
return ;
}
if ( ! this . state . selectedIssue ) {
this . state . error = 'No issue selected.' ;
this . render ( ) ;
return ;
}
// Parse time input (e.g. 1h 30m, 45m, 2h)
let totalSeconds = 0 ;
const hMatch = input . match ( /(\d+)\s*h/ ) ;
const mMatch = input . match ( /(\d+)\s*m/ ) ;
if ( hMatch ) {
totalSeconds += parseInt ( hMatch [ 1 ] , 10 ) * 3600 ;
}
if ( mMatch ) {
totalSeconds += parseInt ( mMatch [ 1 ] , 10 ) * 60 ;
}
// If neither h nor m matched, but it's just a number, assume minutes
if ( ! hMatch && ! mMatch && /^\d+$/ . test ( input ) ) {
totalSeconds += parseInt ( input , 10 ) * 60 ;
} else if ( ! hMatch && ! mMatch ) {
this . state . error = "Invalid format. Use '1h 30m', '45m', or just minutes like '45'." ;
this . render ( ) ;
return ;
}
if ( totalSeconds <= 0 ) {
this . state . error = "Time must be greater than 0." ;
this . render ( ) ;
return ;
}
this . state . error = null ;
this . state . loading = true ;
this . render ( ) ;
try {
await addIssueTime ( this . state . config , this . state . selectedIssue . number , totalSeconds ) ;
this . state . screen = 'details' ;
this . state . loading = false ;
// Reload to show the time tracked
this . reloadSingleIssue ( ) ;
} catch ( err : any ) {
this . state . error = err . message ;
this . state . loading = false ;
this . render ( ) ;
}
return ;
}
let val = this . state . addTimeForm . timeInput ;
if ( key && key . name === 'backspace' ) {
if ( val . length > 0 ) {
this . state . addTimeForm . timeInput = val . slice ( 0 , - 1 ) ;
this . render ( ) ;
}
return ;
}
if ( str && str . length === 1 && ! key . ctrl && ! key . meta && str !== '\n' && str !== '\r' ) {
this . state . error = null ;
this . state . addTimeForm . timeInput += str ;
this . render ( ) ;
}
}
2026-06-02 00:15:30 +00:00
private renderConfirmStateChangeScreen ( cols : number , rows : number ) {
const borderCh = chalk . bold . hex ( '#4A90E2' ) ( '│' ) ;
const issue = this . state . selectedIssue ;
if ( ! issue ) return ;
const action = issue . state === 'open' ? 'Close' : 'Reopen' ;
const title = ` Confirm ${ action } ` ;
const leftLen = stripAnsi ( title ) . length ;
let leftHeader = chalk . bold . hex ( '#4A90E2' ) ( title ) ;
console . log ( leftHeader + ' ' . repeat ( Math . max ( 1 , cols - leftLen - 1 ) ) ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( cols - 2 ) + '┐' ) ) ;
if ( this . state . error ) {
console . log ( borderCh + chalk . red ( ` Error: ${ this . state . error } ` ) . padEnd ( cols - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
}
const question = ` Are you sure you want to ${ action . toLowerCase ( ) } issue # ${ issue . number } ? ` ;
const promptPlain = ` ${ question } [y/N] ` ;
const paddingRows = Math . floor ( ( rows - 8 ) / 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 - promptPlain . length ) / 2 ) ) ) + chalk . yellow . bold ( question ) + ' [y/N] ' ) . padEnd ( cols - 2 + ( chalk . yellow . bold ( question ) . length - question . length ) ) ;
console . log ( borderCh + contentLine + borderCh ) ;
for ( let i = 0 ; i < ( rows - 8 ) - paddingRows ; i ++ ) console . log ( borderCh + ' ' . repeat ( cols - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
const helpLinePlain = ' [y] Yes [n/Esc] No' ;
process . stdout . write ( chalk . gray ( helpLinePlain ) ) ;
}
private async handleConfirmStateChangeKeypress ( str : string , key : any ) {
if ( ( key && ( key . name === 'escape' || key . name === 'n' ) ) || str === 'n' || str === 'N' ) {
this . state . screen = 'details' ;
this . state . error = null ;
this . render ( ) ;
return ;
}
if ( str === 'y' || str === 'Y' ) {
const issue = this . state . selectedIssue ;
if ( ! issue ) return ;
this . state . error = null ;
2026-06-02 00:36:39 +00:00
const newState = issue . state === 'open' ? 'closed' : 'open' ;
this . state . screen = newState === 'closed' ? 'animating-close' : 'animating-reopen' ;
this . animationFrame = 0 ;
2026-06-02 00:15:30 +00:00
this . render ( ) ;
2026-06-02 00:36:39 +00:00
if ( this . animationInterval ) {
clearInterval ( this . animationInterval ) ;
2026-06-02 00:15:30 +00:00
}
2026-06-02 00:36:39 +00:00
this . animationInterval = setInterval ( ( ) = > {
this . animationFrame ++ ;
const frameCount = newState === 'closed' ? GRAVESTONE_FRAMES.length : ZOMBIE_FRAMES.length ;
this . render ( ) ;
if ( this . animationFrame >= frameCount - 1 ) {
if ( this . animationInterval ) {
clearInterval ( this . animationInterval ) ;
this . animationInterval = null ;
}
setTimeout ( async ( ) = > {
this . state . loading = true ;
this . render ( ) ;
try {
await changeIssueState ( this . state . config , issue . number , newState ) ;
this . state . screen = 'details' ;
this . state . loading = false ;
this . reloadSingleIssue ( ) ;
} catch ( err : any ) {
this . state . error = err . message ;
this . state . screen = 'details' ;
this . state . loading = false ;
this . render ( ) ;
}
} , 300 ) ;
}
} , 300 ) ;
2026-06-02 00:15:30 +00:00
return ;
}
}
2026-06-02 18:41:33 +00:00
private renderSetAssigneesScreen ( cols : number , rows : number ) {
const borderCh = chalk . bold . hex ( '#4A90E2' ) ( '│' ) ;
const title = ' Set Assignees ' ;
const leftLen = stripAnsi ( title ) . length ;
let leftHeader = chalk . bold . hex ( '#4A90E2' ) ( title ) ;
console . log ( leftHeader + ' ' . repeat ( Math . max ( 1 , cols - leftLen - 1 ) ) ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '┌' + '─' . repeat ( cols - 2 ) + '┐' ) ) ;
if ( this . state . error ) {
console . log ( borderCh + chalk . red ( ` Error: ${ this . state . error } ` ) . padEnd ( cols - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
}
const label = chalk . yellow ( ' Assignees (comma-separated usernames): ' ) ;
console . log ( borderCh + label + ' ' . repeat ( cols - stripAnsi ( label ) . length - 2 ) + borderCh ) ;
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '├' + '─' . repeat ( cols - 2 ) + '┤' ) ) ;
const inputVal = this . state . assigneesInput ;
let visVal = inputVal ;
if ( visVal . length > cols - 5 ) {
visVal = visVal . substring ( visVal . length - ( cols - 5 ) ) ;
}
visVal = visVal + chalk . inverse ( ' ' ) + ' ' . repeat ( Math . max ( 0 , cols - 5 - stripAnsi ( visVal ) . length ) ) ;
console . log ( borderCh + ' ' + visVal + ' ' + borderCh ) ;
const fillRows = rows - ( this . state . error ? 8 : 6 ) - 1 ;
for ( let i = 0 ; i < fillRows ; i ++ ) {
console . log ( borderCh + ' ' . repeat ( cols - 2 ) + borderCh ) ;
}
console . log ( chalk . bold . hex ( '#4A90E2' ) ( '└' + '─' . repeat ( cols - 2 ) + '┘' ) ) ;
2026-06-01 02:02:23 +00:00
2026-06-02 18:41:33 +00:00
const helpLine = chalk . gray ( ' [Enter] Submit [Esc] Cancel (leave blank to clear assignees)' ) ;
const issueStr = this . state . selectedIssue ? chalk . bold . cyan ( ` Issue # ${ this . state . selectedIssue . number } ` ) : '' ;
const spaces = cols - stripAnsi ( helpLine ) . length - stripAnsi ( issueStr ) . length ;
process . stdout . write ( helpLine + ' ' . repeat ( Math . max ( 0 , spaces ) ) + issueStr ) ;
}
private async handleSetAssigneesKeypress ( str : string , key : any ) {
if ( key && key . name === 'escape' ) {
this . state . screen = 'details' ;
this . state . error = null ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'backspace' ) {
if ( this . state . assigneesInput . length > 0 ) {
this . state . assigneesInput = this . state . assigneesInput . slice ( 0 , - 1 ) ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'return' ) {
const issue = this . state . selectedIssue ;
if ( ! issue ) return ;
this . state . error = null ;
this . state . loading = true ;
this . render ( ) ;
const assignees = this . state . assigneesInput
. split ( ',' )
. map ( s = > s . trim ( ) )
. filter ( s = > s . length > 0 ) ;
try {
await setIssueAssignees ( this . state . config , issue . number , assignees ) ;
this . state . screen = 'details' ;
this . state . loading = false ;
this . reloadSingleIssue ( ) ;
} catch ( err : any ) {
this . state . error = err . message ;
this . state . loading = false ;
this . render ( ) ;
}
return ;
}
if ( str && str . length === 1 && ! key . ctrl && ! key . meta ) {
this . state . error = null ;
this . state . assigneesInput += str ;
this . render ( ) ;
}
}
2026-06-02 22:31:24 +00:00
// LAUNCH ANIMATION IMPLEMENTATION
private startLaunchAnimation() {
this . launchFrameIndex = 0 ;
this . render ( ) ;
this . launchInterval = setInterval ( ( ) = > {
this . launchFrameIndex ++ ;
this . render ( ) ;
if ( this . launchFrameIndex >= this . maxLaunchFrames - 1 ) {
this . skipLaunchAnimation ( ) ;
}
} , 60 ) ; // 60ms interval for fast, smooth fluid color shifting
}
private skipLaunchAnimation() {
if ( this . launchInterval ) {
clearInterval ( this . launchInterval ) ;
this . launchInterval = null ;
}
// Transition to target destination screen
if ( this . launchDestScreen === 'list' ) {
this . state . screen = 'list' ;
this . loadIssues ( ) ;
} else {
this . state . screen = 'setup' ;
this . render ( ) ;
}
}
private loadLaunchFrames() {
try {
const __filename = fileURLToPath ( import . meta . url ) ;
const __dirname = path . dirname ( __filename ) ;
// Resolve path to the root folder where ascii-art.txt is located
const artPath = path . resolve ( __dirname , '..' , 'ascii-art.txt' ) ;
if ( fs . existsSync ( artPath ) ) {
const rawArt = fs . readFileSync ( artPath , 'utf8' ) . split ( /\r?\n/ ) ;
this . launchFrame = this . preprocessArt ( rawArt ) ;
} else {
// Fallback frames if file not found
this . launchFrame = [
" ( ( " ,
" ) ) " ,
" [===] " ,
" \\___/ "
] ;
}
} catch ( e ) {
this . launchFrame = [
" ( ( " ,
" ) ) " ,
" [===] " ,
" \\___/ "
] ;
}
}
private preprocessArt ( lines : string [ ] ) : string [ ] {
let startIdx = 0 ;
while ( startIdx < lines . length && lines [ startIdx ] . trim ( ) === '' ) {
startIdx ++ ;
}
let endIdx = lines . length - 1 ;
while ( endIdx >= 0 && lines [ endIdx ] . trim ( ) === '' ) {
endIdx -- ;
}
if ( startIdx > endIdx ) return [ ] ;
const activeLines = lines . slice ( startIdx , endIdx + 1 ) ;
let minPadding = Infinity ;
for ( const line of activeLines ) {
if ( line . trim ( ) === '' ) continue ;
const match = line . match ( /^[ \t]*/ ) ;
const padding = match ? match [ 0 ] . length : 0 ;
if ( padding < minPadding ) {
minPadding = padding ;
}
}
if ( minPadding === Infinity || minPadding === 0 ) return activeLines ;
return activeLines . map ( line = > line . length >= minPadding ? line . substring ( minPadding ) : '' ) ;
}
private renderLaunchScreen ( cols : number , rows : number ) {
if ( this . launchFrame . length === 0 ) {
this . loadLaunchFrames ( ) ;
}
const frame = this . launchFrame ;
if ( frame . length === 0 ) return ;
const W = Math . max ( . . . frame . map ( line = > line . length ) ) ;
const H = frame . length ;
const padLeftCount = Math . max ( 0 , Math . floor ( ( cols - W ) / 2 ) ) ;
const padTopCount = Math . max ( 0 , Math . floor ( ( rows - H - 5 ) / 2 ) ) ;
for ( let i = 0 ; i < padTopCount ; i ++ ) {
console . log ( '' ) ;
}
// HSL helper function to generate HSL-based hex colors
const hslToHex = ( h : number , s : number , l : number ) : string = > {
l /= 100 ;
const a = ( s * Math . min ( l , 1 - l ) ) / 100 ;
const f = ( n : number ) = > {
const k = ( n + h / 30 ) % 12 ;
const color = l - a * Math . max ( Math . min ( k - 3 , 9 - k , 1 ) , - 1 ) ;
return Math . round ( 255 * color ) . toString ( 16 ) . padStart ( 2 , '0' ) ;
} ;
return ` # ${ f ( 0 ) } ${ f ( 8 ) } ${ f ( 4 ) } ` ;
} ;
const step = this . launchFrameIndex ;
for ( let i = 0 ; i < frame . length ; i ++ ) {
const line = frame [ i ] ;
const leftPad = ' ' . repeat ( padLeftCount ) ;
let coloredLine = '' ;
if ( i < 20 ) {
// Steam gradient: waving light intensity using sine wave based on time and row index
const factor = i / 20 ; // 0 to 1
// Wave lightness of the steam
const wave = Math . sin ( step * 0.4 - i * 0.3 ) ;
const lightness = Math . max ( 10 , Math . min ( 80 , Math . round ( 25 + wave * 15 + factor * 25 ) ) ) ;
// Soft cyan/blue steam hue = 195
const hex = hslToHex ( 195 , 30 , lightness ) ;
coloredLine = chalk . hex ( hex ) ( line ) ;
} else {
// Cup body gradient: shifting rainbow wave
const rowOffset = i - 20 ;
// Cycle the hue over time and rows
const hue = ( step * 8 + rowOffset * 12 ) % 360 ;
const hex = hslToHex ( hue , 95 , 55 ) ;
coloredLine = chalk . hex ( hex ) . bold ( line ) ;
}
console . log ( leftPad + coloredLine ) ;
}
console . log ( '' ) ;
const titlePlain = ' G I T E A & F O R G E J O T U I ' ;
const subtitlePlain = 'Interactive Issue & Pull Request Explorer' ;
// Cycle the title box background color too!
const titleHue = ( step * 6 ) % 360 ;
const titleColor = hslToHex ( titleHue , 95 , 50 ) ;
const titleLeftPad = Math . max ( 0 , Math . floor ( ( cols - titlePlain . length ) / 2 ) ) ;
const subtitleLeftPad = Math . max ( 0 , Math . floor ( ( cols - subtitlePlain . length ) / 2 ) ) ;
console . log ( ' ' . repeat ( titleLeftPad ) + chalk . bold . bgHex ( titleColor ) . black ( titlePlain ) ) ;
console . log ( ' ' . repeat ( subtitleLeftPad ) + chalk . gray ( subtitlePlain ) ) ;
console . log ( '' ) ;
const skipPlain = 'Press any key to skip animation' ;
const skipLeftPad = Math . max ( 0 , Math . floor ( ( cols - skipPlain . length ) / 2 ) ) ;
console . log ( ' ' . repeat ( skipLeftPad ) + chalk . gray . italic ( skipPlain ) ) ;
const printedRows = H + padTopCount + 5 ;
const remainingRows = Math . max ( 0 , rows - printedRows - 1 ) ;
2026-06-02 23:42:07 +00:00
}
// --- Labels Logic ---
private async loadLabels() {
this . state . labelsLoading = true ;
this . state . error = null ;
this . render ( ) ;
try {
this . state . labels = await fetchLabels ( this . state . config ) ;
} catch ( err : any ) {
this . state . error = ` Failed to load labels: ${ err . message } ` ;
} finally {
this . state . labelsLoading = false ;
this . render ( ) ;
}
}
private async handleLabelsListKeypress ( str : string , key : any ) {
if ( this . state . labelsLoading ) return ;
if ( ( key && key . name === 'escape' ) || str === '\u001b' || str === 'q' || str === 'Q' ) {
this . state . screen = 'list' ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'up' ) {
if ( this . state . selectedLabelIndex > 0 ) {
this . state . selectedLabelIndex -- ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'down' ) {
if ( this . state . selectedLabelIndex < this . state . labels . length - 1 ) {
this . state . selectedLabelIndex ++ ;
this . render ( ) ;
}
return ;
}
if ( str === 'c' || str === 'C' ) {
this . state . screen = 'create-label' ;
this . state . labelForm = {
name : '' ,
color : '000000' ,
description : '' ,
exclusive : false ,
activeField : 'name'
} ;
this . render ( ) ;
return ;
}
if ( str === 'e' || str === 'E' ) {
if ( this . state . labels . length > 0 ) {
const lbl = this . state . labels [ this . state . selectedLabelIndex ] ;
this . state . screen = 'edit-label' ;
this . state . labelForm = {
name : lbl.name ,
color : lbl.color ,
description : lbl.description || '' ,
exclusive : lbl.exclusive || false ,
activeField : 'name'
} ;
this . render ( ) ;
}
return ;
}
if ( ( key && key . name === 'delete' ) || str === 'd' || str === 'D' ) {
if ( this . state . labels . length > 0 ) {
const lbl = this . state . labels [ this . state . selectedLabelIndex ] ;
this . state . labelsLoading = true ;
this . render ( ) ;
try {
await deleteLabel ( this . state . config , lbl . id ) ;
await this . loadLabels ( ) ;
} catch ( err : any ) {
this . state . error = ` Failed to delete label: ${ err . message } ` ;
this . state . labelsLoading = false ;
this . render ( ) ;
}
}
return ;
}
}
private async handleLabelFormKeypress ( str : string , key : any ) {
if ( ( key && key . name === 'escape' ) || str === '\u001b' ) {
this . state . screen = 'labels-list' ;
this . render ( ) ;
return ;
}
const form = this . state . labelForm ;
const fields : Array < 'name' | 'color' | 'description' | 'exclusive' > = [ 'name' , 'color' , 'description' , 'exclusive' ] ;
const currentIdx = fields . indexOf ( form . activeField ) ;
if ( key && key . name === 'up' ) {
form . activeField = fields [ ( currentIdx - 1 + fields . length ) % fields . length ] ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'down' ) {
form . activeField = fields [ ( currentIdx + 1 ) % fields . length ] ;
this . render ( ) ;
return ;
}
if ( key && key . name === 'tab' ) {
form . activeField = fields [ ( currentIdx + 1 ) % fields . length ] ;
this . render ( ) ;
return ;
}
if ( str === ' ' && form . activeField === 'exclusive' ) {
form . exclusive = ! form . exclusive ;
this . render ( ) ;
return ;
}
if ( ( key && key . name === 'return' ) || str === '\r' || str === '\n' ) {
if ( form . activeField === 'exclusive' ) {
form . exclusive = ! form . exclusive ;
this . render ( ) ;
return ;
}
if ( ! form . name . trim ( ) || ! form . color . trim ( ) ) {
this . state . error = 'Name and Color are required.' ;
this . render ( ) ;
return ;
}
this . state . labelsLoading = true ;
this . state . error = null ;
this . render ( ) ;
try {
if ( this . state . screen === 'create-label' ) {
await createLabel ( this . state . config , {
name : form.name.trim ( ) ,
color : form.color.trim ( ) ,
description : form.description.trim ( ) ,
exclusive : form.exclusive
} ) ;
} else {
const lbl = this . state . labels [ this . state . selectedLabelIndex ] ;
await updateLabel ( this . state . config , lbl . id , {
name : form.name.trim ( ) ,
color : form.color.trim ( ) ,
description : form.description.trim ( ) ,
exclusive : form.exclusive
} ) ;
}
this . state . screen = 'labels-list' ;
await this . loadLabels ( ) ;
} catch ( err : any ) {
this . state . error = err . message ;
this . state . labelsLoading = false ;
this . render ( ) ;
}
return ;
}
if ( key && key . name === 'backspace' ) {
if ( form . activeField === 'name' ) form . name = form . name . slice ( 0 , - 1 ) ;
if ( form . activeField === 'color' ) form . color = form . color . slice ( 0 , - 1 ) ;
if ( form . activeField === 'description' ) form . description = form . description . slice ( 0 , - 1 ) ;
this . render ( ) ;
return ;
}
if ( str && ! key . ctrl && ! key . meta && str . length === 1 && str . charCodeAt ( 0 ) >= 32 ) {
if ( form . activeField === 'name' ) form . name += str ;
if ( form . activeField === 'color' ) form . color += str ;
if ( form . activeField === 'description' ) form . description += str ;
this . render ( ) ;
}
}
private renderLabelsList ( cols : number , rows : number ) {
const header = chalk . bgBlue . white . bold ( ` Repository Labels - ${ this . state . config . owner } / ${ this . state . config . repo } ` ) ;
const headerPad = Math . max ( 0 , Math . floor ( ( cols - stripAnsi ( header ) . length ) / 2 ) ) ;
console . log ( ' ' . repeat ( headerPad ) + header ) ;
console . log ( chalk . gray ( '─' . repeat ( cols ) ) ) ;
if ( this . state . labelsLoading ) {
console . log ( chalk . yellow ( ` Loading labels... ${ SPINNER_FRAMES [ spinnerIndex ] } ` ) ) ;
} else if ( this . state . labels . length === 0 ) {
console . log ( chalk . gray ( ' No labels found.' ) ) ;
} else {
const listHeight = rows - 6 ; // header(2), footer(2), errors etc.
let startIdx = 0 ;
if ( this . state . selectedLabelIndex >= listHeight ) {
startIdx = this . state . selectedLabelIndex - listHeight + 1 ;
}
for ( let i = 0 ; i < listHeight ; i ++ ) {
const itemIdx = startIdx + i ;
if ( itemIdx >= this . state . labels . length ) break ;
const lbl = this . state . labels [ itemIdx ] ;
const isSelected = itemIdx === this . state . selectedLabelIndex ;
let prefix = isSelected ? chalk . green ( ' > ' ) : ' ' ;
let lblDisplay = chalk . bgHex ( '#' + lbl . color ) . black ( ` ${ lbl . name } ` ) ;
if ( lbl . exclusive ) lblDisplay += ' (exclusive)' ;
let line = prefix + lblDisplay ;
if ( lbl . description ) {
line += chalk . gray ( ` - ${ truncate ( lbl . description , cols - 30 ) } ` ) ;
}
if ( isSelected ) {
console . log ( chalk . bgGray ( line + ' ' . repeat ( Math . max ( 0 , cols - stripAnsi ( line ) . length ) ) ) ) ;
} else {
console . log ( line ) ;
}
}
}
// Fill remaining space
const currentLine = this . state . labelsLoading ? 3 : Math.min ( this . state . labels . length , rows - 6 ) + 2 ;
for ( let i = currentLine ; i < rows - 3 ; i ++ ) console . log ( '' ) ;
if ( this . state . error ) {
console . log ( chalk . red ( ` Error: ${ this . state . error } ` ) ) ;
} else {
2026-06-02 22:31:24 +00:00
console . log ( '' ) ;
}
2026-06-02 23:42:07 +00:00
console . log ( chalk . gray ( '─' . repeat ( cols ) ) ) ;
console . log ( chalk . gray ( ' [c] Create [e] Edit [d] Delete [esc] Back to Issues' ) ) ;
}
private renderLabelForm ( cols : number , rows : number ) {
const isEdit = this . state . screen === 'edit-label' ;
const title = isEdit ? 'Edit Label' : 'Create Label' ;
const header = chalk . bgBlue . white . bold ( ` ${ title } ` ) ;
const headerPad = Math . max ( 0 , Math . floor ( ( cols - stripAnsi ( header ) . length ) / 2 ) ) ;
console . log ( ' ' . repeat ( headerPad ) + header ) ;
console . log ( chalk . gray ( '─' . repeat ( cols ) ) ) ;
const form = this . state . labelForm ;
const width = Math . min ( 60 , cols - 4 ) ;
const leftPadStr = ' ' . repeat ( Math . max ( 0 , Math . floor ( ( cols - width ) / 2 ) ) ) ;
console . log ( '' ) ;
// Name
const nameLabel = form . activeField === 'name' ? chalk . cyan . bold ( '> Name: ' ) : ' Name: ' ;
console . log ( leftPadStr + nameLabel + ( form . name || chalk . gray ( '(empty)' ) ) ) ;
console . log ( '' ) ;
// Color
const colorLabel = form . activeField === 'color' ? chalk . cyan . bold ( '> Color (hex without #): ' ) : ' Color: ' ;
console . log ( leftPadStr + colorLabel + ( form . color || chalk . gray ( 'e.g. ff0000' ) ) ) ;
if ( form . color && form . color . length >= 3 ) {
try {
console . log ( leftPadStr + ' Preview: ' + chalk . bgHex ( '#' + form . color ) . black ( ` ${ form . name || 'Label' } ` ) ) ;
} catch ( e ) {
console . log ( leftPadStr + ' Preview: ' + chalk . red ( 'Invalid color' ) ) ;
}
}
console . log ( '' ) ;
// Description
const descLabel = form . activeField === 'description' ? chalk . cyan . bold ( '> Description: ' ) : ' Description: ' ;
console . log ( leftPadStr + descLabel + ( form . description || chalk . gray ( '(empty)' ) ) ) ;
console . log ( '' ) ;
// Exclusive
const exclLabel = form . activeField === 'exclusive' ? chalk . cyan . bold ( '> Exclusive: ' ) : ' Exclusive: ' ;
console . log ( leftPadStr + exclLabel + ( form . exclusive ? chalk . green ( '[x]' ) : '[ ]' ) ) ;
console . log ( '' ) ;
for ( let i = 13 ; i < rows - 3 ; i ++ ) console . log ( '' ) ;
if ( this . state . error ) {
console . log ( leftPadStr + chalk . red ( ` Error: ${ this . state . error } ` ) ) ;
} else if ( this . state . labelsLoading ) {
console . log ( leftPadStr + chalk . yellow ( ` Saving... ${ SPINNER_FRAMES [ spinnerIndex ] } ` ) ) ;
} else {
console . log ( '' ) ;
}
console . log ( chalk . gray ( '─' . repeat ( cols ) ) ) ;
console . log ( chalk . gray ( ' [↑/↓/Tab] Navigate [Enter] Save [Esc] Cancel' ) ) ;
2026-06-02 22:31:24 +00:00
}
2026-06-03 13:34:31 +00:00
private renderHelpScreen ( cols : number , rows : number ) {
const isDetails = this . state . previousScreen === 'details' ;
// Command lists:
const commands : Array < { key : string ; desc : string } > = isDetails
? [
{ key : 'Esc / Backspace' , desc : 'Back to Issues List' } ,
{ key : 'C' , desc : 'Add Comment to Issue' } ,
{ key : 'A' , desc : 'Assign User(s)' } ,
{ key : 'E' , desc : 'Edit Title & Description' } ,
{ key : 'T' , desc : 'Track Time (add time)' } ,
{ key : 'X' , desc : 'Close / Reopen Issue' } ,
{ key : 'R' , desc : 'Reload Single Issue' } ,
{ key : 'O' , desc : 'Global Setup / Connection Settings' } ,
{ key : '↑ / ↓' , desc : 'Scroll description & comments' } ,
{ key : 'PgUp / PgDn' , desc : 'Scroll faster (10 lines)' } ,
{ key : '?' , desc : 'Show / Close Help Menu' }
]
: [
{ key : '↑ / ↓' , desc : 'Navigate issues list' } ,
{ key : 'Enter' , desc : 'View selected issue details' } ,
{ key : 'C' , desc : 'Create new issue' } ,
{ key : '/' , desc : 'Focus Search input field' } ,
{ key : 'S' , desc : 'Cycle Sort fields and order' } ,
{ key : 'F' , desc : 'Cycle State filter (open/closed/all)' } ,
{ key : 'T' , desc : 'Cycle Type filter (issues/pulls/all)' } ,
{ key : 'L' , desc : 'View and manage Labels list' } ,
{ key : 'N / PageDown' , desc : 'Next page of issues' } ,
{ key : 'P / PageUp' , desc : 'Previous page of issues' } ,
{ key : 'R' , desc : 'Reload issues list' } ,
{ key : 'Tab' , desc : 'Toggle focus (Issues List <=> Settings)' } ,
{ key : 'O' , desc : 'Global Setup / Connection Settings' } ,
{ key : 'Q / Esc' , desc : 'Quit application' } ,
{ key : '?' , desc : 'Show / Close Help Menu' }
] ;
const title = ` KEYBOARD COMMANDS HELP ( ${ isDetails ? 'DETAILS VIEW' : 'LIST VIEW' } ) ` ;
const width = Math . min ( 74 , cols - 4 ) ;
const height = commands . length + 6 ; // list + borders + title + instructions
const leftPadStr = ' ' . repeat ( Math . max ( 0 , Math . floor ( ( cols - width ) / 2 ) ) ) ;
// Top border
console . log ( '\n' ) ; // blank line
const borderCh = chalk . bold . hex ( '#4A90E2' ) ;
const titlePadding = '─' . repeat ( Math . max ( 0 , Math . floor ( ( width - 2 - title . length ) / 2 ) ) ) ;
const titleLine = borderCh ( '┌' + titlePadding + title + titlePadding ) + borderCh ( '─' . repeat ( Math . max ( 0 , width - 2 - title . length - 2 * titlePadding . length ) ) + '┐' ) ;
console . log ( leftPadStr + titleLine ) ;
// Render lines
for ( const cmd of commands ) {
const keyStr = chalk . bold . cyan ( cmd . key . padStart ( 22 ) ) ;
const descStr = chalk . white ( cmd . desc ) ;
const row = ` ${ keyStr } ─ ${ descStr } ` ;
const rowPlainLen = stripAnsi ( row ) . length ;
const rowPadding = ' ' . repeat ( Math . max ( 0 , width - 2 - rowPlainLen ) ) ;
console . log ( leftPadStr + borderCh ( '│' ) + row + rowPadding + borderCh ( '│' ) ) ;
}
// Separator line
console . log ( leftPadStr + borderCh ( '├' + '─' . repeat ( width - 2 ) + '┤' ) ) ;
// Instructions line
const instructions = 'Press [Esc], [q], or [?] to close' ;
const instrPadding = ' ' . repeat ( Math . max ( 0 , Math . floor ( ( width - 2 - instructions . length ) / 2 ) ) ) ;
const instrLine = borderCh ( '│' ) + instrPadding + chalk . yellow . bold ( instructions ) + ' ' . repeat ( Math . max ( 0 , width - 2 - instructions . length - instrPadding . length ) ) + borderCh ( '│' ) ;
console . log ( leftPadStr + instrLine ) ;
// Bottom border
console . log ( leftPadStr + borderCh ( '└' + '─' . repeat ( width - 2 ) + '┘' ) ) ;
// Fill remaining rows
const filledRows = 1 + 1 + height + 1 ; // offset + top border + height + newlines
for ( let i = filledRows ; i < rows ; i ++ ) {
console . log ( '' ) ;
}
}
2026-06-02 18:41:33 +00:00
}