import readline from 'readline'; import chalk from 'chalk'; import { AppState, Issue, Comment } from './types.js'; import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl } from './api.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) + '...'; } /** * 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 === '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' | 'repo' | 'token'> = ['url', 'repo', 'token']; 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 ((key && key.name === 'return') || str === '\r' || str === '\n') { // Submit form if (!form.url.trim()) { this.state.error = 'Instance URL is required.'; this.render(); return; } if (!form.repo.trim()) { this.state.error = 'Repository path (owner/repo) is required.'; this.render(); return; } const repoParts = form.repo.split('/'); if (repoParts.length !== 2 || !repoParts[0].trim() || !repoParts[1].trim()) { this.state.error = 'Repository must be in "owner/repo" format.'; this.render(); return; } this.state.loading = true; this.state.error = null; this.render(); const normalizedUrl = normalizeUrl(form.url); const testConfig = { url: normalizedUrl, token: form.token.trim() || null, owner: repoParts[0].trim(), repo: repoParts[1].trim(), }; try { await validateConnection(testConfig); // Save config this.state.config = testConfig; this.state.screen = 'list'; 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 === '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 === 'repo') form.repo = form.repo.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 === 'repo') form.repo += str; if (form.activeField === 'token') form.token += str; this.render(); } } /** * 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') { // If we entered config via CLI, quit. If we came from setup form, go back to setup! if (this.state.setupForm.repo) { this.state.screen = 'setup'; this.state.error = null; this.render(); } else { this.stop(); } 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 ((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 === '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; }; lines.push(renderField('Gitea URL', form.url, form.activeField === 'url')); lines.push(border + ' '.repeat(width - 2) + border); lines.push(renderField('Repository', form.repo, form.activeField === 'repo')); lines.push(border + ' ' + chalk.gray('Format: owner/repo (e.g. gitea/tea)').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); // 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 [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 === 'repo') activeRowOffset += 2; if (form.activeField === 'token') activeRowOffset += 4; const activeValue = form.activeField === 'token' ? '*'.repeat(form.token.length) : form[form.activeField]; const cursorCol = Math.max(0, Math.floor((cols - width) / 2)) + 23 + 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 header = ` ${spinnerStr}${chalk.bold.hex('#4A90E2')('Forgejo Issue Explorer')} ─ ${chalk.bold.white(instanceName)} ─ repo: ${chalk.bold.cyan(`${this.state.config.owner}/${this.state.config.repo}`)}`; if (this.state.totalIssuesCount > 0) { const totalPages = Math.ceil(this.state.totalIssuesCount / this.state.issuesPerPage); header += ` ─ Page ${chalk.bold.white(this.state.currentPage)} of ${chalk.bold.white(totalPages)} (${chalk.yellow(this.state.totalIssuesCount)} matching)`; } else if (!this.state.loading) { header += ' ─ (0 issues found)'; } console.log(header); 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 [Esc] Back'); 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] '); console.log(` ${typeTag}#${issue.number} ─ ${chalk.bold.white(truncate(issue.title, cols - 16))}`); 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 ${scrollHelp}`); process.stdout.write(helpLine); } }