add help mode, tab to sort

This commit is contained in:
Isaac Johnson 2026-06-03 08:34:31 -05:00
parent e70afe6c05
commit 1d633fe199
3 changed files with 244 additions and 21 deletions

View File

@ -34,6 +34,9 @@ async function bootstrap() {
// Initialize default state // Initialize default state
const state: AppState = { const state: AppState = {
screen: 'setup', screen: 'setup',
previousScreen: 'list',
focusedPane: 'list',
selectedSettingIndex: 0,
config: { config: {
url: '', url: '',
token: null, token: null,

View File

@ -406,6 +406,18 @@ export class TuiEngine {
this.handleLabelsListKeypress(str, key); this.handleLabelsListKeypress(str, key);
} else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') { } else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') {
this.handleLabelFormKeypress(str, key); 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) { private handleListKeypress(str: string, key: any) {
if (this.state.loading) return; 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') { if ((key && key.name === 'escape') || str === '\u001b') {
this.stop(); this.stop();
return; return;
@ -888,6 +971,13 @@ export class TuiEngine {
return; 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') { if ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') {
this.state.screen = 'list'; this.state.screen = 'list';
this.state.selectedIssue = null; this.state.selectedIssue = null;
@ -936,6 +1026,8 @@ export class TuiEngine {
this.renderLabelsList(cols, rows); this.renderLabelsList(cols, rows);
} else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') { } else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') {
this.renderLabelForm(cols, rows); 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 renderField = (label: string, value: string, active: boolean, secret: boolean = false) => {
const fieldWidth = width - 8; const fieldWidth = width - 8;
const displayVal = secret ? '*'.repeat(value.length) : value; 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) + ': [ ' + let coloredValStr = displayVal;
(active ? chalk.bold.cyan(displayVal) + cursorStr : chalk.white(displayVal)).padEnd(active ? displayVal.length + cursorStr.length + (fieldWidth - 19 - displayVal.length) : fieldWidth - 19) + 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 coloredContent = active ? chalk.bold.white(content) : chalk.gray(content);
const activeMarker = active ? chalk.cyan('▶ ') : ' '; 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 renderCheckbox = (label: string, checked: boolean, active: boolean) => {
const activeMarker = active ? chalk.cyan('▶ ') : ' '; const activeMarker = active ? chalk.cyan('▶ ') : ' ';
const box = checked ? '[X]' : '[ ]'; const box = checked ? '[X]' : '[ ]';
const content = label.padEnd(16) + ': ' + (active ? chalk.bold.cyan(box) : chalk.white(box)); const plainContent = label.padEnd(16) + ': ' + box;
const coloredContent = active ? chalk.bold.white(content) : chalk.gray(content); const coloredContent = active
return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border; ? 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(renderField('Gitea URL', form.url, form.activeField === 'url'));
@ -1231,8 +1337,12 @@ export class TuiEngine {
' ' + timeStr + borderCh; ' ' + timeStr + borderCh;
if (isSelected) { if (isSelected) {
if (this.state.focusedPane === 'settings') {
rowText = chalk.bgHex('#1C2F4D').white(rowText);
} else {
rowText = chalk.bgHex('#2E4E7E').white.bold(rowText); rowText = chalk.bgHex('#2E4E7E').white.bold(rowText);
} }
}
console.log(borderCh + rowText); console.log(borderCh + rowText);
} else { } else {
@ -1245,22 +1355,49 @@ export class TuiEngine {
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
// Render Filters line // Render Filters line
let searchLabel = this.state.searchQuery ? chalk.yellow(`"${this.state.searchQuery}"`) : chalk.gray('none'); const isSettings = this.state.focusedPane === 'settings';
if (this.activeSearchInput) { const selIdx = this.state.selectedSettingIndex ?? 0;
searchLabel = chalk.inverse(this.searchInputBuffer + ' ');
} const buildSegment = (idx: number, label: string, val: string, formattedVal: string) => {
const stateFilterLabel = chalk.bold(this.state.stateFilter.toUpperCase()); const text = ` ${label}: ${val} `;
const typeFilterLabel = chalk.bold(this.state.typeFilter.toUpperCase()); if (isSettings && selIdx === idx) {
const sortLabel = chalk.bold(`${this.state.sortField} (${this.state.sortOrder.toUpperCase()})`); 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 filtersPlainLen = stripAnsi(filtersText).length;
const padding = Math.max(0, cols - 2 - filtersPlainLen); const padding = Math.max(0, cols - 2 - filtersPlainLen);
console.log(borderCh + filtersText + ' '.repeat(padding) + borderCh); console.log(borderCh + filtersText + ' '.repeat(padding) + borderCh);
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
// Keyboard controls help line // 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 repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `);
const repoLen = stripAnsi(repoStr).length; const repoLen = stripAnsi(repoStr).length;
@ -1400,7 +1537,7 @@ export class TuiEngine {
} }
const actionKey = issue.state === 'open' ? 'Close' : 'Reopen'; 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); process.stdout.write(helpLine);
} }
@ -2722,4 +2859,83 @@ export class TuiEngine {
console.log(chalk.gray('─'.repeat(cols))); console.log(chalk.gray('─'.repeat(cols)));
console.log(chalk.gray(' [↑/↓/Tab] Navigate [Enter] Save [Esc] Cancel')); 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('');
}
}
} }

View File

@ -80,7 +80,7 @@ export interface LabelForm {
activeField: 'name' | 'color' | 'description' | 'exclusive'; 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 { export interface RepoItem {
@ -93,8 +93,12 @@ export interface RepoItem {
export interface AppState { export interface AppState {
screen: ScreenType; screen: ScreenType;
previousScreen?: ScreenType;
config: Config; config: Config;
focusedPane: 'list' | 'settings';
selectedSettingIndex: number;
// List Screen State // List Screen State
issues: Issue[]; issues: Issue[];
currentPage: number; currentPage: number;