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
455 changes: 328 additions & 127 deletions .worklog/plugins/ampa.mjs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ Options:
`-c, --children` — Also display descendants in a tree layout (optional).
`--prefix <prefix>` (optional)

The output always includes `Risk` and `Effort` fields. When a field has no value a placeholder `—` is shown so the field is consistently visible for triage and prioritization.

Examples:

```sh
Expand Down
1 change: 1 addition & 0 deletions TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This document describes the interactive terminal UI shipped as the `wl tui` (or
- The TUI presents a tree view of work items on the left and a details pane on the right.
- It can show all items, or be limited to in-progress items via `--in-progress`.
- The details pane uses the same human formatter as the CLI so what you see in the TUI matches `wl show --format full`.
- The metadata pane (top-right) shows Status, Stage, Priority, Risk, Effort, Comments, Tags, Assignee, Created, Updated, and GitHub link for the selected item. `Risk` and `Effort` always appear; when a field has no value a placeholder `—` is shown.
- Integrated OpenCode AI assistant for intelligent work item management and coding assistance.

## Controls
Expand Down
14 changes: 8 additions & 6 deletions src/commands/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ export function displayItemTree(items: WorkItem[]): void {
? `Status: ${item.status} · Stage: ${effectiveStage} | Priority: ${item.priority}`
: `Status: ${item.status} | Priority: ${item.priority}`;
console.log(`${detailIndent}${statusSummary}`);
if (item.risk) console.log(`${detailIndent}Risk: ${item.risk}`);
if (item.effort) console.log(`${detailIndent}Effort: ${item.effort}`);
console.log(`${detailIndent}Risk: ${item.risk || '—'}`);
console.log(`${detailIndent}Effort: ${item.effort || '—'}`);
if (item.assignee) console.log(`${detailIndent}Assignee: ${item.assignee}`);
if (item.tags.length > 0) console.log(`${detailIndent}Tags: ${item.tags.join(', ')}`);
}
Expand Down Expand Up @@ -257,8 +257,8 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null,
lines.push(`Status: ${statusLabel} | Priority: ${item.priority}`);
}
lines.push(sortIndexLabel);
if (item.risk) lines.push(`Risk: ${item.risk}`);
if (item.effort) lines.push(`Effort: ${item.effort}`);
lines.push(`Risk: ${item.risk || '—'}`);
lines.push(`Effort: ${item.effort || '—'}`);
if (item.assignee) lines.push(`Assignee: ${item.assignee}`);
if (item.tags && item.tags.length > 0) lines.push(`Tags: ${item.tags.join(', ')}`);
return lines.join('\n');
Expand All @@ -277,8 +277,8 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null,
lines.push(`Status: ${statusLabel} | Priority: ${item.priority}`);
}
lines.push(sortIndexLabel);
if (item.risk) lines.push(`Risk: ${item.risk}`);
if (item.effort) lines.push(`Effort: ${item.effort}`);
lines.push(`Risk: ${item.risk || '—'}`);
lines.push(`Effort: ${item.effort || '—'}`);
if (item.assignee) lines.push(`Assignee: ${item.assignee}`);
if (item.parentId) lines.push(`Parent: ${item.parentId}`);
if (item.description) lines.push(`Description: ${item.description}`);
Expand Down Expand Up @@ -323,7 +323,9 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null,
['SortIndex', String(item.sortIndex)]
];
if (item.risk) frontmatter.push(['Risk', item.risk]);
else frontmatter.push(['Risk', '—']);
if (item.effort) frontmatter.push(['Effort', item.effort]);
else frontmatter.push(['Effort', '—']);
if (item.assignee) frontmatter.push(['Assignee', item.assignee]);
if (item.parentId) frontmatter.push(['Parent', item.parentId]);
if (item.tags && item.tags.length > 0) frontmatter.push(['Tags', item.tags.join(', ')]);
Expand Down
20 changes: 18 additions & 2 deletions src/tui/chords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export class ChordHandler {
}

private scheduleClear(): void {
// Clear any previously scheduled timeout before creating a new one so
// we don't accumulate overlapping timers when scheduleClear is called
// repeatedly (for example when duplicate physical events re-schedule
// the leader timeout).
if (this.timer) clearTimeout(this.timer as any);
this.timer = setTimeout(() => {
if (this.pendingHandler) {
Expand All @@ -81,10 +85,16 @@ export class ChordHandler {
try { console.error(`[chords] feed key=${JSON.stringify(key)} -> normalized='${k}', pending=${JSON.stringify(this.pending)}, timer=${this.timer ? 'set' : 'null'}`); } catch (_) {}
}
// if there is an in-flight pending short-handler timer, cancel it
// Preserve any previously-set pendingHandler: a duplicate physical
// key event should not drop a deferred handler. We will clear the
// timer but keep the handler so the later scheduleClear() call will
// invoke it unless the logic replaces it explicitly.
let prevPendingHandler: Handler | null = null;
if (this.timer) {
clearTimeout(this.timer as any);
this.timer = null;
this.pendingHandler = null;
prevPendingHandler = this.pendingHandler;
// do not set this.pendingHandler = null here; preserve it
}

const nextPending = [...this.pending, k];
Expand All @@ -106,11 +116,17 @@ export class ChordHandler {
// state. This avoids cycles where a duplicate leader clears the
// pending state and prevents the intended follow-up key from
// matching.
const lp = this.pending[this.pending.length - 1];
const lastIsSameAsNew = nextPending.length > 1 && nextPending[nextPending.length - 1] === nextPending[nextPending.length - 2];
if (lastIsSameAsNew) {
if (dbg) try { console.error(`[chords] duplicate key '${k}' ignored (pending=${JSON.stringify(this.pending)})`); } catch (_) {}
// Consume the duplicate event but keep pending as-is.
// Restore preserved pendingHandler (if any) and re-schedule
// the leader timeout so the deferred handler still runs after
// the original timeout period even if the timer was cleared
// by the duplicate physical event.
if (prevPendingHandler) this.pendingHandler = prevPendingHandler;
// ensure a timeout is active to eventually invoke pendingHandler
this.scheduleClear();
return true;
}

Expand Down
5 changes: 5 additions & 0 deletions src/tui/components/metadata-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export class MetadataPaneComponent {
status?: string;
stage?: string;
priority?: string;
risk?: string;
effort?: string;
tags?: string[];
assignee?: string;
createdAt?: Date | string;
Expand All @@ -75,10 +77,13 @@ export class MetadataPaneComponent {
this.box.setContent('');
return;
}
const placeholder = '—';
const lines: string[] = [];
lines.push(`Status: ${item.status ?? ''}`);
lines.push(`Stage: ${item.stage ?? ''}`);
lines.push(`Priority: ${item.priority ?? ''}`);
lines.push(`Risk: ${item.risk && item.risk.trim() ? item.risk : placeholder}`);
lines.push(`Effort: ${item.effort && item.effort.trim() ? item.effort : placeholder}`);
lines.push(`Comments: ${commentCount}`);
lines.push(`Tags: ${item.tags && item.tags.length > 0 ? item.tags.join(', ') : ''}`);
lines.push(`Assignee: ${item.assignee ?? ''}`);
Expand Down
109 changes: 103 additions & 6 deletions src/tui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,24 @@ export class TuiController {
widget._reading = false;
}
} catch (_) {}
try { (screen as any).grabKeys = false; } catch (_) {}
try { (screen as any).program?.hideCursor?.(); } catch (_) {}
try {
// Prefer blessed API when available; fall back to property assignment
if (typeof (screen as any).grabKeys === 'function') {
try { (screen as any).grabKeys(false); } catch (_) { (screen as any).grabKeys = false; }
} else {
(screen as any).grabKeys = false;
}
} catch (err) {
// best-effort cleanup; log when verbose to help diagnose issues
try { debugLog(`endUpdateDialogCommentReading: failed to clear grabKeys: ${String(err)}`); } catch (_) {}
}
try {
if (typeof (screen as any).program?.hideCursor === 'function') {
(screen as any).program.hideCursor();
}
} catch (err) {
try { debugLog(`endUpdateDialogCommentReading: failed to hide cursor: ${String(err)}`); } catch (_) {}
}
};

const startUpdateDialogCommentReading = () => {
Expand Down Expand Up @@ -539,12 +555,23 @@ export class TuiController {
// Clear any pending state held by the chord handler (leader+wait)
try { chordHandler.reset(); } catch (_) {}
};
const endOpencodeTextReading = () => {
// Best-effort cleanup: widget lifecycle differs across blessed versions
// and test doubles, so failures here should not block user input flow.
try {
const widget = opencodeText as any;
if (typeof widget?.cancel === 'function') widget.cancel();
} catch (_) {}
try { (screen as any).grabKeys = false; } catch (_) {}
try { (screen as any).program?.hideCursor?.(); } catch (_) {}
};

// Register Ctrl-W chord handlers
if (chordDebug) console.error('[tui] registering ctrl-w chord handlers');
chordHandler.register(['C-w', 'w'], () => {
if (helpMenu.isVisible()) return;
if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return;
endOpencodeTextReading();
clearCtrlWPending();
cycleFocus(1);
screen.render();
Expand All @@ -553,6 +580,7 @@ export class TuiController {
chordHandler.register(['C-w', 'p'], () => {
if (helpMenu.isVisible()) return;
if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return;
endOpencodeTextReading();
clearCtrlWPending();
focusPaneByIndex(lastPaneFocusIndex);
screen.render();
Expand All @@ -565,6 +593,7 @@ export class TuiController {
chordHandler.register(['C-w', 'h'], () => {
if (helpMenu.isVisible()) return;
if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return;
endOpencodeTextReading();
clearCtrlWPending();
const current = getActivePaneIndex();
focusPaneByIndex(current - 1);
Expand All @@ -574,6 +603,7 @@ export class TuiController {
chordHandler.register(['C-w', 'l'], () => {
if (helpMenu.isVisible()) return;
if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return;
endOpencodeTextReading();
clearCtrlWPending();
const current = getActivePaneIndex();
focusPaneByIndex(current + 1);
Expand Down Expand Up @@ -601,6 +631,7 @@ export class TuiController {
if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return;
if (opencodeDialog.hidden) return;
if (!opencodePane || (opencodePane as any).hidden) return;
endOpencodeTextReading();
clearCtrlWPending();
(opencodePane as Pane).focus?.();
syncFocusFromScreen();
Expand Down Expand Up @@ -1214,6 +1245,7 @@ export class TuiController {
function closeOpencodeDialog() {
// In compact mode, don't hide the dialog - it stays as the input bar
// Just clear the input and keep it open
endOpencodeTextReading();
try { if (typeof opencodeText.clearValue === 'function') opencodeText.clearValue(); } catch (_) {}
try { if (typeof opencodeText.setValue === 'function') opencodeText.setValue(''); } catch (_) {}
setOpencodeCursorIndex('', 0);
Expand All @@ -1223,6 +1255,7 @@ export class TuiController {
}

function closeOpencodePane() {
endOpencodeTextReading();
if (opencodePane) {
opencodePane.hide();
}
Expand Down Expand Up @@ -1485,6 +1518,7 @@ export class TuiController {

// Add Escape key handler to close the opencode dialog
const opencodeTextEscapeHandler = function(this: any) {
endOpencodeTextReading();
opencodeDialog.hide();
if (opencodePane) {
opencodePane.hide();
Expand Down Expand Up @@ -1571,6 +1605,7 @@ export class TuiController {
// to the main list. Use a named handler so it can be removed during
// cleanup in tests that repeatedly create/destroy dialogs.
const opencodeDialogEscapeHandler = () => {
endOpencodeTextReading();
opencodeDialog.hide();
if (opencodePane) {
opencodePane.hide();
Expand Down Expand Up @@ -2030,13 +2065,75 @@ export class TuiController {
const dataPath = getDefaultDataPath();
const dataDir = pathImpl.dirname(dataPath);
const dataFile = pathImpl.basename(dataPath);
const readDataMtimeMs = () => {
try {
return fsImpl.statSync(dataPath).mtimeMs;
} catch (err) {
debugLog(`Failed to read data file mtime for watch event filtering: ${String(err)}`);
return null;
}
};
let lastKnownDataMtimeMs = readDataMtimeMs();
try {
dataWatcher = fsImpl.watch(dataDir, (eventType, filename) => {
// Use a lightweight debounce and avoid synchronous fs.statSync in
// the watch handler which can block the event loop under heavy
// filesystem activity. We schedule an async check to compare mtime
// and only trigger a refresh when the file's mtime actually changed.
let watchDebounce: ReturnType<typeof setTimeout> | null = null;
// Initialize lastSeenMtimeMs from the current known mtime so we do
// not trigger a refresh for the first watch callback when the file
// has not actually changed since startup. Previously this was
// initialized to null which caused an extra refresh call in tests
// that expect no-op behavior when mtime is unchanged.
let lastSeenMtimeMs: number | null = lastKnownDataMtimeMs;
dataWatcher = fsImpl.watch(dataDir, (_eventType, filename) => {
if (isShuttingDown) return;
if (eventType !== 'change' && eventType !== 'rename') return;
if (filename && filename !== dataFile) return;
const selectedIndex = typeof list.selected === 'number' ? (list.selected as number) : 0;
scheduleRefreshFromDatabase(selectedIndex);
// debounce rapid successive watch callbacks
if (watchDebounce) clearTimeout(watchDebounce);
watchDebounce = setTimeout(async () => {
watchDebounce = null;
try {
// Prefer using injected statSync when available because tests
// commonly mock it. If statSync exists but throws, treat the
// failure as a transient error and do NOT fall back to the
// async stat path (which may observe a different file) to
// avoid scheduling spurious refreshes. Only attempt the
// async stat when statSync is not present on the injected
// fsImpl.
let stat: fs.Stats | null = null;
const hasSync = typeof (fsImpl as any).statSync === 'function';
if (hasSync) {
try {
stat = (fsImpl as any).statSync(dataPath);
} catch (e) {
// statSync exists but failed — ignore this watch event
// rather than attempting async stat which can cause
// inconsistent results in tests.
return;
}
} else {
stat = await fsAsync.stat(dataPath).catch(() => null);
}
const mtimeMs = stat?.mtimeMs ?? null;
if (mtimeMs === null) {
// Could not read mtime (stat failed) — ignore this watch
// event rather than triggering a refresh. Transient stat
// failures should not cause spurious refreshes.
return;
}
if (lastSeenMtimeMs === null || mtimeMs !== lastSeenMtimeMs) {
lastSeenMtimeMs = mtimeMs;
const selectedIndex = typeof list.selected === 'number' ? (list.selected as number) : 0;
scheduleRefreshFromDatabase(selectedIndex);
}
} catch (err) {
// best-effort; log when verbose
try { debugLog(`startDatabaseWatch: watch handler error: ${String(err)}`); } catch (_) {}
const selectedIndex = typeof list.selected === 'number' ? (list.selected as number) : 0;
scheduleRefreshFromDatabase(selectedIndex);
}
}, 75);
});
} catch (_) {
dataWatcher = null;
Expand Down
21 changes: 21 additions & 0 deletions test/tui-chords.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,25 @@ describe('ChordHandler', () => {
expect(p2).toBe(true);
expect(abCalled).toBe(true);
});

it('preserves deferred handler when duplicate physical key events arrive', async () => {
const c = new ChordHandler({ timeoutMs: 30 });
let leaderHandlerCalled = false;
// Register a leader handler with a follow-up so the leader is deferred
c.register(['C-w'], () => { leaderHandlerCalled = true; });
c.register(['C-w', 'w'], () => {});

// feed Ctrl-W to set pending and deferred handler
const p1 = c.feed({ name: 'w', ctrl: true });
expect(p1).toBe(true);
expect(leaderHandlerCalled).toBe(false);

// simulate a duplicate physical delivery of the same leader key (e.g. raw+wrapper)
const pDup = c.feed({ name: 'w', ctrl: true });
expect(pDup).toBe(true);

// wait for timeout to elapse and allow deferred handler to run
await new Promise(res => setTimeout(res, 50));
expect(leaderHandlerCalled).toBe(true);
});
});
Loading
Loading