From 59135ed3e4875d8ec9439f52b31b5c8b61367582 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:24:15 +0000 Subject: [PATCH 1/2] Initial plan From 76512107725a2ed75899f472b43ede23c45e5cea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:33:14 +0000 Subject: [PATCH 2/2] Fix keyboard navigation in update dialog: correct tab order, add Escape handlers, add tests and docs Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- src/tui/components/update-dialog-README.md | 82 ++++++++++++++ src/tui/controller.ts | 14 ++- tests/tui/tui-update-dialog.test.ts | 126 +++++++++++++++++++++ 3 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 src/tui/components/update-dialog-README.md diff --git a/src/tui/components/update-dialog-README.md b/src/tui/components/update-dialog-README.md new file mode 100644 index 0000000..cf8c107 --- /dev/null +++ b/src/tui/components/update-dialog-README.md @@ -0,0 +1,82 @@ +# Update Dialog — Keyboard Interaction Reference + +The Update Work Item dialog (`updateDialog`) lets users change the **Status**, **Stage**, and **Priority** of a work item and optionally add a comment before saving. + +## Visual Layout + +``` +┌─ Update Work Item ────────────────────────────────────────────┐ +│ Update: │ +│ ID: <id> Status: <s> · Stage: <s> · Priority: <p> │ +│ │ +│ Status Stage Priority │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ open │ │ idea │ │ critical │ │ +│ │ in-progress│ │ prd_done │ │ high │ │ +│ │ blocked │ │ … │ │ medium │ │ +│ │ … │ │ │ │ low │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ ┌─ Comment ───────────────────────────────────────────────┐ │ +│ │ (multiline text area) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ +``` + +## Tab Order + +Focus moves left-to-right through the three selection columns and then to the comment area: + +| # | Control | Notes | +|---|---------------|--------------------------------------| +| 1 | Status list | Initial focus when dialog opens | +| 2 | Stage list | | +| 3 | Priority list | | +| 4 | Comment box | Multiline textarea | + +- **Tab** advances focus to the next control (wraps from Comment → Status). +- **Shift+Tab** moves focus to the previous control (wraps from Status → Comment). +- **← / →** also moves focus left or right between the three lists (and comment area). + +## Per-Control Keyboard Semantics + +### Selection lists (Status, Stage, Priority) + +The three lists are treated as already-open interactive areas — they do not need to be "opened" first. + +| Key | Action | +|-----------------|-----------------------------------------------------| +| ↑ / ↓ | Navigate list options | +| Enter | Confirm selection and **save** the dialog | +| Escape | **Close** the dialog without saving | +| Tab | Move focus to the next control | +| Shift+Tab | Move focus to the previous control | +| ← / → | Move focus to the adjacent list / comment area | + +### Comment textarea + +| Key | Action | +|-----------------|--------------------------------------------------------------| +| (type) | Insert characters | +| Ctrl+J / Ctrl+M | Insert a newline | +| Enter | **Save** the dialog (field + comment) | +| Escape | **Close** the dialog without saving | +| Tab | Move focus to the next control (Status list, wrapping) | +| Shift+Tab | Move focus to the previous control (Priority list) | + +### Dialog-level keys (active regardless of focused child) + +| Key | Action | +|-----------------|----------------------------| +| Enter | Save the dialog | +| Ctrl+S | Save the dialog | +| Escape | Close without saving | +| Tab | Cycle focus forward | +| Shift+Tab | Cycle focus backward | + +## Behaviour Notes + +- Escape is registered on the dialog box **and** on each of the three selection lists and the comment textarea independently, so it reliably closes the dialog regardless of which widget currently holds focus. +- Arrow key navigation inside lists is provided by blessed's built-in `keys: true` option. +- When the dialog opens it focuses the **Status** list (leftmost column) so keyboard users can immediately navigate. +- Clicking the overlay area behind the dialog triggers an unsaved-changes confirmation before closing. diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 8aa8057..b18e89f 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -212,12 +212,14 @@ export class TuiController { const updateDialogStatusOptions = dialogsComponent.updateDialogStatusOptions; const updateDialogPriorityOptions = dialogsComponent.updateDialogPriorityOptions; const updateDialogComment = dialogsComponent.updateDialogComment; + // Tab order matches the visual left-to-right column layout: Status → Stage → Priority → Comment const updateDialogFieldOrder = [ - updateDialogStageOptions, updateDialogStatusOptions, + updateDialogStageOptions, updateDialogPriorityOptions, updateDialogComment, ]; + // Layout order used for Left/Right key navigation (same as Tab order for consistency) const updateDialogFieldLayout = [ updateDialogStatusOptions, updateDialogStageOptions, @@ -1855,7 +1857,7 @@ export class TuiController { updateOverlay.setFront(); updateDialog.setFront(); updateDialogFocusManager.focusIndex(0); - updateDialogStageOptions.focus(); + updateDialogStatusOptions.focus(); applyUpdateDialogFocusStyles(updateDialogFieldOrder[0]); paneFocusIndex = getFocusPanes().indexOf(list); applyFocusStyles(); @@ -3317,9 +3319,17 @@ export class TuiController { const updateDialogEscapeHandler = () => { closeUpdateDialog(); }; try { (updateDialog as any).__opencode_key_escape = updateDialogEscapeHandler; updateDialog.key(KEY_ESCAPE, updateDialogEscapeHandler); } catch (_) {} + // Escape closes the dialog from any of the three inline selection lists. + // updateDialogOptions aliases updateDialogStageOptions, so both are covered. const updateDialogOptionsEscapeHandler = () => { closeUpdateDialog(); }; try { (updateDialogOptions as any).__opencode_key_escape = updateDialogOptionsEscapeHandler; updateDialogOptions.key(KEY_ESCAPE, updateDialogOptionsEscapeHandler); } catch (_) {} + const updateDialogStatusEscapeHandler = () => { closeUpdateDialog(); }; + try { (updateDialogStatusOptions as any).__opencode_key_escape = updateDialogStatusEscapeHandler; updateDialogStatusOptions.key(KEY_ESCAPE, updateDialogStatusEscapeHandler); } catch (_) {} + + const updateDialogPriorityEscapeHandler = () => { closeUpdateDialog(); }; + try { (updateDialogPriorityOptions as any).__opencode_key_escape = updateDialogPriorityEscapeHandler; updateDialogPriorityOptions.key(KEY_ESCAPE, updateDialogPriorityEscapeHandler); } catch (_) {} + const updateDialogCommentEscapeHandler = () => { closeUpdateDialog(); }; try { (updateDialogComment as any).__opencode_key_escape = updateDialogCommentEscapeHandler; updateDialogComment.key(KEY_ESCAPE, updateDialogCommentEscapeHandler); } catch (_) {} diff --git a/tests/tui/tui-update-dialog.test.ts b/tests/tui/tui-update-dialog.test.ts index f88a76e..1dd2f6b 100644 --- a/tests/tui/tui-update-dialog.test.ts +++ b/tests/tui/tui-update-dialog.test.ts @@ -370,6 +370,132 @@ describe('TUI Update Dialog', () => { screen.destroy(); }); + + it('should follow visual left-to-right tab order: Status -> Stage -> Priority -> Comment', () => { + const screen = blessed.screen({ mouse: true, smartCSR: true }); + + // Order matches visual layout: Status (left), Stage (middle), Priority (right), Comment (bottom) + const statusList = blessed.list({ parent: screen, items: statusLabels.slice(0, 2) }); + const stageList = blessed.list({ parent: screen, items: stageLabels.slice(0, 2) }); + const priorityList = blessed.list({ parent: screen, items: ['high', 'low'] }); + const commentBox = blessed.textarea({ parent: screen, inputOnFocus: true }); + + const fieldOrder = [statusList, stageList, priorityList, commentBox]; + const focusManager = createUpdateDialogFocusManager(fieldOrder); + + // Start at Status (index 0) + focusManager.focusIndex(0); + expect(focusManager.getIndex()).toBe(0); + + // Tab -> Stage + focusManager.cycle(1); + expect(focusManager.getIndex()).toBe(1); + + // Tab -> Priority + focusManager.cycle(1); + expect(focusManager.getIndex()).toBe(2); + + // Tab -> Comment + focusManager.cycle(1); + expect(focusManager.getIndex()).toBe(3); + + // Tab wraps back to Status + focusManager.cycle(1); + expect(focusManager.getIndex()).toBe(0); + + // Shift+Tab from Status wraps to Comment + focusManager.cycle(-1); + expect(focusManager.getIndex()).toBe(3); + + screen.destroy(); + }); + + it('should wrap focus correctly at boundaries', () => { + const screen = blessed.screen({ mouse: true, smartCSR: true }); + + const statusList = blessed.list({ parent: screen, items: statusLabels.slice(0, 2) }); + const stageList = blessed.list({ parent: screen, items: stageLabels.slice(0, 2) }); + const priorityList = blessed.list({ parent: screen, items: ['high', 'low'] }); + const commentBox = blessed.textarea({ parent: screen, inputOnFocus: true }); + + const focusManager = createUpdateDialogFocusManager([statusList, stageList, priorityList, commentBox]); + + // From last field, Tab wraps to first + focusManager.focusIndex(3); + focusManager.cycle(1); + expect(focusManager.getIndex()).toBe(0); + + // From first field, Shift+Tab wraps to last + focusManager.focusIndex(0); + focusManager.cycle(-1); + expect(focusManager.getIndex()).toBe(3); + + screen.destroy(); + }); + }); + + describe('Update Dialog Escape Key Behavior', () => { + it('should close dialog when Escape is pressed on any of the three selection lists', () => { + const screen = blessed.screen({ mouse: true, smartCSR: true }); + + const statusList = blessed.list({ parent: screen, items: statusLabels.slice(0, 2), keys: true }); + const stageList = blessed.list({ parent: screen, items: stageLabels.slice(0, 2), keys: true }); + const priorityList = blessed.list({ parent: screen, items: ['high', 'low'], keys: true }); + + let closeCount = 0; + const closeUpdateDialog = () => { closeCount += 1; }; + + // Simulate registering Escape handlers on each list as done in controller.ts + const statusEscapeHandler = () => { closeUpdateDialog(); }; + const stageEscapeHandler = () => { closeUpdateDialog(); }; + const priorityEscapeHandler = () => { closeUpdateDialog(); }; + + (statusList as any).__opencode_key_escape = statusEscapeHandler; + (stageList as any).__opencode_key_escape = stageEscapeHandler; + (priorityList as any).__opencode_key_escape = priorityEscapeHandler; + + // Trigger Escape on each list — all must close the dialog + statusEscapeHandler(); + expect(closeCount).toBe(1); + + stageEscapeHandler(); + expect(closeCount).toBe(2); + + priorityEscapeHandler(); + expect(closeCount).toBe(3); + + // Verify handler references are stored on all three lists + expect((statusList as any).__opencode_key_escape).toBeDefined(); + expect((stageList as any).__opencode_key_escape).toBeDefined(); + expect((priorityList as any).__opencode_key_escape).toBeDefined(); + + screen.destroy(); + }); + + it('should simulate Escape keypress event on status and priority lists via blessed emit', () => { + const screen = blessed.screen({ mouse: true, smartCSR: true }); + + const statusList = blessed.list({ parent: screen, items: statusLabels.slice(0, 2), keys: true }); + const priorityList = blessed.list({ parent: screen, items: ['high', 'low'], keys: true }); + + let closeCount = 0; + const closeUpdateDialog = () => { closeCount += 1; }; + + statusList.on('keypress', (_ch: unknown, key: { name?: string } | undefined) => { + if (key?.name === 'escape') closeUpdateDialog(); + }); + priorityList.on('keypress', (_ch: unknown, key: { name?: string } | undefined) => { + if (key?.name === 'escape') closeUpdateDialog(); + }); + + statusList.emit('keypress', '', { name: 'escape', full: 'escape' }); + expect(closeCount).toBe(1); + + priorityList.emit('keypress', '', { name: 'escape', full: 'escape' }); + expect(closeCount).toBe(2); + + screen.destroy(); + }); }); describe('Update Dialog Comment Handling', () => {