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