From 6c6badb42566bf3107ced002ee7762f03c80439b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:02:53 +0000 Subject: [PATCH 1/5] Initial plan From 321450f32b2fd65b82ab91e0fb7a8333d80c20bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:06:11 +0000 Subject: [PATCH 2/5] Initial plan Co-authored-by: rgardler-msft <108765066+rgardler-msft@users.noreply.github.com> --- package-lock.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36e287e8..75430592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1138,7 +1138,6 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -2382,7 +2381,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3010,7 +3008,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3110,7 +3107,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3186,7 +3182,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", From 686a945cd10d0c59d0e11b7c242b9fbdede7a6be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:14:43 +0000 Subject: [PATCH 3/5] Implement 50/50 split layout with MetadataPane component and Tab/Shift-Tab focus cycling Co-authored-by: rgardler-msft <108765066+rgardler-msft@users.noreply.github.com> --- src/tui/components/detail.ts | 9 +- src/tui/components/index.ts | 1 + src/tui/components/list.ts | 4 +- src/tui/components/metadata-pane.ts | 105 +++++ src/tui/controller.ts | 37 ++ src/tui/layout.ts | 11 +- test/tui-integration.test.ts | 19 +- tests/tui/focus-cycling-integration.test.ts | 43 +- tests/tui/layout.test.ts | 4 + tests/tui/tui-50-50-layout.test.ts | 483 ++++++++++++++++++++ 10 files changed, 686 insertions(+), 30 deletions(-) create mode 100644 src/tui/components/metadata-pane.ts create mode 100644 tests/tui/tui-50-50-layout.test.ts diff --git a/src/tui/components/detail.ts b/src/tui/components/detail.ts index a4f43ba2..b2c0cc1a 100644 --- a/src/tui/components/detail.ts +++ b/src/tui/components/detail.ts @@ -18,10 +18,11 @@ export class DetailComponent { this.detail = this.blessedImpl.box({ parent: this.screen, - label: ' Details ', - left: '50%', - width: '50%', - height: '100%-1', + label: ' Description & Comments ', + left: 0, + top: '50%', + width: '100%', + height: '50%-1', tags: true, scrollable: true, alwaysScroll: true, diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index 408769bf..3571c61d 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -3,6 +3,7 @@ export { ToastComponent, type ToastOptions } from './toast.js'; export { HelpMenuComponent, type HelpMenuOptions } from './help-menu.js'; export { ListComponent, type ListComponentOptions } from './list.js'; export { DetailComponent, type DetailComponentOptions } from './detail.js'; +export { MetadataPaneComponent, type MetadataPaneOptions } from './metadata-pane.js'; export { OverlaysComponent, type OverlaysComponentOptions } from './overlays.js'; export { DialogsComponent, type DialogsComponentOptions } from './dialogs.js'; export { OpencodePaneComponent, type OpencodePaneComponentOptions } from './opencode-pane.js'; diff --git a/src/tui/components/list.ts b/src/tui/components/list.ts index 62a779c5..375d7276 100644 --- a/src/tui/components/list.ts +++ b/src/tui/components/list.ts @@ -20,8 +20,8 @@ export class ListComponent { this.list = this.blessedImpl.list({ parent: this.screen, label: ' Work Items ', - width: '50%', - height: '100%-1', + width: '65%', + height: '50%', tags: true, keys: true, vi: false, diff --git a/src/tui/components/metadata-pane.ts b/src/tui/components/metadata-pane.ts new file mode 100644 index 00000000..db834993 --- /dev/null +++ b/src/tui/components/metadata-pane.ts @@ -0,0 +1,105 @@ +import blessed from 'blessed'; +import type { BlessedBox, BlessedFactory, BlessedScreen } from '../types.js'; + +export interface MetadataPaneOptions { + parent: BlessedScreen; + blessed?: BlessedFactory; +} + +export class MetadataPaneComponent { + private blessedImpl: BlessedFactory; + private screen: BlessedScreen; + private box: BlessedBox; + + constructor(options: MetadataPaneOptions) { + this.screen = options.parent; + this.blessedImpl = options.blessed || blessed; + + this.box = this.blessedImpl.box({ + parent: this.screen, + label: ' Metadata ', + left: '65%', + top: 0, + width: '35%', + height: '50%', + tags: true, + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, + clickable: true, + border: { type: 'line' }, + style: { + focus: { border: { fg: 'green' } }, + border: { fg: 'white' }, + label: { fg: 'white' }, + }, + content: '', + }); + } + + create(): this { + return this; + } + + getBox(): BlessedBox { + return this.box; + } + + updateFromItem(item: { + status?: string; + stage?: string; + priority?: string; + tags?: string[]; + assignee?: string; + createdAt?: Date | string; + updatedAt?: Date | string; + } | null, commentCount: number): void { + if (!item) { + this.box.setContent(''); + return; + } + const lines: string[] = []; + lines.push(`Status: ${item.status ?? ''}`); + lines.push(`Stage: ${item.stage ?? ''}`); + lines.push(`Priority: ${item.priority ?? ''}`); + lines.push(`Comments: ${commentCount}`); + if (item.tags && item.tags.length > 0) { + lines.push(`Tags: ${item.tags.join(', ')}`); + } + if (item.assignee) { + lines.push(`Assignee: ${item.assignee}`); + } + if (item.createdAt) { + lines.push(`Created: ${String(item.createdAt)}`); + } + if (item.updatedAt) { + lines.push(`Updated: ${String(item.updatedAt)}`); + } + this.box.setContent(lines.join('\n')); + } + + setContent(content: string): void { + this.box.setContent(content); + } + + focus(): void { + this.box.focus(); + } + + show(): void { + this.box.show(); + } + + hide(): void { + this.box.hide(); + } + + destroy(): void { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (typeof this.box.removeAllListeners === 'function') this.box.removeAllListeners(); + this.box.destroy(); + } +} diff --git a/src/tui/controller.ts b/src/tui/controller.ts index c859511e..3423d8cb 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -179,6 +179,7 @@ export class TuiController { screen, listComponent, detailComponent, + metadataPaneComponent, toastComponent, overlaysComponent, dialogsComponent, @@ -190,6 +191,7 @@ export class TuiController { const help = listComponent.getFooter(); const detail = detailComponent.getDetail(); const copyIdButton = detailComponent.getCopyIdButton(); + const metadataPane = (metadataPaneComponent as any)?.getBox?.() ?? (metadataPaneComponent as any)?.box ?? null; const detailOverlay = overlaysComponent.detailOverlay; const detailModal = dialogsComponent.detailModal; @@ -629,6 +631,10 @@ export class TuiController { setBorderFocusStyle(list, focused); }; + const setMetadataBorderFocusStyle = (focused: boolean) => { + setBorderFocusStyle(metadataPane as unknown as Pane, focused); + }; + const setOpencodeBorderFocusStyle = (focused: boolean) => { setBorderFocusStyle(opencodeDialog, focused); }; @@ -637,6 +643,7 @@ export class TuiController { if (!node) return null; if (node === list) return list as unknown as Pane; if (node === detail) return detail as unknown as Pane; + if (metadataPane && node === metadataPane) return metadataPane as unknown as Pane; if (node === opencodeDialog || node === opencodeText) return opencodeDialog as unknown as Pane; if (node === opencodePane) return opencodeDialog as unknown as Pane; return null; @@ -646,6 +653,7 @@ export class TuiController { const getFocusPanes = (): Pane[] => { const panes: Pane[] = [list as unknown as Pane, detail as unknown as Pane]; + if (metadataPane) panes.splice(1, 0, metadataPane as unknown as Pane); if (!opencodeDialog.hidden) panes.push(opencodeDialog as unknown as Pane); return panes; }; @@ -693,12 +701,14 @@ export class TuiController { const applyFocusStyles = () => { const active = getFocusPanes()[paneFocusIndex]; setListBorderFocusStyle(active === list); + setMetadataBorderFocusStyle(metadataPane ? active === metadataPane : false); setDetailBorderFocusStyle(active === detail); setOpencodeBorderFocusStyle(active === opencodeDialog); }; const applyFocusStylesForPane = (pane: any) => { setListBorderFocusStyle(pane === list); + setMetadataBorderFocusStyle(metadataPane ? pane === metadataPane : false); setDetailBorderFocusStyle(pane === detail); setOpencodeBorderFocusStyle(pane === opencodeDialog); }; @@ -1671,6 +1681,7 @@ export class TuiController { const v = visible || buildVisible(); if (v.length === 0) { detail.setContent(''); + if (metadataPaneComponent) metadataPaneComponent.updateFromItem(null, 0); return; } const node = v[idx] || v[0]; @@ -1679,6 +1690,11 @@ export class TuiController { const brightened = brightenDetailIdLine(escaped); detail.setContent(decorateIdsForClick(brightened)); detail.setScroll(0); + // Update metadata pane with current item's metadata + if (metadataPaneComponent) { + const commentCount = db ? db.getCommentsForWorkItem(node.item.id).length : 0; + metadataPaneComponent.updateFromItem(node.item, commentCount); + } } // ID parsing utilities moved to src/tui/id-utils.ts @@ -2778,6 +2794,27 @@ export class TuiController { }); } catch (_) {} + // Tab / Shift-Tab: cycle focus between tree, metadata, and details panes + // Only active when no dialog or overlay is open. + try { + screen.key(KEY_TAB, () => { + if (helpMenu.isVisible()) return; + if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return; + if (opencodeDialog && !opencodeDialog.hidden) return; + cycleFocus(1); + screen.render(); + }); + } catch (_) {} + try { + screen.key(KEY_SHIFT_TAB, () => { + if (helpMenu.isVisible()) return; + if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return; + if (opencodeDialog && !opencodeDialog.hidden) return; + cycleFocus(-1); + screen.render(); + }); + } catch (_) {} + // Open opencode prompt dialog (shortcut O) screen.key(KEY_OPEN_OPENCODE, async () => { diff --git a/src/tui/layout.ts b/src/tui/layout.ts index 0b47bc7a..525bba22 100644 --- a/src/tui/layout.ts +++ b/src/tui/layout.ts @@ -18,6 +18,7 @@ import { DialogsComponent, HelpMenuComponent, ListComponent, + MetadataPaneComponent, ModalDialogsComponent, OpencodePaneComponent, OverlaysComponent, @@ -42,6 +43,7 @@ export interface TuiLayout { // Component instances listComponent: ListComponent; detailComponent: DetailComponent; + metadataPaneComponent: MetadataPaneComponent; toastComponent: ToastComponent; overlaysComponent: OverlaysComponent; dialogsComponent: DialogsComponent; @@ -102,12 +104,18 @@ export function createLayout(options: CreateLayoutOptions = {}): TuiLayout { blessed: blessedImpl, }).create(); - // ── Detail (right pane) ───────────────────────────────────────────── + // ── Detail (bottom pane) ──────────────────────────────────────────── const detailComponent = new DetailComponent({ parent: screen, blessed: blessedImpl, }).create(); + // ── Metadata (top-right pane) ──────────────────────────────────────── + const metadataPaneComponent = new MetadataPaneComponent({ + parent: screen, + blessed: blessedImpl, + }).create(); + // ── Toast ─────────────────────────────────────────────────────────── const toastComponent = new ToastComponent({ parent: screen, @@ -220,6 +228,7 @@ export function createLayout(options: CreateLayoutOptions = {}): TuiLayout { screen, listComponent, detailComponent, + metadataPaneComponent, toastComponent, overlaysComponent, dialogsComponent, diff --git a/test/tui-integration.test.ts b/test/tui-integration.test.ts index 4827d9ac..8b54e1d8 100644 --- a/test/tui-integration.test.ts +++ b/test/tui-integration.test.ts @@ -248,7 +248,7 @@ describe('TUI integration: style preservation', () => { const boxMock = (blessedMock as any).box?.mock; const boxCalls = boxMock?.calls || []; - const detailIndex = boxCalls.findIndex((call: any[]) => call?.[0]?.label === ' Details '); + const detailIndex = boxCalls.findIndex((call: any[]) => call?.[0]?.label === ' Description & Comments '); const detail = detailIndex >= 0 ? boxMock.results[detailIndex]?.value : null; const selectHandler = handlers['select'] || handlers['select item']; @@ -295,7 +295,7 @@ describe('TUI integration: style preservation', () => { const boxMock = (blessedMock as any).box?.mock; const boxCalls = boxMock?.calls || []; - const detailIndex = boxCalls.findIndex((call: any[]) => call?.[0]?.label === ' Details '); + const detailIndex = boxCalls.findIndex((call: any[]) => call?.[0]?.label === ' Description & Comments '); const detail = detailIndex >= 0 ? boxMock.results[detailIndex]?.value : null; const selectHandler = handlers['select'] || handlers['select item']; @@ -349,7 +349,7 @@ describe('TUI integration: style preservation', () => { const boxMock = (blessedMock as any).box?.mock; const boxCalls = boxMock?.calls || []; - const detailIndex = boxCalls.findIndex((call: any[]) => call?.[0]?.label === ' Details '); + const detailIndex = boxCalls.findIndex((call: any[]) => call?.[0]?.label === ' Description & Comments '); const detail = detailIndex >= 0 ? boxMock.results[detailIndex]?.value : null; const screenKeyCtrlW = handlers['screen-key:C-w']; @@ -357,10 +357,16 @@ describe('TUI integration: style preservation', () => { expect(typeof screenKeyCtrlW).toBe('function'); expect(typeof screenKeyW).toBe('function'); + // Cycle 1: list → metadata + screenKeyCtrlW(null, { name: 'C-w' }); + screenKeyW(null, { name: 'w' }); + + // Cycle 2: metadata → detail screenKeyCtrlW(null, { name: 'C-w' }); screenKeyW(null, { name: 'w' }); expect(detail?.focus).toHaveBeenCalled(); + // Cycle 3: detail → list (wrap) screenKeyCtrlW(null, { name: 'C-w' }); screenKeyW(null, { name: 'w' }); const listMock = (blessedMock as any).list?.mock; @@ -487,7 +493,7 @@ describe('TUI integration: style preservation', () => { const listWidget = (blessedMock as any).list?.mock?.results?.[0]?.value; const boxMock = (blessedMock as any).box?.mock; const boxCalls = boxMock?.calls || []; - const detailIndex = boxCalls.findIndex((call: any[]) => call?.[0]?.label === ' Details '); + const detailIndex = boxCalls.findIndex((call: any[]) => call?.[0]?.label === ' Description & Comments '); const detail = detailIndex >= 0 ? boxMock.results[detailIndex]?.value : null; expect(listWidget?.style?.border?.fg).toBe('green'); @@ -495,6 +501,9 @@ describe('TUI integration: style preservation', () => { const screenKeyCtrlW = handlers['screen-key:C-w']; const screenKeyW = handlers['screen-key:w']; + // Cycle twice to reach detail (list → metadata → detail) + screenKeyCtrlW(null, { name: 'C-w' }); + screenKeyW(null, { name: 'w' }); screenKeyCtrlW(null, { name: 'C-w' }); screenKeyW(null, { name: 'w' }); const listMock = (blessedMock as any).list?.mock; @@ -639,7 +648,7 @@ describe('TUI integration: style preservation', () => { await (savedAction as any)({}); const boxes: any[] = (blessedMock as any)._boxes || []; - const detailBox = boxes.find((b) => b.label === ' Details '); + const detailBox = boxes.find((b) => b.label === ' Description & Comments '); const detailModal = boxes.find((b) => b.label === ' Item Details '); expect(detailBox).toBeTruthy(); expect(detailModal).toBeTruthy(); diff --git a/tests/tui/focus-cycling-integration.test.ts b/tests/tui/focus-cycling-integration.test.ts index 8ab1c26b..e2690062 100644 --- a/tests/tui/focus-cycling-integration.test.ts +++ b/tests/tui/focus-cycling-integration.test.ts @@ -102,6 +102,7 @@ function buildLayout(screen: any) { const footer = makeBox(); const detail = makeBox(); const copyIdButton = makeBox(); + const metadataBox = makeBox(); const overlays = { detailOverlay: makeBox(), closeOverlay: makeBox(), @@ -146,12 +147,14 @@ function buildLayout(screen: any) { screen, list, detail, + metadataBox, opencodeDialog: opencodeUi.dialog, opencodeText: opencodeUi.textarea, layout: { screen, listComponent: { getList: () => list, getFooter: () => footer }, detailComponent: { getDetail: () => detail, getCopyIdButton: () => copyIdButton }, + metadataPaneComponent: { getBox: () => metadataBox, updateFromItem: vi.fn() }, toastComponent: { show: vi.fn() } as any, overlaysComponent: overlays, dialogsComponent: dialogs, @@ -315,7 +318,7 @@ describe('TUI focus cycling integration', () => { it('Ctrl-W w cycles focus forward', async () => { const root = makeItem('WL-FOCUS-1'); const screen = makeScreen(); - const { layout, list, detail } = buildLayout(screen); + const { layout, list, detail, metadataBox } = buildLayout(screen); const ctx = buildCtx([root]); const controller = new TuiController(ctx, { @@ -334,11 +337,11 @@ describe('TUI focus cycling integration', () => { // Initial focus should be on list expect(list.style.border.fg).toBe('green'); - // Simulate Ctrl-W w to cycle focus + // Simulate Ctrl-W w to cycle focus: list → metadata simulateCtrlWChord(screen, 'w'); - // Detail should now be focused (green border) - expect(detail.style.border.fg).toBe('green'); + // Metadata should now be focused (green border) + expect(metadataBox.style.border.fg).toBe('green'); // List should be unfocused (white border) expect(list.style.border.fg).toBe('white'); }); @@ -346,7 +349,7 @@ describe('TUI focus cycling integration', () => { it('Ctrl-W h moves focus left', async () => { const root = makeItem('WL-FOCUS-1'); const screen = makeScreen(); - const { layout, list, detail } = buildLayout(screen); + const { layout, list, detail, metadataBox } = buildLayout(screen); const ctx = buildCtx([root]); const controller = new TuiController(ctx, { @@ -362,20 +365,21 @@ describe('TUI focus cycling integration', () => { await controller.start({}); - // First cycle to detail (Ctrl-W w) + // Cycle forward twice to reach detail (list → metadata → detail) + simulateCtrlWChord(screen, 'w'); simulateCtrlWChord(screen, 'w'); expect(detail.style.border.fg).toBe('green'); - // Now Ctrl-W h should move back to list + // Now Ctrl-W h should move back to metadata simulateCtrlWChord(screen, 'h'); - expect(list.style.border.fg).toBe('green'); + expect(metadataBox.style.border.fg).toBe('green'); expect(detail.style.border.fg).toBe('white'); }); it('Ctrl-W l moves focus right', async () => { const root = makeItem('WL-FOCUS-1'); const screen = makeScreen(); - const { layout, list, detail } = buildLayout(screen); + const { layout, list, detail, metadataBox } = buildLayout(screen); const ctx = buildCtx([root]); const controller = new TuiController(ctx, { @@ -391,16 +395,16 @@ describe('TUI focus cycling integration', () => { await controller.start({}); - // List is focused initially; Ctrl-W l should move to detail + // List is focused initially; Ctrl-W l should move to metadata simulateCtrlWChord(screen, 'l'); - expect(detail.style.border.fg).toBe('green'); + expect(metadataBox.style.border.fg).toBe('green'); expect(list.style.border.fg).toBe('white'); }); it('Ctrl-W p returns to previous pane', async () => { const root = makeItem('WL-FOCUS-1'); const screen = makeScreen(); - const { layout, list, detail } = buildLayout(screen); + const { layout, list, detail, metadataBox } = buildLayout(screen); const ctx = buildCtx([root]); const controller = new TuiController(ctx, { @@ -416,14 +420,14 @@ describe('TUI focus cycling integration', () => { await controller.start({}); - // Move to detail + // Move to metadata simulateCtrlWChord(screen, 'w'); - expect(detail.style.border.fg).toBe('green'); + expect(metadataBox.style.border.fg).toBe('green'); // Ctrl-W p should go back to list (previous pane) simulateCtrlWChord(screen, 'p'); expect(list.style.border.fg).toBe('green'); - expect(detail.style.border.fg).toBe('white'); + expect(metadataBox.style.border.fg).toBe('white'); }); it('chord events do not leak when help menu is visible', async () => { @@ -490,7 +494,7 @@ describe('TUI focus cycling integration', () => { it('focus wraps around when cycling past the last pane', async () => { const root = makeItem('WL-FOCUS-1'); const screen = makeScreen(); - const { layout, list, detail } = buildLayout(screen); + const { layout, list, detail, metadataBox } = buildLayout(screen); const ctx = buildCtx([root]); const controller = new TuiController(ctx, { @@ -506,8 +510,11 @@ describe('TUI focus cycling integration', () => { await controller.start({}); - // With opencode dialog hidden, there are 2 panes: list and detail - // Cycle forward twice to wrap back to list + // With opencode dialog hidden, there are 3 panes: list, metadata, detail + // Cycle forward three times to wrap back to list + simulateCtrlWChord(screen, 'w'); + expect(metadataBox.style.border.fg).toBe('green'); + simulateCtrlWChord(screen, 'w'); expect(detail.style.border.fg).toBe('green'); diff --git a/tests/tui/layout.test.ts b/tests/tui/layout.test.ts index b36a2684..80444503 100644 --- a/tests/tui/layout.test.ts +++ b/tests/tui/layout.test.ts @@ -3,6 +3,7 @@ import blessed from 'blessed'; import { createLayout, type TuiLayout, type NextDialogWidgets } from '../../src/tui/layout.js'; import { ListComponent } from '../../src/tui/components/list.js'; import { DetailComponent } from '../../src/tui/components/detail.js'; +import { MetadataPaneComponent } from '../../src/tui/components/metadata-pane.js'; import { ToastComponent } from '../../src/tui/components/toast.js'; import { OverlaysComponent } from '../../src/tui/components/overlays.js'; import { DialogsComponent } from '../../src/tui/components/dialogs.js'; @@ -57,6 +58,7 @@ describe('createLayout', () => { expect(layout.screen).toBeDefined(); expect(layout.listComponent).toBeInstanceOf(ListComponent); expect(layout.detailComponent).toBeInstanceOf(DetailComponent); + expect(layout.metadataPaneComponent).toBeInstanceOf(MetadataPaneComponent); expect(layout.toastComponent).toBeInstanceOf(ToastComponent); expect(layout.overlaysComponent).toBeInstanceOf(OverlaysComponent); expect(layout.dialogsComponent).toBeInstanceOf(DialogsComponent); @@ -113,6 +115,7 @@ describe('createLayout', () => { expect(layout.listComponent).toBeInstanceOf(ListComponent); expect(layout.detailComponent).toBeInstanceOf(DetailComponent); + expect(layout.metadataPaneComponent).toBeInstanceOf(MetadataPaneComponent); expect(layout.toastComponent).toBeInstanceOf(ToastComponent); expect(layout.overlaysComponent).toBeInstanceOf(OverlaysComponent); expect(layout.dialogsComponent).toBeInstanceOf(DialogsComponent); @@ -140,6 +143,7 @@ describe('createLayout', () => { 'screen', 'listComponent', 'detailComponent', + 'metadataPaneComponent', 'toastComponent', 'overlaysComponent', 'dialogsComponent', diff --git a/tests/tui/tui-50-50-layout.test.ts b/tests/tui/tui-50-50-layout.test.ts new file mode 100644 index 00000000..31b9fda7 --- /dev/null +++ b/tests/tui/tui-50-50-layout.test.ts @@ -0,0 +1,483 @@ +/** + * Integration test for the 50/50 split layout with metadata and details panes. + * + * Exercises: + * - Selection propagation: selecting an item updates the MetadataPane and detail pane + * - Comment creation: adding a comment updates the comments view and #comments in metadata + * - Tab/Shift-Tab focus cycling between the three panes + * + * Related work item: WL-0MLORPQUE1B7X8C3 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TuiController } from '../../src/tui/controller.js'; +import { MetadataPaneComponent } from '../../src/tui/components/metadata-pane.js'; + +// ── Blessed mock helpers ────────────────────────────────────────────── + +const makeBox = () => ({ + hidden: true, + width: 0, + height: 0, + style: { border: {} as Record, label: {} as Record, selected: {} }, + show: vi.fn(function () { (this as any).hidden = false; }), + hide: vi.fn(function () { (this as any).hidden = true; }), + focus: vi.fn(), + setFront: vi.fn(), + setContent: vi.fn(), + getContent: vi.fn(() => ''), + setLabel: vi.fn(), + setItems: vi.fn(), + select: vi.fn(), + getItem: vi.fn(() => undefined), + on: vi.fn(), + key: vi.fn(), + setScroll: vi.fn(), + setScrollPerc: vi.fn(), + getScroll: vi.fn(() => 0), + pushLine: vi.fn(), + clearValue: vi.fn(), + setValue: vi.fn(), + getValue: vi.fn(() => ''), + moveCursor: vi.fn(), +}); + +const makeList = () => { + const list = makeBox() as any; + let selected = 0; + let items: string[] = []; + list.setItems = vi.fn((next: string[]) => { + items = next.slice(); + list.items = items.map(value => ({ getContent: () => value })); + }); + list.select = vi.fn((idx: number) => { selected = idx; }); + Object.defineProperty(list, 'selected', { + get: () => selected, + set: (value: number) => { selected = value; }, + }); + list.getItem = vi.fn((idx: number) => { + const value = items[idx]; + return value ? { getContent: () => value } : undefined; + }); + list.items = [] as any[]; + return list; +}; + +const makeScreen = () => ({ + height: 40, + width: 120, + focused: null as any, + render: vi.fn(), + destroy: vi.fn(), + key: vi.fn(), + on: vi.fn(), +}); + +// ── Factory for test items ──────────────────────────────────────────── + +function makeItem(id: string, parentId: string | null = null) { + const now = new Date().toISOString(); + return { + id, + title: `Item ${id}`, + description: 'Test description', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId, + createdAt: now, + updatedAt: now, + tags: ['test'], + assignee: 'alice', + stage: 'prd_complete', + issueType: 'task', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + needsProducerReview: false, + }; +} + +// ── Layout builder ──────────────────────────────────────────────────── + +function buildLayout(screen: any) { + const list = makeList(); + const footer = makeBox(); + const detail = makeBox(); + const copyIdButton = makeBox(); + const metadataBox = makeBox(); + const updateFromItemMock = vi.fn(); + const overlays = { + detailOverlay: makeBox(), + closeOverlay: makeBox(), + updateOverlay: makeBox(), + }; + const dialogs = { + detailModal: makeBox(), + detailClose: makeBox(), + closeDialog: makeBox(), + closeDialogText: makeBox(), + closeDialogOptions: makeList(), + updateDialog: makeBox(), + updateDialogText: makeBox(), + updateDialogOptions: makeList(), + updateDialogStageOptions: makeList(), + updateDialogStatusOptions: makeList(), + updateDialogPriorityOptions: makeList(), + updateDialogComment: makeBox(), + }; + const helpMenu = { + isVisible: vi.fn(() => false), + show: vi.fn(), + hide: vi.fn(), + }; + const modalDialogs = { + selectList: vi.fn(async () => 0), + editTextarea: vi.fn(async () => null), + confirmTextbox: vi.fn(async () => false), + forceCleanup: vi.fn(), + }; + const opencodeUi = { + serverStatusBox: makeBox(), + dialog: makeBox(), + textarea: makeBox(), + suggestionHint: makeBox(), + sendButton: makeBox(), + cancelButton: makeBox(), + ensureResponsePane: vi.fn(() => makeBox()), + }; + + return { + screen, + list, + detail, + metadataBox, + updateFromItemMock, + opencodeDialog: opencodeUi.dialog, + opencodeText: opencodeUi.textarea, + layout: { + screen, + listComponent: { getList: () => list, getFooter: () => footer }, + detailComponent: { getDetail: () => detail, getCopyIdButton: () => copyIdButton }, + metadataPaneComponent: { getBox: () => metadataBox, updateFromItem: updateFromItemMock }, + toastComponent: { show: vi.fn() } as any, + overlaysComponent: overlays, + dialogsComponent: dialogs, + helpMenu, + modalDialogs, + opencodeUi, + nextDialog: { + overlay: makeBox(), + dialog: makeBox(), + close: makeBox(), + text: makeBox(), + options: makeList(), + }, + }, + }; +} + +// ── Context builder ─────────────────────────────────────────────────── + +function buildCtx(items: any[], comments: any[] = []) { + const createCommentMock = vi.fn(); + const getCommentsMock = vi.fn(() => comments); + return { + ctx: { + program: { opts: () => ({ verbose: false }) }, + utils: { + requireInitialized: vi.fn(), + getDatabase: vi.fn(() => ({ + list: () => items, + getPrefix: () => 'test-prefix', + getCommentsForWorkItem: getCommentsMock, + update: () => ({}), + createComment: createCommentMock, + get: (id: string) => items.find(i => i.id === id) ?? null, + })), + }, + } as any, + createCommentMock, + getCommentsMock, + }; +} + +class FakeOpencodeClient { + getStatus() { return { status: 'stopped', port: 9999 }; } + startServer() { return Promise.resolve(true); } + stopServer() { return undefined; } + sendPrompt() { return Promise.resolve(); } +} + +// ── Helper to get screen.key handlers ──────────────────────────────── + +function getKeyHandler(mockFn: ReturnType, keyOrEvent: string | string[]): ((...args: any[]) => any) | null { + const calls = mockFn.mock.calls; + for (const call of calls) { + const registeredKeys = call[0]; + const handler = call[1]; + if (typeof registeredKeys === 'string') { + if (registeredKeys === keyOrEvent) return handler; + } + if (Array.isArray(registeredKeys) && Array.isArray(keyOrEvent)) { + if (keyOrEvent.some(k => registeredKeys.includes(k))) return handler; + } + if (Array.isArray(registeredKeys) && typeof keyOrEvent === 'string') { + if (registeredKeys.includes(keyOrEvent)) return handler; + } + } + return null; +} + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('TUI 50/50 split layout', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('layout includes metadataPaneComponent', async () => { + const item = makeItem('WL-LAYOUT-1'); + const screen = makeScreen(); + const { layout } = buildLayout(screen); + + expect(layout.metadataPaneComponent).toBeDefined(); + expect(typeof layout.metadataPaneComponent.getBox).toBe('function'); + expect(typeof layout.metadataPaneComponent.updateFromItem).toBe('function'); + }); + + it('selecting an item updates the metadata pane', async () => { + const item = makeItem('WL-SELECT-1'); + const screen = makeScreen(); + const { layout, list, updateFromItemMock } = buildLayout(screen); + const { ctx } = buildCtx([item]); + + const controller = new TuiController(ctx, { + createLayout: () => layout as any, + OpencodeClient: FakeOpencodeClient as any, + resolveWorklogDir: () => '/tmp/test-worklog', + createPersistence: () => ({ + loadPersistedState: async () => null, + savePersistedState: async () => undefined, + statePath: '/tmp/tui-state.json', + }), + }); + + await controller.start({}); + + // After start, the metadata pane should have been updated with the selected item + expect(updateFromItemMock).toHaveBeenCalled(); + const [calledItem] = updateFromItemMock.mock.calls[0]; + expect(calledItem).toMatchObject({ id: item.id }); + }); + + it('metadata pane shows comment count', async () => { + const item = makeItem('WL-COMMENT-COUNT-1'); + const comments = [ + { id: 'c1', workItemId: item.id, comment: 'First comment', author: '@user', createdAt: new Date().toISOString() }, + { id: 'c2', workItemId: item.id, comment: 'Second comment', author: '@user', createdAt: new Date().toISOString() }, + ]; + const screen = makeScreen(); + const { layout, updateFromItemMock } = buildLayout(screen); + const { ctx } = buildCtx([item], comments); + + const controller = new TuiController(ctx, { + createLayout: () => layout as any, + OpencodeClient: FakeOpencodeClient as any, + resolveWorklogDir: () => '/tmp/test-worklog', + createPersistence: () => ({ + loadPersistedState: async () => null, + savePersistedState: async () => undefined, + statePath: '/tmp/tui-state.json', + }), + }); + + await controller.start({}); + + // updateFromItem should be called with the comment count (2) + expect(updateFromItemMock).toHaveBeenCalled(); + const [, commentCount] = updateFromItemMock.mock.calls[0]; + expect(commentCount).toBe(2); + }); + + it('Tab key cycles focus forward (list → metadata → detail)', async () => { + const item = makeItem('WL-TAB-1'); + const screen = makeScreen(); + const { layout, list, detail, metadataBox } = buildLayout(screen); + const { ctx } = buildCtx([item]); + + const controller = new TuiController(ctx, { + createLayout: () => layout as any, + OpencodeClient: FakeOpencodeClient as any, + resolveWorklogDir: () => '/tmp/test-worklog', + createPersistence: () => ({ + loadPersistedState: async () => null, + savePersistedState: async () => undefined, + statePath: '/tmp/tui-state.json', + }), + }); + + await controller.start({}); + + // Tab handler should be registered + const tabHandler = getKeyHandler(screen.key as ReturnType, ['tab', 'C-i']); + expect(tabHandler).not.toBeNull(); + + // Initial focus on list + expect(list.style.border.fg).toBe('green'); + + // Tab: list → metadata + tabHandler!('', { name: 'tab' }); + expect(metadataBox.style.border.fg).toBe('green'); + expect(list.style.border.fg).toBe('white'); + + // Tab: metadata → detail + tabHandler!('', { name: 'tab' }); + expect(detail.style.border.fg).toBe('green'); + expect(metadataBox.style.border.fg).toBe('white'); + + // Tab: detail → list (wrap) + tabHandler!('', { name: 'tab' }); + expect(list.style.border.fg).toBe('green'); + expect(detail.style.border.fg).toBe('white'); + }); + + it('Shift-Tab key cycles focus backward (list → detail → metadata)', async () => { + const item = makeItem('WL-STAB-1'); + const screen = makeScreen(); + const { layout, list, detail, metadataBox } = buildLayout(screen); + const { ctx } = buildCtx([item]); + + const controller = new TuiController(ctx, { + createLayout: () => layout as any, + OpencodeClient: FakeOpencodeClient as any, + resolveWorklogDir: () => '/tmp/test-worklog', + createPersistence: () => ({ + loadPersistedState: async () => null, + savePersistedState: async () => undefined, + statePath: '/tmp/tui-state.json', + }), + }); + + await controller.start({}); + + // Shift-Tab handler should be registered + const shiftTabHandler = getKeyHandler(screen.key as ReturnType, ['S-tab', 'C-S-i']); + expect(shiftTabHandler).not.toBeNull(); + + // Initial focus on list + expect(list.style.border.fg).toBe('green'); + + // Shift-Tab: list → detail (wrap backward) + shiftTabHandler!('', { name: 'S-tab' }); + expect(detail.style.border.fg).toBe('green'); + expect(list.style.border.fg).toBe('white'); + + // Shift-Tab: detail → metadata + shiftTabHandler!('', { name: 'S-tab' }); + expect(metadataBox.style.border.fg).toBe('green'); + expect(detail.style.border.fg).toBe('white'); + + // Shift-Tab: metadata → list + shiftTabHandler!('', { name: 'S-tab' }); + expect(list.style.border.fg).toBe('green'); + expect(metadataBox.style.border.fg).toBe('white'); + }); + + it('Tab/Shift-Tab do not fire when a dialog is open', async () => { + const item = makeItem('WL-TAB-DIALOG-1'); + const screen = makeScreen(); + const { layout, list } = buildLayout(screen); + const { ctx } = buildCtx([item]); + + const controller = new TuiController(ctx, { + createLayout: () => layout as any, + OpencodeClient: FakeOpencodeClient as any, + resolveWorklogDir: () => '/tmp/test-worklog', + createPersistence: () => ({ + loadPersistedState: async () => null, + savePersistedState: async () => undefined, + statePath: '/tmp/tui-state.json', + }), + }); + + await controller.start({}); + + // Simulate a dialog being open + layout.dialogsComponent.updateDialog.hidden = false; + + const tabHandler = getKeyHandler(screen.key as ReturnType, ['tab', 'C-i']); + expect(tabHandler).not.toBeNull(); + + // Tab should not change focus while dialog is open + tabHandler!('', { name: 'tab' }); + expect(list.style.border.fg).toBe('green'); // still on list + }); + + it('MetadataPaneComponent.updateFromItem formats metadata correctly', () => { + // Create a mock blessed factory + let capturedContent = ''; + const mockBox = { + setContent: vi.fn((c: string) => { capturedContent = c; }), + on: vi.fn(), + key: vi.fn(), + focus: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + destroy: vi.fn(), + removeAllListeners: vi.fn(), + style: {}, + }; + const mockBlessed = { + box: vi.fn(() => mockBox), + }; + const mockScreen = { on: vi.fn() }; + + const comp = new MetadataPaneComponent({ parent: mockScreen as any, blessed: mockBlessed as any }).create(); + + comp.updateFromItem({ + status: 'in-progress', + stage: 'prd_complete', + priority: 'high', + tags: ['backend', 'feature'], + assignee: 'alice', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-06-01T00:00:00Z', + }, 3); + + expect(capturedContent).toContain('Status:'); + expect(capturedContent).toContain('in-progress'); + expect(capturedContent).toContain('Priority:'); + expect(capturedContent).toContain('high'); + expect(capturedContent).toContain('Comments: 3'); + expect(capturedContent).toContain('Tags:'); + expect(capturedContent).toContain('backend'); + expect(capturedContent).toContain('Assignee:'); + expect(capturedContent).toContain('alice'); + }); + + it('MetadataPaneComponent.updateFromItem clears content for null item', () => { + let capturedContent = 'initial'; + const mockBox = { + setContent: vi.fn((c: string) => { capturedContent = c; }), + on: vi.fn(), + key: vi.fn(), + focus: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + destroy: vi.fn(), + removeAllListeners: vi.fn(), + style: {}, + }; + const mockBlessed = { + box: vi.fn(() => mockBox), + }; + const mockScreen = { on: vi.fn() }; + + const comp = new MetadataPaneComponent({ parent: mockScreen as any, blessed: mockBlessed as any }).create(); + comp.updateFromItem(null, 0); + + expect(capturedContent).toBe(''); + }); +}); From 075e733db81a93c3576e4c1158ab754bbcdf51b6 Mon Sep 17 00:00:00 2001 From: rgardler-msft Date: Sun, 1 Mar 2026 20:54:21 -0800 Subject: [PATCH 4/5] WL-0MLORPQUE1B7X8C3: Fix PR review issues - null-safe metadataPane, UTC dates, consistent rows - Replace unsafe 'as any' chain with null-safe optional chaining for metadataPane extraction - Add null guard inside setMetadataBorderFocusStyle to prevent crashes when metadataPane is absent - Simplify applyFocusStyles/applyFocusStylesForPane by removing redundant ternaries - Add formatDate static method using UTC methods for timezone-independent rendering - Make all metadata rows render consistently (Tags, Assignee, Created, Updated always present) - Replace @ts-ignore with explicit typed cast in destroy() - Remove doubled blank line at controller.ts:2817 - Add test assertions for date formatting, row count, and empty-field rendering --- src/tui/components/metadata-pane.ts | 34 ++++++++++++---------- src/tui/controller.ts | 9 +++--- tests/tui/tui-50-50-layout.test.ts | 45 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/tui/components/metadata-pane.ts b/src/tui/components/metadata-pane.ts index db834993..3f53525d 100644 --- a/src/tui/components/metadata-pane.ts +++ b/src/tui/components/metadata-pane.ts @@ -47,6 +47,19 @@ export class MetadataPaneComponent { return this.box; } + private static formatDate(value: Date | string | undefined): string { + if (!value) return ''; + const d = typeof value === 'string' ? new Date(value) : value; + if (isNaN(d.getTime())) return String(value); + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const mon = months[d.getUTCMonth()]; + const day = d.getUTCDate(); + const year = d.getUTCFullYear(); + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mm = String(d.getUTCMinutes()).padStart(2, '0'); + return `${mon} ${day}, ${year} ${hh}:${mm}`; + } + updateFromItem(item: { status?: string; stage?: string; @@ -65,18 +78,10 @@ export class MetadataPaneComponent { lines.push(`Stage: ${item.stage ?? ''}`); lines.push(`Priority: ${item.priority ?? ''}`); lines.push(`Comments: ${commentCount}`); - if (item.tags && item.tags.length > 0) { - lines.push(`Tags: ${item.tags.join(', ')}`); - } - if (item.assignee) { - lines.push(`Assignee: ${item.assignee}`); - } - if (item.createdAt) { - lines.push(`Created: ${String(item.createdAt)}`); - } - if (item.updatedAt) { - lines.push(`Updated: ${String(item.updatedAt)}`); - } + lines.push(`Tags: ${item.tags && item.tags.length > 0 ? item.tags.join(', ') : ''}`); + lines.push(`Assignee: ${item.assignee ?? ''}`); + lines.push(`Created: ${MetadataPaneComponent.formatDate(item.createdAt)}`); + lines.push(`Updated: ${MetadataPaneComponent.formatDate(item.updatedAt)}`); this.box.setContent(lines.join('\n')); } @@ -97,9 +102,8 @@ export class MetadataPaneComponent { } destroy(): void { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (typeof this.box.removeAllListeners === 'function') this.box.removeAllListeners(); + const box = this.box as unknown as { removeAllListeners?: () => void; destroy: () => void }; + if (typeof box.removeAllListeners === 'function') box.removeAllListeners(); this.box.destroy(); } } diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 3423d8cb..6f78d6fb 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -191,7 +191,7 @@ export class TuiController { const help = listComponent.getFooter(); const detail = detailComponent.getDetail(); const copyIdButton = detailComponent.getCopyIdButton(); - const metadataPane = (metadataPaneComponent as any)?.getBox?.() ?? (metadataPaneComponent as any)?.box ?? null; + const metadataPane = metadataPaneComponent?.getBox?.() ?? null; const detailOverlay = overlaysComponent.detailOverlay; const detailModal = dialogsComponent.detailModal; @@ -632,7 +632,7 @@ export class TuiController { }; const setMetadataBorderFocusStyle = (focused: boolean) => { - setBorderFocusStyle(metadataPane as unknown as Pane, focused); + if (metadataPane) setBorderFocusStyle(metadataPane as unknown as Pane, focused); }; const setOpencodeBorderFocusStyle = (focused: boolean) => { @@ -701,14 +701,14 @@ export class TuiController { const applyFocusStyles = () => { const active = getFocusPanes()[paneFocusIndex]; setListBorderFocusStyle(active === list); - setMetadataBorderFocusStyle(metadataPane ? active === metadataPane : false); + setMetadataBorderFocusStyle(active === metadataPane); setDetailBorderFocusStyle(active === detail); setOpencodeBorderFocusStyle(active === opencodeDialog); }; const applyFocusStylesForPane = (pane: any) => { setListBorderFocusStyle(pane === list); - setMetadataBorderFocusStyle(metadataPane ? pane === metadataPane : false); + setMetadataBorderFocusStyle(pane === metadataPane); setDetailBorderFocusStyle(pane === detail); setOpencodeBorderFocusStyle(pane === opencodeDialog); }; @@ -2815,7 +2815,6 @@ export class TuiController { }); } catch (_) {} - // Open opencode prompt dialog (shortcut O) screen.key(KEY_OPEN_OPENCODE, async () => { if (state.moveMode) return; diff --git a/tests/tui/tui-50-50-layout.test.ts b/tests/tui/tui-50-50-layout.test.ts index 31b9fda7..67978650 100644 --- a/tests/tui/tui-50-50-layout.test.ts +++ b/tests/tui/tui-50-50-layout.test.ts @@ -455,6 +455,14 @@ describe('TUI 50/50 split layout', () => { expect(capturedContent).toContain('backend'); expect(capturedContent).toContain('Assignee:'); expect(capturedContent).toContain('alice'); + // Dates should be formatted as human-friendly strings + expect(capturedContent).toContain('Created:'); + expect(capturedContent).toContain('Jan 1, 2024'); + expect(capturedContent).toContain('Updated:'); + expect(capturedContent).toContain('Jun 1, 2024'); + // All rows should always be present (consistent layout) + const lines = capturedContent.split('\n'); + expect(lines.length).toBe(8); }); it('MetadataPaneComponent.updateFromItem clears content for null item', () => { @@ -480,4 +488,41 @@ describe('TUI 50/50 split layout', () => { expect(capturedContent).toBe(''); }); + + it('MetadataPaneComponent.updateFromItem renders all rows even when fields are empty', () => { + let capturedContent = ''; + const mockBox = { + setContent: vi.fn((c: string) => { capturedContent = c; }), + on: vi.fn(), + key: vi.fn(), + focus: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + destroy: vi.fn(), + removeAllListeners: vi.fn(), + style: {}, + }; + const mockBlessed = { + box: vi.fn(() => mockBox), + }; + const mockScreen = { on: vi.fn() }; + + const comp = new MetadataPaneComponent({ parent: mockScreen as any, blessed: mockBlessed as any }).create(); + comp.updateFromItem({ + status: 'open', + stage: '', + priority: 'medium', + tags: [], + assignee: '', + }, 0); + + // All 8 rows should always be present for consistent layout + const lines = capturedContent.split('\n'); + expect(lines.length).toBe(8); + expect(capturedContent).toContain('Status:'); + expect(capturedContent).toContain('Tags:'); + expect(capturedContent).toContain('Assignee:'); + expect(capturedContent).toContain('Created:'); + expect(capturedContent).toContain('Updated:'); + }); }); From 138cbd33a896a45134051b48df7c97c9db31c6ef Mon Sep 17 00:00:00 2001 From: rgardler-msft Date: Sun, 1 Mar 2026 21:05:59 -0800 Subject: [PATCH 5/5] WL-0MLORPQUE1B7X8C3: Remove metadata from description pane, keep in dialog The description pane now shows only title, description, and comments (metadata is in the dedicated metadata pane). The detail modal dialog retains the full format with all metadata fields. - Add 'detail-pane' format to humanFormatWorkItem() rendering title, description, and comments only - Switch updateDetailForIndex() from 'full' to 'detail-pane' - Keep openDetailsForId() using 'full' so dialogs include metadata --- src/commands/helpers.ts | 27 +++++++++++++++++++++++++++ src/tui/controller.ts | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index d43236fd..0e055e89 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -285,6 +285,33 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null, return lines.join('\n'); } + // detail-pane: title + description + comments only (metadata is in the metadata pane) + if (fmt === 'detail-pane') { + lines.push(renderTitle(item, '# ')); + + if (item.description) { + lines.push(''); + lines.push('## Description'); + lines.push(''); + lines.push(item.description); + } + + if (db) { + const comments = db.getCommentsForWorkItem(item.id); + if (comments.length > 0) { + lines.push(''); + lines.push('## Comments'); + lines.push(''); + for (const c of comments) { + lines.push(` ${c.author} at ${c.createdAt}`); + lines.push(` ${c.comment}`); + } + } + } + + return lines.join('\n'); + } + // full output lines.push(renderTitle(item, '# ')); lines.push(''); diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 6f78d6fb..35e08aa0 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -1685,7 +1685,7 @@ export class TuiController { return; } const node = v[idx] || v[0]; - const text = humanFormatWorkItem(node.item, db, 'full'); + const text = humanFormatWorkItem(node.item, db, 'detail-pane'); const escaped = escapeBlessedTags(text); const brightened = brightenDetailIdLine(escaped); detail.setContent(decorateIdsForClick(brightened));