Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions src/commands/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down
9 changes: 5 additions & 4 deletions src/tui/components/detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/tui/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions src/tui/components/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions src/tui/components/metadata-pane.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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;
}

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;
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}`);
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'));
}

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 {
const box = this.box as unknown as { removeAllListeners?: () => void; destroy: () => void };
if (typeof box.removeAllListeners === 'function') box.removeAllListeners();
this.box.destroy();
}
}
38 changes: 37 additions & 1 deletion src/tui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export class TuiController {
screen,
listComponent,
detailComponent,
metadataPaneComponent,
toastComponent,
overlaysComponent,
dialogsComponent,
Expand All @@ -190,6 +191,7 @@ export class TuiController {
const help = listComponent.getFooter();
const detail = detailComponent.getDetail();
const copyIdButton = detailComponent.getCopyIdButton();
const metadataPane = metadataPaneComponent?.getBox?.() ?? null;

const detailOverlay = overlaysComponent.detailOverlay;
const detailModal = dialogsComponent.detailModal;
Expand Down Expand Up @@ -629,6 +631,10 @@ export class TuiController {
setBorderFocusStyle(list, focused);
};

const setMetadataBorderFocusStyle = (focused: boolean) => {
if (metadataPane) setBorderFocusStyle(metadataPane as unknown as Pane, focused);
};

const setOpencodeBorderFocusStyle = (focused: boolean) => {
setBorderFocusStyle(opencodeDialog, focused);
};
Expand All @@ -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;
Expand All @@ -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;
};
Expand Down Expand Up @@ -693,12 +701,14 @@ export class TuiController {
const applyFocusStyles = () => {
const active = getFocusPanes()[paneFocusIndex];
setListBorderFocusStyle(active === list);
setMetadataBorderFocusStyle(active === metadataPane);
setDetailBorderFocusStyle(active === detail);
setOpencodeBorderFocusStyle(active === opencodeDialog);
};

const applyFocusStylesForPane = (pane: any) => {
setListBorderFocusStyle(pane === list);
setMetadataBorderFocusStyle(pane === metadataPane);
setDetailBorderFocusStyle(pane === detail);
setOpencodeBorderFocusStyle(pane === opencodeDialog);
};
Expand Down Expand Up @@ -1671,14 +1681,20 @@ 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];
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));
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
Expand Down Expand Up @@ -2778,6 +2794,26 @@ 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 () => {
Expand Down
11 changes: 10 additions & 1 deletion src/tui/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DialogsComponent,
HelpMenuComponent,
ListComponent,
MetadataPaneComponent,
ModalDialogsComponent,
OpencodePaneComponent,
OverlaysComponent,
Expand All @@ -42,6 +43,7 @@ export interface TuiLayout {
// Component instances
listComponent: ListComponent;
detailComponent: DetailComponent;
metadataPaneComponent: MetadataPaneComponent;
toastComponent: ToastComponent;
overlaysComponent: OverlaysComponent;
dialogsComponent: DialogsComponent;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -220,6 +228,7 @@ export function createLayout(options: CreateLayoutOptions = {}): TuiLayout {
screen,
listComponent,
detailComponent,
metadataPaneComponent,
toastComponent,
overlaysComponent,
dialogsComponent,
Expand Down
Loading