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
82 changes: 82 additions & 0 deletions src/tui/components/update-dialog-README.md
Original file line number Diff line number Diff line change
@@ -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: <title> │
│ 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.
14 changes: 12 additions & 2 deletions src/tui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 (_) {}

Expand Down
126 changes: 126 additions & 0 deletions tests/tui/tui-update-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading