diff --git a/src/index.ts b/src/index.ts index 1bad78f..eb00ec0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,9 @@ async function bootstrap() { // Initialize default state const state: AppState = { screen: 'setup', + previousScreen: 'list', + focusedPane: 'list', + selectedSettingIndex: 0, config: { url: '', token: null, diff --git a/src/tui.ts b/src/tui.ts index 01b8026..46e8ae2 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -406,6 +406,18 @@ export class TuiEngine { 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(); } } @@ -656,6 +668,77 @@ export class TuiEngine { 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 + 4) % 4; + this.render(); + return; + } + if (key && key.name === 'right') { + this.state.selectedSettingIndex = (this.state.selectedSettingIndex + 1) % 4; + this.render(); + return; + } + if (key && key.name === 'up') { + return; + } + if (key && key.name === 'down') { + return; + } + if ((key && key.name === 'return') || str === '\r' || str === '\n') { + const selIdx = this.state.selectedSettingIndex; + if (selIdx === 0) { + // Search + this.activeSearchInput = true; + this.searchInputBuffer = this.state.searchQuery; + this.render(); + } else if (selIdx === 1) { + // State + const states: Array<'open' | 'closed' | 'all'> = ['open', 'closed', 'all']; + const currentIdx = states.indexOf(this.state.stateFilter); + this.state.stateFilter = states[(currentIdx + 1) % states.length]; + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + } else if (selIdx === 2) { + // Type + const types: Array<'issues' | 'pulls' | 'all'> = ['issues', 'pulls', 'all']; + const currentIdx = types.indexOf(this.state.typeFilter); + this.state.typeFilter = types[(currentIdx + 1) % types.length]; + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + } else if (selIdx === 3) { + // Sort + const fields: Array<'created' | 'updated' | 'comments' | 'assignees'> = ['created', 'updated', 'comments', 'assignees']; + const currentSortIdx = fields.indexOf(this.state.sortField); + if (this.state.sortOrder === 'desc') { + this.state.sortOrder = 'asc'; + } else { + this.state.sortOrder = 'desc'; + this.state.sortField = fields[(currentSortIdx + 1) % fields.length]; + } + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + } + return; + } + } + + if (str === '?') { + this.state.previousScreen = 'list'; + this.state.screen = 'help'; + this.render(); + return; + } + if ((key && key.name === 'escape') || str === '\u001b') { this.stop(); return; @@ -888,6 +971,13 @@ export class TuiEngine { 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; @@ -936,6 +1026,8 @@ export class TuiEngine { 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); } } @@ -982,23 +1074,37 @@ export class TuiEngine { const renderField = (label: string, value: string, active: boolean, secret: boolean = false) => { const fieldWidth = width - 8; const displayVal = secret ? '*'.repeat(value.length) : value; - const cursorStr = active ? chalk.inverse(' ') : ''; + const valStrLength = displayVal.length + (active ? 1 : 0); + const paddingInsideBracket = Math.max(0, fieldWidth - 19 - valStrLength); - const content = label.padEnd(16) + ': [ ' + - (active ? chalk.bold.cyan(displayVal) + cursorStr : chalk.white(displayVal)).padEnd(active ? displayVal.length + cursorStr.length + (fieldWidth - 19 - displayVal.length) : fieldWidth - 19) + - ' ]'; + let coloredValStr = displayVal; + if (active) { + coloredValStr = chalk.bold.cyan(displayVal) + chalk.inverse(' ') + ' '.repeat(paddingInsideBracket); + } else { + coloredValStr = chalk.white(displayVal) + ' '.repeat(paddingInsideBracket); + } - const coloredContent = active ? chalk.bold.white(content) : chalk.gray(content); + 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('▶ ') : ' '; - return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border; + 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 content = label.padEnd(16) + ': ' + (active ? chalk.bold.cyan(box) : chalk.white(box)); - const coloredContent = active ? chalk.bold.white(content) : chalk.gray(content); - return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border; + 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')); @@ -1231,7 +1337,11 @@ export class TuiEngine { ' ' + timeStr + borderCh; if (isSelected) { - rowText = chalk.bgHex('#2E4E7E').white.bold(rowText); + if (this.state.focusedPane === 'settings') { + rowText = chalk.bgHex('#1C2F4D').white(rowText); + } else { + rowText = chalk.bgHex('#2E4E7E').white.bold(rowText); + } } console.log(borderCh + rowText); @@ -1245,22 +1355,49 @@ export class TuiEngine { console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); // Render Filters line - let searchLabel = this.state.searchQuery ? chalk.yellow(`"${this.state.searchQuery}"`) : chalk.gray('none'); - if (this.activeSearchInput) { - searchLabel = chalk.inverse(this.searchInputBuffer + ' '); - } - const stateFilterLabel = chalk.bold(this.state.stateFilter.toUpperCase()); - const typeFilterLabel = chalk.bold(this.state.typeFilter.toUpperCase()); - const sortLabel = chalk.bold(`${this.state.sortField} (${this.state.sortOrder.toUpperCase()})`); + const isSettings = this.state.focusedPane === 'settings'; + const selIdx = this.state.selectedSettingIndex ?? 0; + + const buildSegment = (idx: number, label: string, val: string, formattedVal: string) => { + const text = ` ${label}: ${val} `; + if (isSettings && selIdx === idx) { + return chalk.bgHex('#2E4E7E').white.bold(text); + } + return chalk.gray(` ${label}: `) + formattedVal; + }; + + const searchPlain = this.activeSearchInput ? this.searchInputBuffer : (this.state.searchQuery || 'none'); + const searchLabel = this.activeSearchInput + ? chalk.inverse(this.searchInputBuffer + ' ') + : (this.state.searchQuery ? chalk.yellow(`"${this.state.searchQuery}"`) : chalk.gray('none')); + + const statePlain = this.state.stateFilter.toUpperCase(); + const stateLabel = chalk.bold(statePlain); + + const typePlain = this.state.typeFilter.toUpperCase(); + const typeLabel = chalk.bold(typePlain); + + const sortPlain = `${this.state.sortField} (${this.state.sortOrder.toUpperCase()})`; + const sortLabel = chalk.bold(sortPlain); + + const seg0 = buildSegment(0, 'Search', searchPlain, searchLabel); + const seg1 = buildSegment(1, 'State', statePlain, stateLabel); + const seg2 = buildSegment(2, 'Type', typePlain, typeLabel); + const seg3 = buildSegment(3, 'Sort', sortPlain, sortLabel); + + const separator = chalk.bold.hex('#4A90E2')(' ─ '); + const filtersText = ` ${seg0}${separator}${seg1}${separator}${seg2}${separator}${seg3}`; - const filtersText = ` Search: ${searchLabel} ─ State: ${stateFilterLabel} ─ Type: ${typeFilterLabel} ─ Sort: ${sortLabel}`; 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 - const fullHelpLine = ' [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [L] Labels [N/P] Page [R] Reload [O] Settings [Esc] Quit'; + 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; @@ -1400,7 +1537,7 @@ export class TuiEngine { } const actionKey = issue.state === 'open' ? 'Close' : 'Reopen'; - const helpLine = chalk.gray(` [Esc/Backspace] Back to List [C] Add Comment [A] Assign [E] Edit [T] Add Time [X] ${actionKey} [R] Reload [O] Settings ${scrollHelp}`); + const helpLine = chalk.gray(` [?] Help [Esc/Backspace] Back to List [C] Add Comment [A] Assign [E] Edit [T] Add Time [X] ${actionKey} [R] Reload [O] Settings ${scrollHelp}`); process.stdout.write(helpLine); } @@ -2722,4 +2859,83 @@ export class TuiEngine { 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: '↑ / ↓', 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(''); + } + } } diff --git a/src/types.ts b/src/types.ts index b37a276..0331fbd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,7 +80,7 @@ export interface LabelForm { activeField: 'name' | 'color' | 'description' | 'exclusive'; } -export type ScreenType = 'launch' | 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees' | 'labels-list' | 'create-label' | 'edit-label'; +export type ScreenType = 'launch' | 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees' | 'labels-list' | 'create-label' | 'edit-label' | 'help'; export interface RepoItem { @@ -93,8 +93,12 @@ export interface RepoItem { export interface AppState { screen: ScreenType; + previousScreen?: ScreenType; config: Config; + focusedPane: 'list' | 'settings'; + selectedSettingIndex: number; + // List Screen State issues: Issue[]; currentPage: number;