From f458997fc1a5988c25115c0c24f8dff48c66c673 Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 12:54:41 -0700 Subject: [PATCH 01/10] WL-0MMLXZ9Z90O3N49Q: Extract GitHub open/push helper and wire metadata pane for reuse --- src/tui/components/metadata-pane.ts | 6 +- src/tui/controller.ts | 111 ++++++++-------------------- src/tui/github-action-helper.js | 80 ++++++++++++++++++++ 3 files changed, 116 insertions(+), 81 deletions(-) create mode 100644 src/tui/github-action-helper.js 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/controller.ts b/src/tui/controller.ts index 336ce27..2484854 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -3110,90 +3110,41 @@ 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; - if (!detailModal.hidden || helpMenu.isVisible() || !closeDialog.hidden || !updateDialog.hidden || !nextDialog.hidden) return; - if (state.moveMode) return; - - const item = getSelectedItem(); - if (!item) { - showToast('No item selected'); - 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}`; - 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…'); - } - } 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, - ); + try { + const helperModule = await import('./github-action-helper.js'); + 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; + if (!detailModal.hidden || helpMenu.isVisible() || !closeDialog.hidden || !updateDialog.hidden || !nextDialog.hidden) return; + if (state.moveMode) return; - // 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); + const item = getSelectedItem(); + if (!item) { + showToast('No item selected'); + 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}`); - 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'); - } - } catch (_) { - // ignore browser open errors - } - } else if (result.errors.length > 0) { - showToast(`Push failed: ${result.errors[0]}`); - } else { - showToast('Push complete (no changes)'); + try { + await helperModule.default({ + item, + screen, + db, + showToast, + fsImpl, + spawnImpl, + copyToClipboard, + resolveGithubConfig, + upsertIssuesFromWorkItems, + list, + refreshFromDatabase, + }); + } catch (err: any) { + showToast(err?.message || 'GitHub action failed'); } - } catch (err: any) { - showToast(`Push failed: ${err?.message || 'Unknown error'}`); - } - }); + }); + } catch (e) { + // If helper import fails, fall back to the inline handler above (left unchanged) + } // Toggle needs producer review flag (shortcut r) screen.key(KEY_TOGGLE_NEEDS_REVIEW, () => { diff --git a/src/tui/github-action-helper.js b/src/tui/github-action-helper.js new file mode 100644 index 0000000..50a54d7 --- /dev/null +++ b/src/tui/github-action-helper.js @@ -0,0 +1,80 @@ +import { copyToClipboard } from '../clipboard.js'; + +export default async function githubActionHelper(opts) { + const { + item, + screen, + db, + showToast, + fsImpl, + spawnImpl, + copyToClipboard: copyFn = copyToClipboard, + resolveGithubConfig, + upsertIssuesFromWorkItems, + list, + refreshFromDatabase, + } = opts; + + // Resolve github config (throws if not configured) + let githubConfig = 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 openUrl = (await import('../utils/open-url.js')).default; + const ok = await openUrl(url, fsImpl); + if (!ok) { + const clipResult = await copyFn(url, { spawn: spawnImpl, writeOsc52: (s) => { 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; + } + + // No mapping yet — push this item to GitHub + 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 && list.selected) || 0); } catch (_) {} + + const synced = result && result.syncedItems ? result.syncedItems.find(s => s.id === item.id) : null; + if (synced?.issueNumber) { + const url = `https://github.com/${githubConfig.repo}/issues/${synced.issueNumber}`; + showToast(`Pushed: ${githubConfig.repo}#${synced.issueNumber}`); + try { + const openUrl = (await import('../utils/open-url.js')).default; + const ok = await openUrl(url, fsImpl); + if (!ok) { + const clipResult = await copyFn(url, { spawn: spawnImpl, writeOsc52: (s) => { 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 (result.errors && result.errors.length > 0) { + showToast(`Push failed: ${result.errors[0]}`); + } else { + showToast('Push complete (no changes)'); + } + } catch (err) { + showToast(`Push failed: ${err?.message || 'Unknown error'}`); + } +} From 67e2080a2b51c09b819ca4e55afc0ce8c78f18b6 Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 12:57:19 -0700 Subject: [PATCH 02/10] WL-0MMLXZ9Z90O3N49Q: Add TypeScript helper for GitHub open/push and wire dynamic import --- src/tui/controller.ts | 4 +- ...tion-helper.js => github-action-helper.ts} | 48 +++++++++++-------- 2 files changed, 31 insertions(+), 21 deletions(-) rename src/tui/{github-action-helper.js => github-action-helper.ts} (51%) diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 2484854..da46d08 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -3111,7 +3111,7 @@ export class TuiController { // Open GitHub issue or push item to GitHub (shortcut G) try { - const helperModule = await import('./github-action-helper.js'); + const helperModule = await import('./github-action-helper'); 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; @@ -3125,7 +3125,7 @@ export class TuiController { } try { - await helperModule.default({ + await (helperModule as any).default({ item, screen, db, diff --git a/src/tui/github-action-helper.js b/src/tui/github-action-helper.ts similarity index 51% rename from src/tui/github-action-helper.js rename to src/tui/github-action-helper.ts index 50a54d7..8ed8bcc 100644 --- a/src/tui/github-action-helper.js +++ b/src/tui/github-action-helper.ts @@ -1,6 +1,18 @@ import { copyToClipboard } from '../clipboard.js'; -export default async function githubActionHelper(opts) { +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, @@ -15,8 +27,7 @@ export default async function githubActionHelper(opts) { refreshFromDatabase, } = opts; - // Resolve github config (throws if not configured) - let githubConfig = null; + let githubConfig: { repo: string; labelPrefix?: string } | null = null; try { githubConfig = resolveGithubConfig({}); } catch (e) { @@ -25,12 +36,13 @@ export default async function githubActionHelper(opts) { } if (item.githubIssueNumber) { - const url = `https://github.com/${githubConfig.repo}/issues/${item.githubIssueNumber}`; + const url = `https://github.com/${githubConfig!.repo}/issues/${item.githubIssueNumber}`; try { - const openUrl = (await import('../utils/open-url.js')).default; + 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) => { try { (screen as any).program?.write?.(s); } catch (_) {} } }); + 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…'); @@ -41,11 +53,8 @@ export default async function githubActionHelper(opts) { return; } - // No mapping yet — push this item to GitHub showToast('Pushing to GitHub…'); - try { - screen.render?.(); - } catch (_) {} + try { screen?.render?.(); } catch (_) {} try { const comments = db ? db.getCommentsForWorkItem(item.id) : []; @@ -55,26 +64,27 @@ export default async function githubActionHelper(opts) { if (db && typeof db.upsertItems === 'function') db.upsertItems(updatedItems); } - try { refreshFromDatabase && refreshFromDatabase((list && list.selected) || 0); } catch (_) {} + try { refreshFromDatabase && refreshFromDatabase(list?.selected ?? 0); } catch (_) {} - const synced = result && result.syncedItems ? result.syncedItems.find(s => s.id === item.id) : null; - if (synced?.issueNumber) { - const url = `https://github.com/${githubConfig.repo}/issues/${synced.issueNumber}`; - showToast(`Pushed: ${githubConfig.repo}#${synced.issueNumber}`); + 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 openUrl = (await import('../utils/open-url.js')).default; + 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) => { try { if (screen && screen.program && typeof screen.program.write === 'function') screen.program.write(s); } catch (_) {} } }); + 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 (result.errors && result.errors.length > 0) { + } else if (result && result.errors && result.errors.length > 0) { showToast(`Push failed: ${result.errors[0]}`); } else { showToast('Push complete (no changes)'); } - } catch (err) { + } catch (err: any) { showToast(`Push failed: ${err?.message || 'Unknown error'}`); } } From d65b0def0e0dafe60bf6e16fa04a62cb9840efa6 Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 13:01:41 -0700 Subject: [PATCH 03/10] WL-0MMLXZ9Z90O3N49Q: Ensure fallback inline G handler is registered when helper import fails --- src/tui/controller.ts | 84 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/src/tui/controller.ts b/src/tui/controller.ts index da46d08..e831e14 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -3143,7 +3143,89 @@ export class TuiController { } }); } catch (e) { - // If helper import fails, fall back to the inline handler above (left unchanged) + // If helper import fails, register the inline handler so G still works. + 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; + if (!detailModal.hidden || helpMenu.isVisible() || !closeDialog.hidden || !updateDialog.hidden || !nextDialog.hidden) return; + if (state.moveMode) return; + + const item = getSelectedItem(); + if (!item) { + showToast('No item selected'); + 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}`; + 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…'); + } + } 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. + if (updatedItems.length > 0) { + (db as any).upsertItems?.(updatedItems); + } + + 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}`); + 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'); + } + } catch (_) { + // ignore browser open errors + } + } 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'}`); + } + }); } // Toggle needs producer review flag (shortcut r) From 3a396b8b5efcbed8c58e952aaa7a54564074458f Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 13:06:02 -0700 Subject: [PATCH 04/10] WL-0MMLXZ9Z90O3N49Q: Make delegate handler ignore non-plain 'g' key names to avoid clash with shift+G --- src/tui/controller.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tui/controller.ts b/src/tui/controller.ts index e831e14..c9f12c2 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -2996,6 +2996,9 @@ export class TuiController { // Delegate to GitHub Copilot (shortcut g) screen.key(KEY_DELEGATE, async (_ch: any, key: any) => { + // Only handle plain 'g' key events. If key.name is present and not 'g' + // then ignore (this avoids uppercase/shift ambiguity). + 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 From c68378b4478c666f15db3090d74296b2c7f787b8 Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 13:13:22 -0700 Subject: [PATCH 05/10] WL-0MMLXZ9Z90O3N49Q: Ensure delegate handler ignores raw uppercase 'G' (blessed reports shift via ch) --- src/tui/controller.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tui/controller.ts b/src/tui/controller.ts index c9f12c2..f3dd354 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -2996,8 +2996,12 @@ 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 uppercase/shift ambiguity). + // 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; From 01011b5177ce30aac0c77a4d6a0aee24366afe07 Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 13:21:49 -0700 Subject: [PATCH 06/10] WL-0MMLXZ9Z90O3N49Q: Normalize G binding across blessed key representations --- src/tui/constants.ts | 4 +++- src/tui/controller.ts | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) 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 f3dd354..2bf4fff 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -3120,8 +3120,10 @@ export class TuiController { try { const helperModule = await import('./github-action-helper'); 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; + // Only fire for uppercase G intent. Blessed can represent this as + // raw ch='G', key.shift=true, or key.full='G'. + 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; @@ -3152,8 +3154,10 @@ export class TuiController { } catch (e) { // If helper import fails, register the inline handler so G still works. 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; + // Only fire for uppercase G intent. Blessed can represent this as + // raw ch='G', key.shift=true, or key.full='G'. + 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; From c7ac9e0825d52c03d574f38f271c2a6947879c2e Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 13:35:12 -0700 Subject: [PATCH 07/10] WL-0MMLXZ9Z90O3N49Q: Route raw uppercase G keypress directly to GitHub shortcut handler --- src/tui/controller.ts | 116 +++++++++++++++--------------------------- 1 file changed, 42 insertions(+), 74 deletions(-) diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 2bf4fff..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 @@ -3117,56 +3125,34 @@ export class TuiController { }); // Open GitHub issue or push item to GitHub (shortcut G) - try { - const helperModule = await import('./github-action-helper'); - screen.key(KEY_GITHUB_PUSH, async (_ch: any, key: any) => { - // Only fire for uppercase G intent. Blessed can represent this as - // raw ch='G', key.shift=true, or key.full='G'. - 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; - - const item = getSelectedItem(); - if (!item) { - showToast('No item selected'); - return; - } - - try { - await (helperModule as any).default({ - item, - screen, - db, - showToast, - fsImpl, - spawnImpl, - copyToClipboard, - resolveGithubConfig, - upsertIssuesFromWorkItems, - list, - refreshFromDatabase, - }); - } catch (err: any) { - showToast(err?.message || 'GitHub action failed'); - } - }); - } catch (e) { - // If helper import fails, register the inline handler so G still works. - screen.key(KEY_GITHUB_PUSH, async (_ch: any, key: any) => { - // Only fire for uppercase G intent. Blessed can represent this as - // raw ch='G', key.shift=true, or key.full='G'. - 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; + 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; - const item = getSelectedItem(); - if (!item) { - showToast('No item selected'); - return; - } + const item = getSelectedItem(); + if (!item) { + showToast('No item selected'); + return; + } + try { + 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 { @@ -3177,13 +3163,11 @@ export class TuiController { } 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}`; 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 { @@ -3195,39 +3179,19 @@ export class TuiController { return; } - // No mapping yet — push this item to GitHub - showToast(`Pushing 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. + 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 => s.id === item.id); + 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}`); - 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'); - } - } catch (_) { - // ignore browser open errors - } } else if (result.errors.length > 0) { showToast(`Push failed: ${result.errors[0]}`); } else { @@ -3236,9 +3200,13 @@ export class TuiController { } 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) screen.key(KEY_TOGGLE_NEEDS_REVIEW, () => { if (!detailModal.hidden || helpMenu.isVisible() || !closeDialog.hidden || !updateDialog.hidden || !nextDialog.hidden) return; From a91794166e9423ce22a1cf2c873d218f31b2b240 Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 14:44:08 -0700 Subject: [PATCH 08/10] WL-0MMLXZ9Z90O3N49Q: Silence throttler debug in TUI and ensure GitHub URL open fallback --- src/github-throttler.ts | 7 ++++--- src/tui/github-action-helper.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) 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/github-action-helper.ts b/src/tui/github-action-helper.ts index 8ed8bcc..e017ebb 100644 --- a/src/tui/github-action-helper.ts +++ b/src/tui/github-action-helper.ts @@ -79,6 +79,23 @@ export default async function githubActionHelper(opts: { 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 { From adfc36d70f1c4d614988fda6033140b3d481ce30 Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 14:48:38 -0700 Subject: [PATCH 09/10] WL-0MMLXZ9Z90O3N49Q: Speed up browser launch by using detached spawn and WSL-first explorer order --- src/utils/open-url.ts | 62 ++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 25 deletions(-) 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; } From 62c505caeaced32126c4bc4398c1243ea0b1faef Mon Sep 17 00:00:00 2001 From: Sorra Date: Wed, 11 Mar 2026 15:02:58 -0700 Subject: [PATCH 10/10] WL-0MMLXZ9Z90O3N49Q: Update metadata GitHub mapping assertion to issue-number format --- tests/tui/tui-github-metadata.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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/); }); }); -