import readline from "readline"; import chalk from "chalk"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { AppState, Issue, Comment } from "./types.js"; import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime, changeIssueState, setIssueAssignees, fetchLabels, createLabel, updateLabel, deleteLabel, } 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; 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(" __|_____|__ "), ], ]; 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"; } } // Utility to safely truncate strings function truncate(str: string, length: number): string { if (str.length <= length) return str; 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`; } /** * 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 = ""; private animationFrame: number = 0; private animationInterval: NodeJS.Timeout | null = null; private launchDestScreen: "list" | "setup" = "setup"; private launchFrame: string[] = []; private launchFrameIndex = 0; private launchInterval: NodeJS.Timeout | null = null; private maxLaunchFrames = 60; private autoRefreshTimer: NodeJS.Timeout | null = null; private restartAutoRefresh() { if (this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; } if (this.state.autoRefreshInterval && this.state.autoRefreshInterval > 0) { this.autoRefreshTimer = setInterval(() => { // We need a way to check if input is active, but isTextInputActive isn't accessible here if we didn't add it. // Wait, isTextInputActive is a method of TuiEngine, or I reverted it? // Ah! I reverted isTextInputActive earlier! const isTextInputActive = this.state.screen === "setup" || (this.state.screen === "repo-picker" && this.state.repoPickerActiveSearch) || (this.state.screen === "list" && this.activeSearchInput) || this.state.screen === "create-issue" || this.state.screen === "add-comment" || this.state.screen === "edit-issue" || this.state.screen === "add-time" || this.state.screen === "set-assignees" || this.state.screen === "create-label" || this.state.screen === "edit-label"; if (isTextInputActive) return; // Background refresh if (this.state.screen === "list") { this.loadIssues(); } else if ( this.state.screen === "details" && this.state.selectedIssue ) { this.reloadSingleIssue(); } }, this.state.autoRefreshInterval * 1000); } } 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)); // Enter alternate screen buffer and hide standard cursor process.stdout.write("\x1B[?1049h\x1B[?25l"); // Bootstrap destination if ( this.state.config.url && this.state.config.owner && this.state.config.repo ) { this.launchDestScreen = "list"; } else { this.launchDestScreen = "setup"; } this.restartAutoRefresh(); this.state.screen = "launch"; this.startLaunchAnimation(); } /** * Stops the TUI engine, restores terminal state. */ public stop() { this.stopSpinner(); if (this.animationInterval) { clearInterval(this.animationInterval); this.animationInterval = null; } if (this.launchInterval) { clearInterval(this.launchInterval); this.launchInterval = null; } if (this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; } // Exit alternate screen buffer and show standard cursor process.stdout.write("\x1B[?1049l\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 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(); } } /** * 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 === "launch") { this.skipLaunchAnimation(); 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); } else if (this.state.screen === "create-issue") { this.handleCreateIssueKeypress(str, key); } else if (this.state.screen === "confirm-cancel-create") { this.handleConfirmCancelCreateKeypress(str, key); } else if (this.state.screen === "confirm-cancel-edit") { this.handleConfirmCancelEditKeypress(str, key); } else if (this.state.screen === "add-comment") { this.handleAddCommentKeypress(str, key); } else if (this.state.screen === "edit-issue") { this.handleEditIssueKeypress(str, key); } else if (this.state.screen === "add-time") { this.handleAddTimeKeypress(str, key); } else if (this.state.screen === "set-assignees") { this.handleSetAssigneesKeypress(str, key); } 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); } 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(); } } /** * 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") || str === "k") { if (this.state.selectedRepoIndex > 0) { this.state.selectedRepoIndex--; this.render(); } return; } if ((key && key.name === "down") || str === "j") { 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 === "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 + 5) % 5; this.render(); return; } if (key && key.name === "right") { this.state.selectedSettingIndex = (this.state.selectedSettingIndex + 1) % 5; 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(); } else if (selIdx === 4) { // Auto-Refresh const intervals = [0, 15, 30, 60, 300]; const currentIdx = intervals.indexOf( this.state.autoRefreshInterval || 0, ); this.state.autoRefreshInterval = intervals[(currentIdx + 1) % intervals.length]; this.state.config.autoRefreshInterval = this.state.autoRefreshInterval; // Restart timer this.restartAutoRefresh(); // Save to config globally saveGlobalConfig(this.state.config); this.render(); } return; } } if (str === "?") { this.state.previousScreen = "list"; this.state.screen = "help"; this.render(); 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") || str === "k") { if (this.state.selectedIssueIndex > 0) { this.state.selectedIssueIndex--; this.render(); } return; } if ((key && key.name === "down") || str === "j") { 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 -> assignees const fields: Array<"created" | "updated" | "comments" | "assignees"> = [ "created", "updated", "comments", "assignees", ]; 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 === "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.state.createIssueForm.cursor = this.state.createIssueForm.title.length; this.render(); 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 === "l" || str === "L") { this.state.screen = "labels-list"; this.state.selectedLabelIndex = 0; this.loadLabels(); return; } if (str === "q" || str === "Q") { this.stop(); } } /** * Key handling for detail screen scrolling */ private handleDetailsKeypress(str: string, key: any) { 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 === "return") || str === "\r" || str === "\n") { const intervals = [0, 15, 30, 60, 300]; const currentIdx = intervals.indexOf( this.state.autoRefreshInterval || 0, ); this.state.autoRefreshInterval = intervals[(currentIdx + 1) % intervals.length]; this.state.config.autoRefreshInterval = this.state.autoRefreshInterval; this.restartAutoRefresh(); saveGlobalConfig(this.state.config); this.render(); return; } if ((key && key.name === "escape") || str === "\u001b") { this.state.focusedPane = "list"; this.render(); return; } // Swallow other keys in settings mode return; } if ((key && key.name === "up") || str === "k") { if (this.state.detailScrollOffset > 0) { this.state.detailScrollOffset--; this.render(); } return; } // Navigate with j/k as well for vim-style navigation in details view content scroll else if ((key && key.name === "down") || str === "j") { this.state.detailScrollOffset++; 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 (str === "c" || str === "C") { this.state.screen = "add-comment"; this.state.addCommentForm.body = ""; this.state.error = null; this.render(); return; } 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.editIssueForm.cursor = this.state.editIssueForm.title.length; this.state.error = null; this.render(); } return; } if (str === "r" || str === "R") { if (this.state.selectedIssue) { this.reloadSingleIssue(); } return; } 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; } if (str === "x" || str === "X") { if (this.state.selectedIssue) { this.state.screen = "confirm-state-change"; this.state.error = null; this.render(); } return; } 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; } if (str === "?") { this.state.previousScreen = "details"; this.state.screen = "help"; 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, clear scrollback buffer, and reset cursor position process.stdout.write("\x1B[2J\x1B[3J\x1B[H"); if (this.state.screen === "launch") { this.renderLaunchScreen(cols, rows); } else 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); } else if (this.state.screen === "create-issue") { this.renderCreateIssueScreen(cols, rows); } else if (this.state.screen === "add-comment") { this.renderAddCommentScreen(cols, rows); } else if (this.state.screen === "edit-issue") { this.renderEditIssueScreen(cols, rows); } else if (this.state.screen === "add-time") { this.renderAddTimeScreen(cols, rows); } else if (this.state.screen === "confirm-state-change") { this.renderConfirmStateChangeScreen(cols, rows); } else if (this.state.screen === "confirm-cancel-create") { this.renderConfirmCancelCreateScreen(cols, rows); } else if (this.state.screen === "confirm-cancel-edit") { this.renderConfirmCancelEditScreen(cols, rows); } 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); } else if (this.state.screen === "set-assignees") { this.renderSetAssigneesScreen(cols, rows); } 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); } else if (this.state.screen === "help") { this.renderHelpScreen(cols, rows); } } 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)); } /** * 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 valStrLength = displayVal.length + (active ? 1 : 0); const paddingInsideBracket = Math.max(0, fieldWidth - 19 - valStrLength); let coloredValStr = displayVal; if (active) { coloredValStr = chalk.bold.cyan(displayVal) + chalk.inverse(" ") + " ".repeat(paddingInsideBracket); } else { coloredValStr = chalk.white(displayVal) + " ".repeat(paddingInsideBracket); } 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(" ]")); const activeMarker = active ? chalk.cyan("▶ ") : " "; const remainingPadding = Math.max(0, width - 6 - plainContent.length); return ( border + " " + activeMarker + coloredContent + " ".repeat(remainingPadding) + " " + border ); }; const renderCheckbox = ( label: string, checked: boolean, active: boolean, ) => { const activeMarker = active ? chalk.cyan("▶ ") : " "; const box = checked ? "[X]" : "[ ]"; 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 ); }; 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?:\/\//, "", ); const rightHeader = ""; const rightLen = 0; // We want the total line length to fit exactly within 'cols' const maxLeftWidth = cols - 1; // Spinner plain length is 2 if loading, 0 otherwise const spinnerPlainLen = this.state.loading ? 2 : 0; const titlePlain = "Forgejo Issue Explorer"; const titleColor = chalk.bold.hex("#4A90E2")(titlePlain); const instanceColor = ` ─ ${chalk.bold.white(instanceName)}`; const instancePlain = ` ─ ${instanceName}`; let statsColor = ""; let statsPlain = ""; let shortStatsColor = ""; let shortStatsPlain = ""; if (this.state.totalIssuesCount > 0) { const totalPages = Math.ceil( this.state.totalIssuesCount / this.state.issuesPerPage, ); 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})`; } else if (!this.state.loading) { statsColor = " ─ (0 issues found)"; statsPlain = " ─ (0 issues found)"; shortStatsColor = " ─ (0)"; shortStatsPlain = " ─ (0)"; } 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}`; } const leftLen = stripAnsi(leftHeader).length; let spacesCount = cols - leftLen - rightLen; if (spacesCount < 1) spacesCount = 1; console.log(leftHeader + " ".repeat(spacesCount - 1) + 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 assigneesWidth = 14; const labelsWidth = 16; const createdWidth = 12; const commentsWidth = 6; const timeWidth = 7; // 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, ); // 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("Assignees", assigneesWidth) + borderCh + " " + padHeader("Labels", labelsWidth) + borderCh + " " + padHeader("Created", createdWidth) + borderCh + " " + padHeader("Coms", commentsWidth) + borderCh + " " + padHeader("Time", timeWidth) + borderCh, ); console.log(chalk.bold.hex("#4A90E2")("├" + "─".repeat(cols - 2) + "┤")); // Table Content Height available (rows - 10 ensures total printed lines is rows - 2, preventing terminal scrolling) const tableHeight = rows - 10; 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 assigneesNames = (issue.assignees || []) .map((u) => u.login) .join(","); const assigneesStr = truncate(assigneesNames, assigneesWidth).padEnd( assigneesWidth, ); 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)); const createdStr = formatDate(issue.created_at) .substring(0, 10) .padEnd(createdWidth); const commentsStr = String(issue.comments).padEnd(commentsWidth); const timeStr = formatTime(issue.total_tracked_time).padEnd( timeWidth, ); let rowText = " " + idStr + borderCh + " " + typeStr + borderCh + " " + stateStr + borderCh + " " + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh + " " + authorStr + borderCh + " " + assigneesStr + borderCh + " " + issueLabelsStr + borderCh + " " + createdStr + borderCh + " " + commentsStr + borderCh + " " + timeStr + borderCh; if (isSelected) { if (this.state.focusedPane === "settings") { rowText = chalk.bgHex("#1C2F4D").white(rowText); } else { 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 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); let refreshStr = "Off"; if (this.state.autoRefreshInterval) { if (this.state.autoRefreshInterval >= 60) { refreshStr = `${this.state.autoRefreshInterval / 60}m`; } else { refreshStr = `${this.state.autoRefreshInterval}s`; } } const seg4 = buildSegment(4, "Refresh", refreshStr, chalk.bold(refreshStr)); const separator = chalk.bold.hex("#4A90E2")(" ─ "); const filtersText = ` ${seg0}${separator}${seg1}${separator}${seg2}${separator}${seg3}${separator}${seg4}`; const filtersPlainLen = stripAnsi(filtersText).length; const padding = Math.max(0, cols - 2 - filtersPlainLen); console.log(borderCh + filtersText + " ".repeat(padding) + borderCh); console.log(chalk.bold.hex("#4A90E2")("└" + "─".repeat(cols - 2) + "┘")); // Keyboard controls help line 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"; } 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, ); // 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(" "); const assigneesStr = (issue.assignees || []).map((u) => u.login).join(", "); const assigneesDisp = assigneesStr ? chalk.magenta(assigneesStr) : chalk.gray("none"); const timeStr = formatTime(issue.total_tracked_time); 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)}`; const metaPlainLen = stripAnsi(metaLine).length; const padding = Math.max(0, cols - 2 - metaPlainLen); console.log(borderCh + metaLine + " ".repeat(padding) + borderCh); if (labels) { const labelsLine = ` Labels: ${labels}`; const labelsPlainLen = stripAnsi(labelsLine).length; const labelsPadding = Math.max(0, cols - 2 - labelsPlainLen); console.log(borderCh + labelsLine + " ".repeat(labelsPadding) + borderCh); } 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 metaHeight = labels ? 3 : 2; // Meta 1, optionally Meta 2 (Labels), and Separator const displayHeight = rows - 7 - metaHeight; // Header: 1, Borders: 3, Meta, Settings: 1, Help: 1, and -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]; const linePlainLen = stripAnsi(line).length; const padding = Math.max(0, cols - 4 - linePlainLen); console.log( borderCh + " " + line + " ".repeat(padding) + " " + borderCh, ); } else { console.log(borderCh + " ".repeat(cols - 2) + borderCh); } } console.log(chalk.bold.hex("#4A90E2")("├" + "─".repeat(cols - 2) + "┤")); const isSettings = this.state.focusedPane === "settings"; let refreshStr = "Off"; if (this.state.autoRefreshInterval) { if (this.state.autoRefreshInterval >= 60) { refreshStr = `${this.state.autoRefreshInterval / 60}m`; } else { refreshStr = `${this.state.autoRefreshInterval}s`; } } const text = ` Auto-Refresh: ${refreshStr} `; const coloredText = isSettings ? chalk.bgHex("#2E4E7E").white.bold(text) : chalk.gray(` Auto-Refresh: `) + chalk.bold(refreshStr); const paddingLength = Math.max(0, cols - 2 - stripAnsi(text).length - 1); console.log( borderCh + " " + coloredText + " ".repeat(paddingLength) + 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 actionKey = issue.state === "open" ? "Close" : "Reopen"; let helpLine = chalk.gray( ` [?] Help [Esc/Backspace] Back to List [C] Add Comment [A] Assign [E] Edit [T] Add Time [X] ${actionKey} [R] Reload [Tab] Menu ${scrollHelp}`, ); if (isSettings) { helpLine = chalk.gray( ` [?] Help [Tab] Back to Details [Enter] Toggle Auto-Refresh [Esc] Quit menu`, ); } 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 } } /** * 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; let titleContent = ""; if (form.activeField === "title") { titleLabel = chalk.yellow(" Title: "); const c = Math.min(form.cursor || 0, form.title.length); let startIdx = 0; if (c > cols - 13) { startIdx = c - (cols - 13) + 1; } const visTitle = form.title.substring(startIdx); const visCursor = c - startIdx; const visBefore = visTitle.substring(0, visCursor); const visChar = visCursor < visTitle.length ? visTitle[visCursor] : " "; const visAfter = visTitle .substring(visCursor + 1) .padEnd(Math.max(0, cols - 13 - visBefore.length - 1), " ") .substring(0, Math.max(0, cols - 13 - visBefore.length - 1)); titleContent = visBefore + chalk.inverse(visChar) + visAfter; } else { titleVisible = titleVisible.length > cols - 13 ? titleVisible.substring(titleVisible.length - (cols - 13)) : titleVisible; titleContent = titleVisible.padEnd(cols - 12); } 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"); let cursorLine = 0; let cursorCol = 0; let remaining = Math.min(form.cursor || 0, form.body.length); for (let i = 0; i < lines.length; i++) { if (remaining <= lines[i].length) { cursorLine = i; cursorCol = remaining; break; } remaining -= lines[i].length + 1; } let startLine = Math.max(0, lines.length - bodyRows); if (form.activeField === "body") { if (cursorLine < startLine) { startLine = cursorLine; } else if (cursorLine >= startLine + bodyRows) { startLine = cursorLine - bodyRows + 1; } } for (let i = 0; i < bodyRows; i++) { const lineIdx = startLine + i; let lineContent = ""; if (lineIdx < lines.length) { lineContent = lines[lineIdx]; } if (form.activeField === "body" && lineIdx === cursorLine) { let startCol = 0; if (cursorCol > cols - 6) { startCol = cursorCol - (cols - 6) + 1; } const visLineStr = lineContent.substring(startCol); const visCursorCol = cursorCol - startCol; const before = visLineStr.substring(0, visCursorCol); const char = visCursorCol < visLineStr.length ? visLineStr[visCursorCol] : " "; const after = visLineStr.substring(visCursorCol + 1); let visLine = before + chalk.inverse(char) + after; const plainLen = stripAnsi(visLine).length; if (plainLen < cols - 4) { visLine += " ".repeat(cols - 4 - plainLen); } console.log(borderCh + " " + visLine + " " + borderCh); } else { let visLineStr = lineContent; if (visLineStr.length > cols - 5) { visLineStr = visLineStr.substring(visLineStr.length - (cols - 5)); } visLineStr = visLineStr.padEnd(cols - 4); console.log(borderCh + " " + visLineStr + " " + 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 = "confirm-cancel-create"; 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.state.createIssueForm.cursor = this.state.createIssueForm[ this.state.createIssueForm.activeField ].length; this.render(); return; } // Text input handling const activeField = this.state.createIssueForm.activeField; let val = this.state.createIssueForm[activeField]; let cursor = this.state.createIssueForm.cursor; if (cursor === undefined || cursor > val.length) cursor = val.length; if (key && key.name === "left") { this.state.createIssueForm.cursor = Math.max(0, cursor - 1); this.render(); return; } if (key && key.name === "right") { this.state.createIssueForm.cursor = Math.min(val.length, cursor + 1); this.render(); return; } if ( key && (key.name === "up" || key.name === "down") && activeField === "body" ) { const lines = val.split("\n"); let lineIdx = 0, col = 0, count = 0; for (let i = 0; i < lines.length; i++) { if (count + lines[i].length >= cursor) { lineIdx = i; col = cursor - count; break; } count += lines[i].length + 1; } if (key.name === "up" && lineIdx > 0) { const prevLine = lines[lineIdx - 1]; const newCol = Math.min(col, prevLine.length); let newCursor = 0; for (let i = 0; i < lineIdx - 1; i++) newCursor += lines[i].length + 1; this.state.createIssueForm.cursor = newCursor + newCol; this.render(); } else if (key.name === "down" && lineIdx < lines.length - 1) { const nextLine = lines[lineIdx + 1]; const newCol = Math.min(col, nextLine.length); let newCursor = 0; for (let i = 0; i <= lineIdx; i++) newCursor += lines[i].length + 1; this.state.createIssueForm.cursor = newCursor + newCol; this.render(); } return; } if (key && (key.name === "backspace" || key.name === "delete")) { if (key.name === "backspace" && cursor > 0) { this.state.createIssueForm[activeField] = val.slice(0, cursor - 1) + val.slice(cursor); this.state.createIssueForm.cursor = cursor - 1; this.render(); } else if (key.name === "delete" && cursor < val.length) { this.state.createIssueForm[activeField] = val.slice(0, cursor) + val.slice(cursor + 1); this.render(); } return; } if (key && key.name === "return") { if (activeField === "body") { this.state.createIssueForm.body = val.slice(0, cursor) + "\n" + val.slice(cursor); this.state.createIssueForm.cursor = cursor + 1; this.render(); } else { this.state.createIssueForm.activeField = "body"; this.state.createIssueForm.cursor = this.state.createIssueForm.body.length; this.render(); } return; } if (str && str.length === 1 && !key.ctrl && !key.meta) { this.state.error = null; this.state.createIssueForm[activeField] = val.slice(0, cursor) + str + val.slice(cursor); this.state.createIssueForm.cursor = cursor + 1; this.render(); } } 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(); } } 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; let titleContent = ""; if (form.activeField === "title") { titleLabel = chalk.yellow(" Title: "); const c = Math.min(form.cursor || 0, form.title.length); let startIdx = 0; if (c > cols - 13) { startIdx = c - (cols - 13) + 1; } const visTitle = form.title.substring(startIdx); const visCursor = c - startIdx; const visBefore = visTitle.substring(0, visCursor); const visChar = visCursor < visTitle.length ? visTitle[visCursor] : " "; const visAfter = visTitle .substring(visCursor + 1) .padEnd(Math.max(0, cols - 13 - visBefore.length - 1), " ") .substring(0, Math.max(0, cols - 13 - visBefore.length - 1)); titleContent = visBefore + chalk.inverse(visChar) + visAfter; } else { titleVisible = titleVisible.length > cols - 13 ? titleVisible.substring(titleVisible.length - (cols - 13)) : titleVisible; titleContent = titleVisible.padEnd(cols - 12); } 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"); let cursorLine = 0; let cursorCol = 0; let remaining = Math.min(form.cursor || 0, form.body.length); for (let i = 0; i < lines.length; i++) { if (remaining <= lines[i].length) { cursorLine = i; cursorCol = remaining; break; } remaining -= lines[i].length + 1; } let startLine = Math.max(0, lines.length - bodyRows); if (form.activeField === "body") { if (cursorLine < startLine) { startLine = cursorLine; } else if (cursorLine >= startLine + bodyRows) { startLine = cursorLine - bodyRows + 1; } } for (let i = 0; i < bodyRows; i++) { const lineIdx = startLine + i; let lineContent = ""; if (lineIdx < lines.length) { lineContent = lines[lineIdx]; } if (form.activeField === "body" && lineIdx === cursorLine) { let startCol = 0; if (cursorCol > cols - 6) { startCol = cursorCol - (cols - 6) + 1; } const visLineStr = lineContent.substring(startCol); const visCursorCol = cursorCol - startCol; const before = visLineStr.substring(0, visCursorCol); const char = visCursorCol < visLineStr.length ? visLineStr[visCursorCol] : " "; const after = visLineStr.substring(visCursorCol + 1); let visLine = before + chalk.inverse(char) + after; const plainLen = stripAnsi(visLine).length; if (plainLen < cols - 4) { visLine += " ".repeat(cols - 4 - plainLen); } console.log(borderCh + " " + visLine + " " + borderCh); } else { let visLineStr = lineContent; if (visLineStr.length > cols - 5) { visLineStr = visLineStr.substring(visLineStr.length - (cols - 5)); } visLineStr = visLineStr.padEnd(cols - 4); console.log(borderCh + " " + visLineStr + " " + 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 = "confirm-cancel-edit"; 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.state.editIssueForm.cursor = this.state.editIssueForm[this.state.editIssueForm.activeField].length; this.render(); return; } // Text input handling const activeField = this.state.editIssueForm.activeField; let val = this.state.editIssueForm[activeField]; let cursor = this.state.editIssueForm.cursor; if (cursor === undefined || cursor > val.length) cursor = val.length; if (key && key.name === "left") { this.state.editIssueForm.cursor = Math.max(0, cursor - 1); this.render(); return; } if (key && key.name === "right") { this.state.editIssueForm.cursor = Math.min(val.length, cursor + 1); this.render(); return; } if ( key && (key.name === "up" || key.name === "down") && activeField === "body" ) { const lines = val.split("\n"); let lineIdx = 0, col = 0, count = 0; for (let i = 0; i < lines.length; i++) { if (count + lines[i].length >= cursor) { lineIdx = i; col = cursor - count; break; } count += lines[i].length + 1; } if (key.name === "up" && lineIdx > 0) { const prevLine = lines[lineIdx - 1]; const newCol = Math.min(col, prevLine.length); let newCursor = 0; for (let i = 0; i < lineIdx - 1; i++) newCursor += lines[i].length + 1; this.state.editIssueForm.cursor = newCursor + newCol; this.render(); } else if (key.name === "down" && lineIdx < lines.length - 1) { const nextLine = lines[lineIdx + 1]; const newCol = Math.min(col, nextLine.length); let newCursor = 0; for (let i = 0; i <= lineIdx; i++) newCursor += lines[i].length + 1; this.state.editIssueForm.cursor = newCursor + newCol; this.render(); } return; } if (key && (key.name === "backspace" || key.name === "delete")) { if (key.name === "backspace" && cursor > 0) { this.state.editIssueForm[activeField] = val.slice(0, cursor - 1) + val.slice(cursor); this.state.editIssueForm.cursor = cursor - 1; this.render(); } else if (key.name === "delete" && cursor < val.length) { this.state.editIssueForm[activeField] = val.slice(0, cursor) + val.slice(cursor + 1); this.render(); } return; } if (key && key.name === "return") { if (activeField === "body") { this.state.editIssueForm.body = val.slice(0, cursor) + "\n" + val.slice(cursor); this.state.editIssueForm.cursor = cursor + 1; this.render(); } else { this.state.editIssueForm.activeField = "body"; this.state.editIssueForm.cursor = this.state.editIssueForm.body.length; this.render(); } return; } if (str && str.length === 1 && !key.ctrl && !key.meta) { this.state.error = null; this.state.editIssueForm[activeField] = val.slice(0, cursor) + str + val.slice(cursor); this.state.editIssueForm.cursor = cursor + 1; this.render(); } } 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(); } } 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; const newState = issue.state === "open" ? "closed" : "open"; this.state.screen = newState === "closed" ? "animating-close" : "animating-reopen"; this.animationFrame = 0; this.render(); if (this.animationInterval) { clearInterval(this.animationInterval); } 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); return; } } private renderConfirmCancelCreateScreen(cols: number, rows: number) { const borderCh = chalk.bold.hex("#4A90E2")("│"); const title = " Confirm Cancel "; 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) + "┐")); const question = "Are you sure you want to cancel creating this issue?"; 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 padContentLeft = Math.max( 0, Math.floor((cols - 2 - promptPlain.length) / 2), ); const contentLine = ( " ".repeat(padContentLeft) + 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 handleConfirmCancelCreateKeypress(str: string, key: any) { if ( (key && (key.name === "escape" || key.name === "n")) || str === "n" || str === "N" ) { this.state.screen = "create-issue"; this.state.error = null; this.render(); return; } if (str === "y" || str === "Y") { this.state.screen = "list"; this.state.error = null; this.render(); return; } } private renderConfirmCancelEditScreen(cols: number, rows: number) { const borderCh = chalk.bold.hex("#4A90E2")("│"); const title = " Confirm Cancel "; 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) + "┐")); const question = "Are you sure you want to cancel editing this issue?"; 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 padContentLeft = Math.max( 0, Math.floor((cols - 2 - promptPlain.length) / 2), ); const contentLine = ( " ".repeat(padContentLeft) + 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 handleConfirmCancelEditKeypress(str: string, key: any) { if ( (key && (key.name === "escape" || key.name === "n")) || str === "n" || str === "N" ) { this.state.screen = "edit-issue"; this.state.error = null; this.render(); return; } if (str === "y" || str === "Y") { this.state.screen = "details"; this.state.error = null; this.render(); return; } } 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) + "┘")); 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(); } } // 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); } // --- 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") || str === "k") { if (this.state.selectedLabelIndex > 0) { this.state.selectedLabelIndex--; this.render(); } return; } if ((key && key.name === "down") || str === "j") { 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 { console.log(""); } 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")); } 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: "↑ / ↓ / j/k", 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(""); } } }