diff --git a/src/github-throttler.ts b/src/github-throttler.ts index f6d542f..4f60176 100644 --- a/src/github-throttler.ts +++ b/src/github-throttler.ts @@ -45,9 +45,10 @@ export class TokenBucketThrottler { // start full this.tokens = this.burst; this.lastRefill = this.clock.now(); - // Enable debug when explicit env var set or when the process is started - // with a `--verbose` flag (useful when running test runner with `--verbose`). - this.debug = Boolean(process.env.WL_GITHUB_THROTTLER_DEBUG) || (Array.isArray(process.argv) && process.argv.includes('--verbose')); + // Enable throttler debug logging only when explicitly requested. + // Tying this to global `--verbose` causes console.debug output to interfere + // with full-screen TUI rendering during GitHub push operations. + this.debug = Boolean(process.env.WL_GITHUB_THROTTLER_DEBUG); } schedule(fn: () => Promise | T): Promise { diff --git a/src/tui/components/metadata-pane.ts b/src/tui/components/metadata-pane.ts index a019468..d87d2f1 100644 --- a/src/tui/components/metadata-pane.ts +++ b/src/tui/components/metadata-pane.ts @@ -88,8 +88,12 @@ export class MetadataPaneComponent { if (!item.githubRepo) { lines.push('GitHub: (set githubRepo in config to enable)'); } else if (item.githubIssueNumber) { - lines.push(`GitHub: ${item.githubRepo}#${item.githubIssueNumber} (G to open)`); + // Only show the issue number in the metadata pane; repo is implied by config + // Make the text explicit about interaction so controller can wire key/click handlers + lines.push(`GitHub: #${item.githubIssueNumber} (G to open)`); } else { + // Show a visual affordance that pushing is available; controller will + // handle the actual push logic and keyboard/mouse interactions. lines.push('GitHub: (G to push to GitHub)'); } diff --git a/src/tui/constants.ts b/src/tui/constants.ts index ea30728..7e1acee 100644 --- a/src/tui/constants.ts +++ b/src/tui/constants.ts @@ -156,7 +156,9 @@ export const KEY_TOGGLE_DO_NOT_DELEGATE = ['d', 'D']; export const KEY_TOGGLE_NEEDS_REVIEW = ['r', 'R']; export const KEY_MOVE = ['m', 'M']; export const KEY_DELEGATE = ['g']; -export const KEY_GITHUB_PUSH = ['G']; +// Include both cases because blessed may normalize key.name to lowercase while +// still exposing uppercase intent via the raw `ch` argument. +export const KEY_GITHUB_PUSH = ['g', 'G']; // Composite keys often used in help menu / close handlers export const KEY_MENU_CLOSE = ['escape', 'q']; diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 336ce27..660542b 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -2773,6 +2773,14 @@ export class TuiController { } catch (err) { debugLog(`ChordHandler.feed threw: ${(err as any)?.message ?? String(err)}`); } + + // Some terminals/blessed combinations report Shift+g as raw ch='G' + // without setting key.shift in downstream `screen.key` handlers. + // Handle it directly here so the GitHub shortcut is reliable. + if (_ch === 'G') { + void handleGithubPushShortcut(_ch, key); + return false; + } // No legacy pending-state fallback: chordHandler.feed handles all // Ctrl-W prefixes and their follow-ups. If chordHandler didn't @@ -2996,6 +3004,13 @@ export class TuiController { // Delegate to GitHub Copilot (shortcut g) screen.key(KEY_DELEGATE, async (_ch: any, key: any) => { + // If the raw character is uppercase 'G', treat it as the GitHub push + // shortcut and do not handle it here. Blessed may report shift via + // the raw char (`ch`) rather than `key.shift`/`key.name`. + if (_ch === 'G') return; + // Only handle plain 'g' key events. If key.name is present and not 'g' + // then ignore (this avoids other key ambiguities). + if (key && key.name && key.name !== 'g') return; // Ignore when shift is held — that is handled by KEY_GITHUB_PUSH ('G') if (key?.shift) return; // Guard: suppress when overlays are visible or in move mode @@ -3110,9 +3125,9 @@ export class TuiController { }); // Open GitHub issue or push item to GitHub (shortcut G) - screen.key(KEY_GITHUB_PUSH, async (_ch: any, key: any) => { - // Only fire for shift+G (not plain g which is handled by KEY_DELEGATE) - if (!key?.shift) return; + async function handleGithubPushShortcut(_ch: any, key: any): Promise { + const isUppercaseG = _ch === 'G' || key?.shift || key?.full === 'G'; + if (!isUppercaseG) return; if (!detailModal.hidden || helpMenu.isVisible() || !closeDialog.hidden || !updateDialog.hidden || !nextDialog.hidden) return; if (state.moveMode) return; @@ -3122,77 +3137,74 @@ export class TuiController { return; } - // Resolve github config (null means not configured) - let githubConfig: { repo: string; labelPrefix: string } | null = null; try { - githubConfig = resolveGithubConfig({}); - } catch (_) { - showToast('Set githubRepo in config or run: wl github --repo push'); - return; - } - - if (item.githubIssueNumber) { - // Item already has a GitHub mapping — open the issue URL in the browser - const url = `https://github.com/${githubConfig.repo}/issues/${item.githubIssueNumber}`; + const helperModule = await import('./github-action-helper'); + await (helperModule as any).default({ + item, + screen, + db, + showToast, + fsImpl, + spawnImpl, + copyToClipboard, + resolveGithubConfig, + upsertIssuesFromWorkItems, + list, + refreshFromDatabase, + }); + } catch (_e) { + // Resolve github config (null means not configured) + let githubConfig: { repo: string; labelPrefix: string } | null = null; try { - const openUrl = (await import('../utils/open-url.js')).default; - const ok = await openUrl(url, fsImpl as any); - if (!ok) { - // Fall back to clipboard - const clipResult = await copyToClipboard(url, { spawn: spawnImpl, writeOsc52: (s: string) => { try { (screen as any).program?.write?.(s); } catch (_) {} } }); - showToast(clipResult.success ? `URL copied: ${url}` : `Open failed: ${url}`); - } else { - showToast('Opening GitHub issue…'); - } + githubConfig = resolveGithubConfig({}); } catch (_) { - showToast(`GitHub: ${url}`); - } - return; - } - - // No mapping yet — push this item to GitHub - showToast(`Pushing to GitHub…`); - screen.render(); - - try { - const comments = db ? db.getCommentsForWorkItem(item.id) : []; - const { updatedItems, result } = await upsertIssuesFromWorkItems( - [item], - comments as any, - githubConfig, - ); - - // Persist the updated GitHub mapping fields back to the database. - // upsertItems is available on WorklogDatabase but may not be present in - // all test doubles, so use optional chaining to guard gracefully. - if (updatedItems.length > 0) { - (db as any).upsertItems?.(updatedItems); + showToast('Set githubRepo in config or run: wl github --repo push'); + return; } - refreshFromDatabase(list.selected as number); - - const synced = result.syncedItems.find(s => s.id === item.id); - if (synced?.issueNumber) { - const url = `https://github.com/${githubConfig.repo}/issues/${synced.issueNumber}`; - showToast(`Pushed: ${githubConfig.repo}#${synced.issueNumber}`); + if (item.githubIssueNumber) { + const url = `https://github.com/${githubConfig.repo}/issues/${item.githubIssueNumber}`; try { const openUrl = (await import('../utils/open-url.js')).default; const ok = await openUrl(url, fsImpl as any); if (!ok) { const clipResult = await copyToClipboard(url, { spawn: spawnImpl, writeOsc52: (s: string) => { try { (screen as any).program?.write?.(s); } catch (_) {} } }); - if (clipResult.success) showToast('URL copied to clipboard'); + showToast(clipResult.success ? `URL copied: ${url}` : `Open failed: ${url}`); + } else { + showToast('Opening GitHub issue…'); } } catch (_) { - // ignore browser open errors + showToast(`GitHub: ${url}`); } - } else if (result.errors.length > 0) { - showToast(`Push failed: ${result.errors[0]}`); - } else { - showToast('Push complete (no changes)'); + return; + } + + showToast('Pushing to GitHub…'); + screen.render(); + try { + const comments = db ? db.getCommentsForWorkItem(item.id) : []; + const { updatedItems, result } = await upsertIssuesFromWorkItems([item], comments as any, githubConfig); + if (updatedItems.length > 0) { + (db as any).upsertItems?.(updatedItems); + } + refreshFromDatabase(list.selected as number); + const synced = result.syncedItems.find((s: any) => s.id === item.id); + if (synced?.issueNumber) { + const url = `https://github.com/${githubConfig.repo}/issues/${synced.issueNumber}`; + showToast(`Pushed: ${githubConfig.repo}#${synced.issueNumber}`); + } else if (result.errors.length > 0) { + showToast(`Push failed: ${result.errors[0]}`); + } else { + showToast('Push complete (no changes)'); + } + } catch (err: any) { + showToast(`Push failed: ${err?.message || 'Unknown error'}`); } - } catch (err: any) { - showToast(`Push failed: ${err?.message || 'Unknown error'}`); } + } + + screen.key(KEY_GITHUB_PUSH, async (_ch: any, key: any) => { + await handleGithubPushShortcut(_ch, key); }); // Toggle needs producer review flag (shortcut r) diff --git a/src/tui/github-action-helper.ts b/src/tui/github-action-helper.ts new file mode 100644 index 0000000..e017ebb --- /dev/null +++ b/src/tui/github-action-helper.ts @@ -0,0 +1,107 @@ +import { copyToClipboard } from '../clipboard.js'; + +export default async function githubActionHelper(opts: { + item: any; + screen?: any; + db?: any; + showToast: (s: string) => void; + fsImpl?: any; + spawnImpl?: any; + copyToClipboard?: typeof copyToClipboard; + resolveGithubConfig: (o: any) => { repo: string; labelPrefix?: string } | null; + upsertIssuesFromWorkItems: (items: any[], comments: any[], cfg: any) => Promise; + list?: any; + refreshFromDatabase?: (idx?: number) => void; +}): Promise { + const { + item, + screen, + db, + showToast, + fsImpl, + spawnImpl, + copyToClipboard: copyFn = copyToClipboard, + resolveGithubConfig, + upsertIssuesFromWorkItems, + list, + refreshFromDatabase, + } = opts; + + let githubConfig: { repo: string; labelPrefix?: string } | null = null; + try { + githubConfig = resolveGithubConfig({}); + } catch (e) { + showToast('Set githubRepo in config or run: wl github --repo push'); + return; + } + + if (item.githubIssueNumber) { + const url = `https://github.com/${githubConfig!.repo}/issues/${item.githubIssueNumber}`; + try { + const openUrlMod = await import('../utils/open-url.js'); + const openUrl = (openUrlMod as any).default; + const ok = await openUrl(url, fsImpl); + if (!ok) { + const clipResult = await copyFn(url, { spawn: spawnImpl, writeOsc52: (s: string) => { try { (screen as any).program?.write?.(s); } catch (_) {} } }); + showToast(clipResult.success ? `URL copied: ${url}` : `Open failed: ${url}`); + } else { + showToast('Opening GitHub issue…'); + } + } catch (e) { + showToast(`GitHub: ${url}`); + } + return; + } + + showToast('Pushing to GitHub…'); + try { screen?.render?.(); } catch (_) {} + + try { + const comments = db ? db.getCommentsForWorkItem(item.id) : []; + const { updatedItems, result } = await upsertIssuesFromWorkItems([item], comments, githubConfig); + + if (updatedItems && updatedItems.length > 0) { + if (db && typeof db.upsertItems === 'function') db.upsertItems(updatedItems); + } + + try { refreshFromDatabase && refreshFromDatabase(list?.selected ?? 0); } catch (_) {} + + const synced = result && result.syncedItems ? result.syncedItems.find((s: any) => s.id === item.id) : null; + if (synced && synced.issueNumber) { + const url = `https://github.com/${githubConfig!.repo}/issues/${synced.issueNumber}`; + showToast(`Pushed: ${githubConfig!.repo}#${synced.issueNumber}`); + try { + const openUrlMod = await import('../utils/open-url.js'); + const openUrl = (openUrlMod as any).default; + const ok = await openUrl(url, fsImpl); + if (!ok) { + const clipResult = await copyFn(url, { spawn: spawnImpl, writeOsc52: (s: string) => { try { if (screen && screen.program && typeof screen.program.write === 'function') screen.program.write(s); } catch (_) {} } }); + if (clipResult.success) showToast('URL copied to clipboard'); + } + } catch (_) {} + } else if (item.githubIssueNumber) { + // Mapping might have existed already or sync returned no explicit item; + // still try to open the known mapped URL. + const url = `https://github.com/${githubConfig!.repo}/issues/${item.githubIssueNumber}`; + try { + const openUrlMod = await import('../utils/open-url.js'); + const openUrl = (openUrlMod as any).default; + const ok = await openUrl(url, fsImpl); + if (!ok) { + const clipResult = await copyFn(url, { spawn: spawnImpl, writeOsc52: (s: string) => { try { if (screen && screen.program && typeof screen.program.write === 'function') screen.program.write(s); } catch (_) {} } }); + if (clipResult.success) showToast('URL copied to clipboard'); + } else { + showToast('Opening GitHub issue…'); + } + } catch (_) { + showToast(`GitHub: ${url}`); + } + } else if (result && result.errors && result.errors.length > 0) { + showToast(`Push failed: ${result.errors[0]}`); + } else { + showToast('Push complete (no changes)'); + } + } catch (err: any) { + showToast(`Push failed: ${err?.message || 'Unknown error'}`); + } +} diff --git a/src/utils/open-url.ts b/src/utils/open-url.ts index 0c1e896..fbd38a1 100644 --- a/src/utils/open-url.ts +++ b/src/utils/open-url.ts @@ -1,4 +1,5 @@ import * as fs from 'fs'; +import { spawn } from 'child_process'; export async function openUrlInBrowser(url: string, fsImpl: typeof fs = fs): Promise { // Prefer candidates based on environment; try each until one succeeds. @@ -14,41 +15,52 @@ export async function openUrlInBrowser(url: string, fsImpl: typeof fs = fs): Pro } })(); - const candidates: string[] = []; + const candidates: Array<{ cmd: string; args: string[] }> = []; if (platform === 'darwin') { - candidates.push(`open "${url}"`); + candidates.push({ cmd: 'open', args: [url] }); } else if (platform === 'win32') { - candidates.push(`powershell.exe Start "${url}"`); + candidates.push({ cmd: 'powershell.exe', args: ['Start', url] }); } else { // linux-like if (isWsl) { - // prefer wslview if installed, then explorer.exe, then xdg-open - candidates.push(`wslview "${url}"`); - candidates.push(`explorer.exe "${url}"`); - candidates.push(`xdg-open "${url}"`); + // In WSL prefer explorer.exe first for faster launch to host browser. + candidates.push({ cmd: 'explorer.exe', args: [url] }); + candidates.push({ cmd: 'wslview', args: [url] }); + candidates.push({ cmd: 'xdg-open', args: [url] }); } else { - candidates.push(`xdg-open "${url}"`); + candidates.push({ cmd: 'xdg-open', args: [url] }); } } - try { - const { exec } = await import('child_process'); - for (const cmd of candidates) { - // eslint-disable-next-line no-await-in-loop - const ok = await new Promise((resolve) => { - try { - exec(cmd, (err) => { - resolve(!err); - }); - } catch (_) { - resolve(false); - } - }); - if (ok) return true; - } - } catch (_) { - // ignore + for (const candidate of candidates) { + // eslint-disable-next-line no-await-in-loop + const ok = await new Promise((resolve) => { + try { + const cp = spawn(candidate.cmd, candidate.args, { + detached: true, + stdio: 'ignore', + }); + let settled = false; + cp.once('error', () => { + if (!settled) { + settled = true; + resolve(false); + } + }); + cp.once('spawn', () => { + if (!settled) { + settled = true; + try { cp.unref(); } catch (_) {} + resolve(true); + } + }); + } catch (_) { + resolve(false); + } + }); + if (ok) return true; } + return false; } diff --git a/tests/tui/tui-github-metadata.test.ts b/tests/tui/tui-github-metadata.test.ts index db8b493..baa8a07 100644 --- a/tests/tui/tui-github-metadata.test.ts +++ b/tests/tui/tui-github-metadata.test.ts @@ -74,7 +74,7 @@ describe('MetadataPaneComponent GitHub row', () => { expect(getContent()).toContain('githubRepo'); }); - it('shows link with repo and issue number when item has a GitHub mapping', () => { + it('shows issue number when item has a GitHub mapping', () => { const { comp, getContent } = createMockMetadataPane(); comp.updateFromItem({ status: 'open', @@ -85,7 +85,8 @@ describe('MetadataPaneComponent GitHub row', () => { const content = getContent(); expect(content).toContain('GitHub:'); - expect(content).toContain('owner/repo#42'); + // Metadata pane intentionally shows issue number only; repo is implied by config. + expect(content).toContain('#42'); expect(content).toContain('G to open'); }); @@ -232,4 +233,3 @@ describe('TUI G key (shift+G) GitHub action', () => { expect(msg).not.toMatch(/Set githubRepo in config/); }); }); -